323 lines
12 KiB
TypeScript
323 lines
12 KiB
TypeScript
import { DeviceDriver, ElementLocator, Rect, Platform } from './types';
|
|
import { APP_CONFIG } from '../config/app.config';
|
|
|
|
export class AndroidDriver implements DeviceDriver {
|
|
readonly platform: Platform = 'android';
|
|
private host: string;
|
|
private port: number;
|
|
private sessionId: string | null = null;
|
|
private baseUrl: string;
|
|
|
|
constructor(host = 'localhost', port = 4723) {
|
|
this.host = host;
|
|
this.port = port;
|
|
this.baseUrl = `http://${host}:${port}`;
|
|
}
|
|
|
|
private get sessionUrl(): string {
|
|
if (!this.sessionId) throw new Error('No active Appium session');
|
|
return `${this.baseUrl}/session/${this.sessionId}`;
|
|
}
|
|
|
|
private async request(method: string, path: string, body?: any): Promise<any> {
|
|
const url = path.startsWith('/') ? `${this.baseUrl}${path}` : path;
|
|
const opts: RequestInit = {
|
|
method,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
};
|
|
if (body !== undefined) opts.body = JSON.stringify(body);
|
|
const resp = await fetch(url, opts);
|
|
const json = await resp.json();
|
|
if (json.value && json.value.error) {
|
|
throw new Error(`Appium: ${json.value.message || json.value.error}`);
|
|
}
|
|
return json.value;
|
|
}
|
|
|
|
async createSession(): Promise<void> {
|
|
const capabilities = {
|
|
alwaysMatch: {
|
|
platformName: 'Android',
|
|
'appium:automationName': 'UiAutomator2',
|
|
'appium:noReset': true,
|
|
'appium:autoLaunch': false,
|
|
'appium:newCommandTimeout': 300,
|
|
'appium:uiautomator2ServerInstallTimeout': 60000,
|
|
},
|
|
};
|
|
const result = await this.request('POST', '/session', { capabilities });
|
|
this.sessionId = result.sessionId;
|
|
await this.request('POST', `/session/${this.sessionId}/appium/device/activate_app`, { appId: APP_CONFIG.android.appPackage });
|
|
await new Promise(r => setTimeout(r, 3000));
|
|
}
|
|
|
|
async destroySession(): Promise<void> {
|
|
if (!this.sessionId) return;
|
|
await this.request('DELETE', `/session/${this.sessionId}`);
|
|
this.sessionId = null;
|
|
}
|
|
|
|
async activateApp(appId: string): Promise<void> {
|
|
await this.request('POST', `/session/${this.sessionId}/appium/device/activate_app`, { appId });
|
|
await new Promise(r => setTimeout(r, 2000));
|
|
}
|
|
|
|
async findElement(locator: ElementLocator): Promise<string | null> {
|
|
if (!locator.android) return null;
|
|
return this.findElementRaw(locator.android.using, locator.android.value);
|
|
}
|
|
|
|
async findElements(locator: ElementLocator): Promise<string[]> {
|
|
if (!locator.android) return [];
|
|
return this.findElementsRaw(locator.android.using, locator.android.value);
|
|
}
|
|
|
|
private mapStrategy(using: string, value: string): { using: string; value: string } {
|
|
if (using === 'name' || using === 'text') {
|
|
return { using: '-android uiautomator', value: `new UiSelector().text("${value}")` };
|
|
}
|
|
if (using === 'accessibility id' || using === 'content-desc') {
|
|
return { using: 'accessibility id', value };
|
|
}
|
|
if (using === 'id') {
|
|
return { using: 'id', value: value.includes(':id/') ? value : `com.theswitchbot.switchbot:id/${value}` };
|
|
}
|
|
if (using === 'predicate string') {
|
|
const textMatch = value.match(/name\s*(?:==|CONTAINS)\s*"([^"]+)"/);
|
|
if (textMatch) {
|
|
const text = textMatch[1];
|
|
const contains = value.includes('CONTAINS');
|
|
return { using: '-android uiautomator', value: contains
|
|
? `new UiSelector().textContains("${text}")`
|
|
: `new UiSelector().text("${text}")` };
|
|
}
|
|
return { using: '-android uiautomator', value: `new UiSelector().textContains("${value}")` };
|
|
}
|
|
if (using === 'class name') {
|
|
const classMap: Record<string, string> = {
|
|
'XCUIElementTypeTextField': 'android.widget.EditText',
|
|
'XCUIElementTypeSecureTextField': 'android.widget.EditText',
|
|
'XCUIElementTypeStaticText': 'android.widget.TextView',
|
|
'XCUIElementTypeButton': 'android.widget.Button',
|
|
'XCUIElementTypeSwitch': 'android.widget.Switch',
|
|
'XCUIElementTypeCell': 'android.widget.LinearLayout',
|
|
'XCUIElementTypeImage': 'android.widget.ImageView',
|
|
};
|
|
const androidClass = classMap[value] || value;
|
|
return { using: 'class name', value: androidClass };
|
|
}
|
|
return { using, value };
|
|
}
|
|
|
|
async findElementRaw(using: string, value: string): Promise<string | null> {
|
|
try {
|
|
const mapped = this.mapStrategy(using, value);
|
|
const result = await this.request('POST', `/session/${this.sessionId}/element`, mapped);
|
|
return result.ELEMENT || result['element-6066-11e4-a52e-4f735466cecf'] || null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async findElementsRaw(using: string, value: string): Promise<string[]> {
|
|
try {
|
|
const mapped = this.mapStrategy(using, value);
|
|
const result = await this.request('POST', `/session/${this.sessionId}/elements`, mapped);
|
|
if (!Array.isArray(result)) return [];
|
|
return result.map((e: any) => e.ELEMENT || e['element-6066-11e4-a52e-4f735466cecf']).filter(Boolean);
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
async getElementRect(elementId: string): Promise<Rect> {
|
|
const result = await this.request('GET', `/session/${this.sessionId}/element/${elementId}/rect`);
|
|
return { x: result.x, y: result.y, width: result.width, height: result.height };
|
|
}
|
|
|
|
async getElementAttribute(elementId: string, attr: string): Promise<string> {
|
|
const result = await this.request('GET', `/session/${this.sessionId}/element/${elementId}/attribute/${attr}`);
|
|
return result || '';
|
|
}
|
|
|
|
async tap(x: number, y: number): Promise<void> {
|
|
await this.request('POST', `/session/${this.sessionId}/actions`, {
|
|
actions: [{
|
|
type: 'pointer',
|
|
id: 'finger1',
|
|
parameters: { pointerType: 'touch' },
|
|
actions: [
|
|
{ type: 'pointerMove', duration: 0, x: Math.round(x), y: Math.round(y) },
|
|
{ type: 'pointerDown', button: 0 },
|
|
{ type: 'pause', duration: 100 },
|
|
{ type: 'pointerUp', button: 0 },
|
|
],
|
|
}],
|
|
});
|
|
}
|
|
|
|
async doubleTap(x: number, y: number): Promise<void> {
|
|
await this.tap(x, y);
|
|
await new Promise(r => setTimeout(r, 100));
|
|
await this.tap(x, y);
|
|
}
|
|
|
|
async longPress(x: number, y: number, duration = 2): Promise<void> {
|
|
await this.request('POST', `/session/${this.sessionId}/actions`, {
|
|
actions: [{
|
|
type: 'pointer',
|
|
id: 'finger1',
|
|
parameters: { pointerType: 'touch' },
|
|
actions: [
|
|
{ type: 'pointerMove', duration: 0, x: Math.round(x), y: Math.round(y) },
|
|
{ type: 'pointerDown', button: 0 },
|
|
{ type: 'pause', duration: Math.round(duration * 1000) },
|
|
{ type: 'pointerUp', button: 0 },
|
|
],
|
|
}],
|
|
});
|
|
}
|
|
|
|
async tapElement(elementId: string): Promise<void> {
|
|
await this.request('POST', `/session/${this.sessionId}/element/${elementId}/click`, {});
|
|
}
|
|
|
|
async clickElement(elementId: string): Promise<void> {
|
|
await this.request('POST', `/session/${this.sessionId}/element/${elementId}/click`, {});
|
|
}
|
|
|
|
async typeText(elementId: string, text: string): Promise<void> {
|
|
await this.tapElement(elementId);
|
|
await new Promise(r => setTimeout(r, 300));
|
|
await this.request('POST', `/session/${this.sessionId}/element/${elementId}/value`, { text });
|
|
}
|
|
|
|
async clearText(elementId: string): Promise<void> {
|
|
await this.request('POST', `/session/${this.sessionId}/element/${elementId}/clear`, {});
|
|
}
|
|
|
|
async swipe(fromX: number, fromY: number, toX: number, toY: number, duration = 0.5): Promise<void> {
|
|
const durationMs = Math.round(duration * 1000);
|
|
await this.request('POST', `/session/${this.sessionId}/actions`, {
|
|
actions: [{
|
|
type: 'pointer',
|
|
id: 'finger1',
|
|
parameters: { pointerType: 'touch' },
|
|
actions: [
|
|
{ type: 'pointerMove', duration: 0, x: Math.round(fromX), y: Math.round(fromY) },
|
|
{ type: 'pointerDown', button: 0 },
|
|
{ type: 'pointerMove', duration: durationMs, x: Math.round(toX), y: Math.round(toY) },
|
|
{ type: 'pointerUp', button: 0 },
|
|
],
|
|
}],
|
|
});
|
|
}
|
|
|
|
async scrollDown(distance = 300): Promise<void> {
|
|
const size = await this.getWindowSize();
|
|
const startX = size.width / 2;
|
|
const startY = size.height / 2;
|
|
await this.swipe(startX, startY, startX, startY - distance, 0.5);
|
|
}
|
|
|
|
async scrollUp(distance = 300): Promise<void> {
|
|
const size = await this.getWindowSize();
|
|
const startX = size.width / 2;
|
|
const startY = size.height / 2;
|
|
await this.swipe(startX, startY, startX, startY + distance, 0.5);
|
|
}
|
|
|
|
async goBack(): Promise<void> {
|
|
await this.request('POST', `/session/${this.sessionId}/back`, {});
|
|
}
|
|
|
|
async getSource(): Promise<string> {
|
|
return await this.request('GET', `/session/${this.sessionId}/source`);
|
|
}
|
|
|
|
async getWindowSize(): Promise<{ width: number; height: number }> {
|
|
const result = await this.request('GET', `/session/${this.sessionId}/window/rect`);
|
|
return { width: result.width, height: result.height };
|
|
}
|
|
|
|
async screenshot(): Promise<string> {
|
|
return await this.request('GET', `/session/${this.sessionId}/screenshot`);
|
|
}
|
|
|
|
async tapByLocator(locator: ElementLocator): Promise<boolean> {
|
|
const elemId = await this.findElement(locator);
|
|
if (!elemId) return false;
|
|
await this.tapElement(elemId);
|
|
return true;
|
|
}
|
|
|
|
async waitForElement(locator: ElementLocator, timeoutMs = 10000): Promise<string | null> {
|
|
const start = Date.now();
|
|
while (Date.now() - start < timeoutMs) {
|
|
const elemId = await this.findElement(locator);
|
|
if (elemId) return elemId;
|
|
await new Promise(r => setTimeout(r, 500));
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async isElementVisible(locator: ElementLocator): Promise<boolean> {
|
|
const elemId = await this.findElement(locator);
|
|
if (!elemId) return false;
|
|
const displayed = await this.getElementAttribute(elemId, 'displayed');
|
|
return displayed === 'true';
|
|
}
|
|
|
|
async findBotCard(): Promise<string | null> {
|
|
return this.findDeviceCard('Bot');
|
|
}
|
|
|
|
async findDeviceCard(deviceName: string): Promise<string | null> {
|
|
let el = await this.findElementRaw('-android uiautomator',
|
|
`new UiSelector().textContains("${deviceName}")`);
|
|
if (el) return el;
|
|
el = await this.findElementRaw('-android uiautomator',
|
|
`new UiScrollable(new UiSelector().scrollable(true)).scrollIntoView(new UiSelector().textContains("${deviceName}"))`);
|
|
return el;
|
|
}
|
|
|
|
async isOnHomepage(): Promise<boolean> {
|
|
const source = await this.getSource();
|
|
return source.includes('content-desc="Home"') && source.includes('content-desc="Profile"');
|
|
}
|
|
|
|
async goBackToHomepage(): Promise<boolean> {
|
|
for (let i = 0; i < 6; i++) {
|
|
if (await this.isOnHomepage()) {
|
|
const homeTab = await this.findElementRaw('accessibility id', 'Home');
|
|
if (homeTab) await this.tapElement(homeTab);
|
|
await new Promise(r => setTimeout(r, 500));
|
|
return true;
|
|
}
|
|
await this.goBack();
|
|
await new Promise(r => setTimeout(r, 800));
|
|
}
|
|
return await this.isOnHomepage();
|
|
}
|
|
|
|
async dismissPopupIfPresent(): Promise<boolean> {
|
|
const source = await this.getSource();
|
|
if (source.includes('android.app.Dialog') || source.includes('permission') ||
|
|
source.includes('Upgrade') || source.includes("What's New") ||
|
|
source.includes('AlertDialog') || source.includes('Please Note') ||
|
|
source.includes('Restart') || source.includes('Got it')) {
|
|
const dismissTexts = ['OK', 'Got it', 'Close', 'Cancel', 'Confirm', 'Allow',
|
|
'Later', 'Skip', 'Done', 'ALLOW', 'DENY', 'Not Now'];
|
|
for (const text of dismissTexts) {
|
|
const el = await this.findElementRaw('-android uiautomator', `new UiSelector().text("${text}")`);
|
|
if (el) {
|
|
await this.tapElement(el);
|
|
await new Promise(r => setTimeout(r, 1000));
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
}
|