AI_UIAutomation/utils/wda-helper.ts

383 lines
14 KiB
TypeScript

import * as http from 'http';
export class WDAHelper {
private host: string;
private port: number;
private sessionId: string | null = null;
constructor(host = 'localhost', port = 8100) {
this.host = host;
this.port = port;
}
private request(method: string, path: string, body?: any): Promise<any> {
return new Promise((resolve, reject) => {
const options = {
hostname: this.host,
port: this.port,
path: path,
method: method,
headers: { 'Content-Type': 'application/json' },
};
const req = http.request(options, (res) => {
let data = '';
res.on('data', (chunk) => (data += chunk));
res.on('end', () => {
try { resolve(JSON.parse(data)); } catch { resolve(data); }
});
});
req.on('error', reject);
if (body) req.write(JSON.stringify(body));
req.end();
});
}
private async requestWithRetry(method: string, path: string, body?: any): Promise<any> {
try {
return await this.request(method, path, body);
} catch (e: any) {
if (e.code === 'ECONNREFUSED' || e.code === 'ECONNRESET') {
await this.recoverWDA();
return await this.request(method, path, body);
}
throw e;
}
}
private async recoverWDA(): Promise<void> {
// Recreate Appium session to restart WDA
const appiumReq = (method: string, path: string, body?: any): Promise<any> => {
return new Promise((resolve, reject) => {
const options = {
hostname: 'localhost', port: 4723, path, method,
headers: { 'Content-Type': 'application/json' },
};
const req = http.request(options, (res) => {
let data = '';
res.on('data', (chunk) => (data += chunk));
res.on('end', () => { try { resolve(JSON.parse(data)); } catch { resolve(data); } });
});
req.on('error', reject);
if (body) req.write(JSON.stringify(body));
req.end();
});
};
const res = await appiumReq('POST', '/session', {
capabilities: { alwaysMatch: {
platformName: 'iOS', 'appium:automationName': 'XCUITest',
'appium:udid': '00008110-001A34303AE9801E',
'appium:bundleId': 'com.wohand.wohand',
'appium:noReset': true, 'appium:wdaLocalPort': 8100,
'appium:newCommandTimeout': 1800,
}}
});
// Wait for WDA to become ready
await new Promise(r => setTimeout(r, 3000));
// Get new WDA session
const sessionsRes = await this.request('GET', '/sessions');
const sessions = sessionsRes?.value;
if (Array.isArray(sessions) && sessions.length > 0) {
this.sessionId = sessions[0].id;
}
}
async createSession(): Promise<string> {
// Try to reuse existing WDA session first (avoids killing Appium-managed session)
try {
// Method 1: Try /sessions endpoint
const sessionsRes = await this.request('GET', '/sessions');
const sessions = sessionsRes?.value;
if (Array.isArray(sessions) && sessions.length > 0) {
this.sessionId = sessions[0].id;
await this.request('GET', `/session/${this.sessionId}/window/rect`);
this.reusedSession = true;
await this.activateApp();
return this.sessionId!;
}
// Method 2: Extract sessionId from any response that includes it
if (sessionsRes?.sessionId) {
this.sessionId = sessionsRes.sessionId;
await this.request('GET', `/session/${this.sessionId}/window/rect`);
this.reusedSession = true;
await this.activateApp();
return this.sessionId!;
}
} catch (e: any) {
if (e.code === 'ECONNREFUSED' || e.code === 'ECONNRESET') {
await this.recoverWDA();
if (this.sessionId) {
this.reusedSession = true;
await this.activateApp();
return this.sessionId;
}
}
}
// Method 3: Try /status which also returns sessionId
try {
const statusRes = await this.request('GET', '/status');
if (statusRes?.sessionId) {
this.sessionId = statusRes.sessionId;
await this.request('GET', `/session/${this.sessionId}/window/rect`);
this.reusedSession = true;
await this.activateApp();
return this.sessionId!;
}
} catch {}
const res = await this.request('POST', '/session', {
capabilities: {
alwaysMatch: {
platformName: 'iOS',
automationName: 'XCUITest',
shouldUseSingletonTestManager: false,
shouldUseTestManagerForVisibilityDetection: false,
},
},
});
this.sessionId = res.value?.sessionId || res.sessionId;
await this.activateApp();
return this.sessionId!;
}
private reusedSession = false;
private async activateApp(): Promise<void> {
try {
await this.request('POST', `/session/${this.sessionId}/wda/apps/activate`, {
bundleId: 'com.wohand.wohand',
});
await new Promise(r => setTimeout(r, 3000));
} catch {}
}
/** 激活任意 bundleId 的 app(用于切到系统设置 com.apple.Preferences 再切回)。 */
async activateAppById(bundleId: string): Promise<void> {
await this.request('POST', `/session/${this.sessionId}/wda/apps/activate`, { bundleId });
await new Promise(r => setTimeout(r, 2500));
}
async destroySession(): Promise<void> {
if (this.sessionId && !this.reusedSession) {
await this.request('DELETE', `/session/${this.sessionId}`);
}
this.sessionId = null;
this.reusedSession = false;
}
async findElement(using: string, value: string): Promise<string | null> {
const res = await this.requestWithRetry('POST', `/session/${this.sessionId}/element`, { using, value });
return res.value?.ELEMENT || res.value?.['element-6066-11e4-a52e-4f735466cecf'] || null;
}
async findElements(using: string, value: string): Promise<string[]> {
const res = await this.requestWithRetry('POST', `/session/${this.sessionId}/elements`, { using, value });
if (!res.value || !Array.isArray(res.value)) return [];
return res.value.map((e: any) => e.ELEMENT || e['element-6066-11e4-a52e-4f735466cecf']);
}
async getElementRect(elementId: string): Promise<{ x: number; y: number; width: number; height: number }> {
const res = await this.requestWithRetry('GET', `/session/${this.sessionId}/element/${elementId}/rect`);
return res.value;
}
async getElementAttribute(elementId: string, attr: string): Promise<string> {
const res = await this.requestWithRetry('GET', `/session/${this.sessionId}/element/${elementId}/attribute/${attr}`);
return res.value;
}
async tap(x: number, y: number): Promise<void> {
await this.requestWithRetry('POST', `/session/${this.sessionId}/wda/tap`, { x, y });
}
async doubleTap(x: number, y: number): Promise<void> {
await this.requestWithRetry('POST', `/session/${this.sessionId}/wda/doubleTap`, { x, y });
}
async longPress(x: number, y: number, duration = 2): Promise<void> {
await this.requestWithRetry('POST', `/session/${this.sessionId}/wda/touchAndHold`, { x, y, duration });
}
async tapElement(elementId: string): Promise<void> {
const rect = await this.getElementRect(elementId);
await this.tap(rect.x + rect.width / 2, rect.y + rect.height / 2);
}
async clickElement(elementId: string): Promise<void> {
await this.requestWithRetry('POST', `/session/${this.sessionId}/element/${elementId}/click`);
}
async tapByName(name: string): Promise<boolean> {
const elemId = await this.findElement('name', name);
if (!elemId) return false;
await this.tapElement(elemId);
return true;
}
async tapByPredicate(predicate: string): Promise<boolean> {
const elems = await this.findElements('predicate string', predicate);
if (elems.length === 0) return false;
await this.tapElement(elems[0]);
return true;
}
async swipe(fromX: number, fromY: number, toX: number, toY: number, duration = 0.5): Promise<void> {
await this.requestWithRetry('POST', `/session/${this.sessionId}/wda/dragfromtoforduration`, {
fromX, fromY, toX, toY, duration,
});
}
async scrollDown(distance = 300): Promise<void> {
const windowRes = await this.request('GET', `/session/${this.sessionId}/window/rect`);
const { width, height } = windowRes.value;
const centerX = width / 2;
const startY = height * 0.6;
const endY = startY - distance;
await this.swipe(centerX, startY, centerX, endY);
}
async scrollUp(distance = 300): Promise<void> {
const windowRes = await this.request('GET', `/session/${this.sessionId}/window/rect`);
const { width, height } = windowRes.value;
const centerX = width / 2;
const startY = height * 0.4;
const endY = startY + distance;
await this.swipe(centerX, startY, centerX, endY);
}
async getWindowSize(): Promise<{ width: number; height: number }> {
const res = await this.requestWithRetry('GET', `/session/${this.sessionId}/window/rect`);
return { width: res.value.width, height: res.value.height };
}
async getSource(): Promise<string> {
const res = await this.requestWithRetry('GET', `/session/${this.sessionId}/source`);
return typeof res.value === 'string' ? res.value : '';
}
async screenshot(): Promise<string> {
const res = await this.requestWithRetry('GET', `/session/${this.sessionId}/screenshot`);
return res.value || '';
}
async isElementVisible(name: string): Promise<boolean> {
const elemId = await this.findElement('name', name);
if (!elemId) return false;
const visible = await this.getElementAttribute(elemId, 'visible');
return visible === 'true' || visible === '1';
}
async waitForElement(name: string, timeoutMs = 10000): Promise<string | null> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const elemId = await this.findElement('name', name);
if (elemId) return elemId;
await new Promise((r) => setTimeout(r, 500));
}
return null;
}
async typeText(elementId: string, text: string): Promise<void> {
await this.request('POST', `/session/${this.sessionId}/element/${elementId}/value`, {
value: text.split(''),
});
}
async clearText(elementId: string): Promise<void> {
await this.request('POST', `/session/${this.sessionId}/element/${elementId}/clear`);
}
async findBotCard(): Promise<string | null> {
return this.findDeviceCard('Bot');
}
async findDeviceCard(deviceName: string): Promise<string | null> {
const predicates = [
`name CONTAINS "${deviceName}" AND type == "XCUIElementTypeCell"`,
];
for (const pred of predicates) {
const elems = await this.findElements('predicate string', pred);
if (elems.length > 0) return elems[0];
}
return null;
}
async isOnHomepage(): Promise<boolean> {
const source = await this.getSource();
// Exclude pages that contain "Home" in other contexts
if (source.includes('Home Assistant')) return false;
if (source.includes('Device Settings') || source.includes('Firmware Version')) return false;
// Add Device page has "Home Automation" and "Show More" - not homepage
if (source.includes('Scanning for Bluetooth') || source.includes('Add Manually')) return false;
const hasMainTabBar = source.includes('主页') || source.includes('自动化')
|| (source.includes('Home') && source.includes('More'))
|| (source.includes('Add') && source.includes('More'));
const hasPopup = source.includes('name="ON"') && source.includes('name="OFF"');
return hasMainTabBar && !hasPopup;
}
async goBackToHomepage(): Promise<boolean> {
for (let i = 0; i < 12; i++) {
const source = await this.getSource();
// Detect fullscreen mode: page source has very few elements (only "SwitchBot" + scroll bar)
const isFullscreen = source.includes('SwitchBot')
&& !source.includes('Add') && !source.includes('More')
&& !source.includes('主页') && !source.includes('自动化')
&& !source.includes('Features') && !source.includes('Direction')
&& !source.includes('Device online') && !source.includes('设备在线')
&& !source.includes('Subscribe') && !source.includes('Filter Options')
&& !source.includes('Motion detected');
if (isFullscreen) {
await this.tap(195, 422);
await new Promise((r) => setTimeout(r, 1500));
const buttons = await this.findElements('class name', 'XCUIElementTypeButton');
if (buttons.length > 0) {
await this.request('POST', `/session/${this.sessionId}/element/${buttons[0]}/click`);
} else {
await this.tap(30, 30);
}
await new Promise((r) => setTimeout(r, 2000));
continue;
}
const hasPopup = source.includes('name="ON"') && source.includes('name="OFF"');
if (hasPopup) {
await this.tap(195, 200);
await new Promise((r) => setTimeout(r, 800));
continue;
}
const isMainHome = source.includes('主页') || source.includes('自动化')
|| (source.includes('Add') && source.includes('More') && !source.includes('Direction') && !source.includes('Features') && !source.includes('Playback') && !source.includes('Home Assistant') && !source.includes('Device Settings') && !source.includes('Scanning for Bluetooth') && !source.includes('Add Manually'));
if (isMainHome) {
return true;
}
// After 5 attempts, try tapping the Home tab if visible
if (i >= 5) {
const homeTab = await this.findElement('predicate string', 'name CONTAINS "主页" OR name CONTAINS "Home"');
if (homeTab) {
await this.tapElement(homeTab);
await new Promise((r) => setTimeout(r, 1500));
continue;
}
}
// Event detail page has X close button at top-left (24, 53)
if (source.includes('View Playback') || source.includes('1/')) {
await this.tap(24, 53);
await new Promise((r) => setTimeout(r, 1500));
continue;
}
// Tap back button (top-left)
await this.tap(33, 69);
await new Promise((r) => setTimeout(r, 1500));
}
return await this.isOnHomepage();
}
}