AI_UIAutomation/utils/wda-helper.ts

460 lines
20 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-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<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> {
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<void> {
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<void> {
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<void> {
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<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,
});
}
/** WDA 原生元素滚动(不产生触点点击,根治列表行误点)。body 可传:
* {direction:'up'|'down'|'left'|'right'} 滚一屏;或 {predicateString:'name CONTAINS "X"'} / {name:'X'} 滚到该子元素可见(toVisible)。 */
async scrollElement(elementId: string, body: Record<string, unknown>): Promise<void> {
await this.request('POST', `/session/${this.sessionId}/wda/element/${elementId}/scroll`, body);
}
async scrollDown(distance = 300): Promise<void> {
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> {
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<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(''),
});
}
/** 给 PickerWheel 整串设值(不拆字符):时间轮等需 {value:["20"]} 一次到位,typeText 的逐字符拆分对 picker 无效。 */
async setPickerValue(elementId: string, value: string): Promise<void> {
await this.request('POST', `/session/${this.sessionId}/element/${elementId}/value`, {
value: [value],
});
}
async clearText(elementId: string): Promise<void> {
await this.request('POST', `/session/${this.sessionId}/element/${elementId}/clear`);
}
/** 向当前聚焦的输入框发送键盘按键。自绘/RN 输入框对 element/value 无效,需模拟真实键入。 */
async sendKeys(keys: string[]): Promise<void> {
await this.request('POST', `/session/${this.sessionId}/wda/keys`, { value: keys });
}
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;
}
/** 等首页渲染稳定:可见卡片**数量**连续两次不变 = 加载完成(用数量而非名,避免摄像头"Motion detected"等状态变化导致永不稳定)。最多 ~2.5s。
* 根治"页面没加载完就甩动/点击 → 落到半渲染卡上误点"。 */
private async waitHomeSettled(maxMs = 2500): Promise<void> {
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<boolean> { 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();
}
}