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-001014490AE1401E', '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 { await this.applySpeedSettings(); // 先压低 WDA 等待,后续所有命令都受益 try { await this.request('POST', `/session/${this.sessionId}/wda/apps/activate`, { bundleId: 'com.wohand.wohand', }); await new Promise(r => setTimeout(r, 3000)); } catch {} } /** * iOS 提速:WDA 默认每条命令前会等 App "quiescence"(动画/网络静止)。SwitchBot RN 有常驻动画/spinner, * 导致每条命令都等满 idle 超时 → 整体极慢。关掉 idle 等待 + 动画冷却,显著提速所有命令(getSource/tap/find)。 * 通过 appium/settings 下发(非致命,旧 WDA 不支持的键忽略)。 */ private async applySpeedSettings(): Promise { try { await this.request('POST', `/session/${this.sessionId}/appium/settings`, { settings: { waitForIdleTimeout: 0, // 不等 App 静止(最大提速点) animationCoolOffTimeout: 0, // 不等动画冷却 shouldUseCompactResponses: true, }, }); } 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)); } /** 硬复位:terminate + launch SwitchBot App → 回到干净首页(用例失败残留态的终极兜底,保证不级联污染后续)。 */ async restartApp(bundleId = 'com.wohand.wohand'): Promise { try { await this.request('POST', `/session/${this.sessionId}/wda/apps/terminate`, { bundleId }); } catch { /* 忽略 */ } await new Promise(r => setTimeout(r, 1500)); try { await this.request('POST', `/session/${this.sessionId}/wda/apps/launch`, { bundleId }); } catch { try { await this.request('POST', `/session/${this.sessionId}/wda/apps/activate`, { bundleId }); } catch { /* 忽略 */ } } await new Promise(r => setTimeout(r, 3500)); } 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, }); } /** WDA 原生元素滚动(不产生触点点击,根治列表行误点)。body 可传: * {direction:'up'|'down'|'left'|'right'} 滚一屏;或 {predicateString:'name CONTAINS "X"'} / {name:'X'} 滚到该子元素可见(toVisible)。 */ async scrollElement(elementId: string, body: Record): Promise { await this.request('POST', `/session/${this.sessionId}/wda/element/${elementId}/scroll`, body); } async scrollDown(distance = 300): Promise { void distance; const { width, height } = (await this.request('GET', `/session/${this.sessionId}/window/rect`)).value; // 快速甩动(0.1s = 滚动手势,非点击/长按)。起手 0.8h 向上甩 → 下滑列表。 await this.swipe(width / 2, Math.round(height * 0.8), width / 2, Math.round(height * 0.2), 0.1); } async scrollUp(distance = 300): Promise { void distance; const { width, height } = (await this.request('GET', `/session/${this.sessionId}/window/rect`)).value; await this.swipe(width / 2, Math.round(height * 0.2), width / 2, Math.round(height * 0.8), 0.1); } 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(''), }); } /** 给 PickerWheel 整串设值(不拆字符):时间轮等需 {value:["20"]} 一次到位,typeText 的逐字符拆分对 picker 无效。 */ async setPickerValue(elementId: string, value: string): Promise { await this.request('POST', `/session/${this.sessionId}/element/${elementId}/value`, { value: [value], }); } async clearText(elementId: string): Promise { await this.request('POST', `/session/${this.sessionId}/element/${elementId}/clear`); } /** 向当前聚焦的输入框发送键盘按键。自绘/RN 输入框对 element/value 无效,需模拟真实键入。 */ async sendKeys(keys: string[]): Promise { await this.request('POST', `/session/${this.sessionId}/wda/keys`, { value: keys }); } 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; } /** 等首页渲染稳定:可见卡片**数量**连续两次不变 = 加载完成(用数量而非名,避免摄像头"Motion detected"等状态变化导致永不稳定)。最多 ~2.5s。 * 根治"页面没加载完就甩动/点击 → 落到半渲染卡上误点"。 */ private async waitHomeSettled(maxMs = 2500): Promise { let lastN = -1; const t0 = Date.now(); while (Date.now() - t0 < maxMs) { const s = await this.getSource(); const n = (s.match(/XCUIElementTypeCell/g) || []).length; if (n > 0 && n === lastN) return; // 卡片数两次一致 = 稳定 lastN = n; await new Promise((r) => setTimeout(r, 600)); } } async goBackToHomepage(): Promise { for (let i = 0; i < 12; i++) { const source = await this.getSource(); // ★ 加载守卫:App 刚重启/页面切换时 source 极稀疏(只有 "SwitchBot" 启动图,Add/More/主页 还没渲染)。 // 此时**绝不能点任何东西**(尤其别走全屏分支点 buttons[0] = 首页顶部消息铃 → 误进消息通知页),只等它渲染完。 const loading = source.trim().length < 600 || /Loading|加载|启动中/.test(source); const looksHome = source.includes('主页') || source.includes('自动化') || (source.includes('Add') && source.includes('More')); if (loading && !looksHome) { await new Promise((r) => setTimeout(r, 1500)); continue; } // Detect fullscreen mode: 真·摄像头全屏页 source **极短**(只有 "SwitchBot" 水印 + 滚动条,通常 <2500 字符)。 // ★ 必须加长度闸:否则 Preferences 页(含 SwitchBot 水印、不含 Add/More/主页,但 source 长达 1.7w 字符) // 会被误判成全屏 → 走全屏分支盲点 (195,422) 落到 App Notifications 行 → **误进消息通知页**(本次根因)。 const isFullscreen = source.length < 2500 && 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)); // ★ 找**左上角关闭/返回键**(按位置,x<60 y<110 的小按钮),不要盲点 buttons[0] // —— buttons[0] 在首页是顶部消息铃,会误进消息通知页(重启后误触根因)。 const btns = await this.findElements('class name', 'XCUIElementTypeButton'); let tapped = false; for (const b of btns.slice(0, 8)) { const r = await this.getElementRect(b).catch(() => null); if (r && r.width > 0 && r.x < 60 && r.y > 25 && r.y < 110 && r.width < 90) { await this.request('POST', `/session/${this.sessionId}/element/${b}/click`).catch(() => {}); tapped = true; break; } } if (!tapped) 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) { await this.waitHomeSettled(); // 等首页卡片渲染稳定再返回 → 之后找卡/甩动不会落到半渲染卡上误点 return true; } // 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):优先找导航栏左上角**真实返回按钮元素**(添加完成后停在设备功能页,需点返回回首页)。 let tappedBack = false; const navBtns = await this.findElements('class name', 'XCUIElementTypeButton').catch(() => [] as string[]); for (const b of navBtns.slice(0, 8)) { const r = await this.getElementRect(b).catch(() => null); if (r && r.width > 0 && r.x < 60 && r.y > 25 && r.y < 110 && r.width < 90) { // 左上角小按钮 = 返回键 await this.request('POST', `/session/${this.sessionId}/element/${b}/click`).catch(() => {}); tappedBack = true; break; } } if (!tappedBack) { // 无左上返回键 = 多半是**根 tab 页**(Profile/Automations,按钮都在右上)→ 点底部 "Home/主页" tab 直接回首页。 // 用**精确** name(避免 "Manage Homes"/"Home App"/"Home Assistant" 误匹配)+ 取最底部那个(底部 tab 栏)。 const homeTabs = await this.findElements('predicate string', 'name == "Home" OR name == "主页"').catch(() => [] as string[]); let best: string | null = null, bestY = -1; for (const t of homeTabs) { const r = await this.getElementRect(t).catch(() => null); if (r && r.width > 0 && r.y > bestY) { best = t; bestY = r.y; } } if (best) { await this.request('POST', `/session/${this.sessionId}/element/${best}/click`).catch(() => {}); } else await this.tap(33, 69); // 兜底 } await new Promise((r) => setTimeout(r, 1500)); } return await this.isOnHomepage(); } }