383 lines
14 KiB
TypeScript
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();
|
|
}
|
|
}
|