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 { 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 { 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 { // Recreate Appium session to restart WDA const appiumReq = (method: string, path: string, body?: any): Promise => { 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 { // 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 { 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 { await this.request('POST', `/session/${this.sessionId}/wda/apps/activate`, { bundleId }); await new Promise(r => setTimeout(r, 2500)); } async destroySession(): Promise { 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 { 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 { 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 { const res = await this.requestWithRetry('GET', `/session/${this.sessionId}/element/${elementId}/attribute/${attr}`); return res.value; } async tap(x: number, y: number): Promise { await this.requestWithRetry('POST', `/session/${this.sessionId}/wda/tap`, { x, y }); } async doubleTap(x: number, y: number): Promise { await this.requestWithRetry('POST', `/session/${this.sessionId}/wda/doubleTap`, { x, y }); } async longPress(x: number, y: number, duration = 2): Promise { await this.requestWithRetry('POST', `/session/${this.sessionId}/wda/touchAndHold`, { x, y, duration }); } async tapElement(elementId: string): Promise { const rect = await this.getElementRect(elementId); await this.tap(rect.x + rect.width / 2, rect.y + rect.height / 2); } async clickElement(elementId: string): Promise { await this.requestWithRetry('POST', `/session/${this.sessionId}/element/${elementId}/click`); } async tapByName(name: string): Promise { const elemId = await this.findElement('name', name); if (!elemId) return false; await this.tapElement(elemId); return true; } async tapByPredicate(predicate: string): Promise { 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 { await this.requestWithRetry('POST', `/session/${this.sessionId}/wda/dragfromtoforduration`, { fromX, fromY, toX, toY, duration, }); } async scrollDown(distance = 300): Promise { 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 { 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 { const res = await this.requestWithRetry('GET', `/session/${this.sessionId}/source`); return typeof res.value === 'string' ? res.value : ''; } async screenshot(): Promise { const res = await this.requestWithRetry('GET', `/session/${this.sessionId}/screenshot`); return res.value || ''; } async isElementVisible(name: string): Promise { 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 { 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 { await this.request('POST', `/session/${this.sessionId}/element/${elementId}/value`, { value: text.split(''), }); } async clearText(elementId: string): Promise { await this.request('POST', `/session/${this.sessionId}/element/${elementId}/clear`); } async findBotCard(): Promise { return this.findDeviceCard('Bot'); } async findDeviceCard(deviceName: string): Promise { 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 { 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 { 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(); } }