446 lines
19 KiB
TypeScript
446 lines
19 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;
|
|
}
|
|
|
|
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) {
|
|
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();
|
|
}
|
|
}
|