From 5d3f8b12cf36e0c2d7efaa866dea354c1a02a0c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B2=A1=E5=A4=8D=E4=B9=A0?= <2353956224@qq.com> Date: Wed, 13 Aug 2025 14:21:19 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B9bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/server/mw/WebsocketProxy copy.ts | 8 +- src/utils/dumpHierarchy.ts | 310 ++++++++++++--------------- 2 files changed, 139 insertions(+), 179 deletions(-) diff --git a/src/server/mw/WebsocketProxy copy.ts b/src/server/mw/WebsocketProxy copy.ts index b01bb9b..dea2cd3 100644 --- a/src/server/mw/WebsocketProxy copy.ts +++ b/src/server/mw/WebsocketProxy copy.ts @@ -415,7 +415,7 @@ export class WebsocketProxy extends Mw { }) .catch(err => { - console.error('Error:', err); + console.error('watchNode Error:', err); }); @@ -722,10 +722,14 @@ export class WebsocketProxy extends Mw { } } - }) + }).catch(err => { + console.error('dumpNode Error:', err); + }); } else if (parsedMessage.action === 'test') { dump(parsedMessage.udid).then((data) => { console.log('dump ui树', data.xml) + }).catch((err) => { + console.error('dump ui树失败: ', err); }) } diff --git a/src/utils/dumpHierarchy.ts b/src/utils/dumpHierarchy.ts index 6ac820e..415f90c 100644 --- a/src/utils/dumpHierarchy.ts +++ b/src/utils/dumpHierarchy.ts @@ -1,187 +1,143 @@ import { execFile } from 'child_process'; +import { promisify } from 'util'; import path from 'path'; const xml2js = require('xml2js'); +const execFileAsync = promisify(execFile); -/** - * 调用编译后的 .exe 文件获取设备 UI 层级 - * @param udid 设备udid - * @returns Promise<{udid: string, xml: string}> - */ -// export function dumpHierarchy(udid: string): Promise { -// const exePath = path.resolve(__dirname, 'uiauto_dump.exe'); // 替换为 .exe 路径 -// return new Promise((resolve, reject) => { -// execFile(exePath, [udid], (error, stdout, stderr) => { -// if (error) { -// reject(new Error(`执行 .exe 文件失败:${error.message}`)); -// return; -// } -// if (stderr) { -// reject(new Error(`.exe 文件输出错误:${stderr}`)); -// return; -// } -// try { -// // console.log(stdout) -// const data = JSON.parse(stdout); -// resolve(data); -// } catch (e) { -// reject(new Error(`解析输出数据失败:${e}`)); -// } -// }); -// }); -// } - -/** - * 调用打包后的 .exe,监听某个 resource-id 节点是否出现 - * @param udid 设备 ID - * @param resourceId 节点 resource-id,如 com.xx:id/ok_btn - * @param timeout 超时时间,单位秒,默认 30 - * @returns Promise<{ udid: string, xml: string }> - */ -export function watchNode( - udid: string, - resourceId: string, - timeout: number -): Promise<{ udid: string; resource_id: string; found: boolean; count: number; nodes: any[] }> { - const exePath = path.resolve(__dirname, 'watch_node.exe'); // 替换为你实际打包的 exe 文件名 - - return new Promise((resolve, reject) => { - execFile(exePath, ['watch', udid, resourceId, timeout.toString()], (error, stdout, stderr) => { - if (error) { - return reject(new Error(`执行 .exe 文件失败:${error.message}`)); - } - if (stderr) { - return reject(new Error(`.exe 文件输出错误:${stderr}`)); - } - - try { - const result = JSON.parse(stdout); - resolve(result); - } catch (e) { - reject(new Error(`解析输出数据失败:${(e as Error).message}\n原始输出: ${stdout}`)); - } - }); - }); -} -export function dumpNode( - udid: string, - resourceId: string -): Promise<{ - udid: string - resource_id: string - found: boolean - count: number - nodes: any[] -}> { - const exePath = path.resolve(__dirname, 'watch_node.exe') - - return new Promise((resolve, reject) => { - execFile(exePath, ['dump', udid], async (error, stdout, stderr) => { - if (error) return reject(new Error(`执行 .exe 文件失败:${error.message}`)) - if (stderr) return reject(new Error(`.exe 文件输出错误:${stderr}`)) - - let data: any - try { - data = JSON.parse(stdout) - } catch (e) { - return reject(new Error(`解析输出数据失败:${(e as Error).message}\n原始输出: ${stdout}`)) - } - - if (!data.xml) { - return reject(new Error(`未返回 xml 内容:${JSON.stringify(data)}`)) - } - - try { - const result = await xml2js.parseStringPromise(data.xml, { - explicitArray: false, - attrkey: '$', - }) - - const nodes: any[] = [] - - // 递归提取一个节点的基本信息(及其子节点) - const extractNodeInfo = (node: any): any => { - const info: any = { - text: node.$?.text || '', - 'resource-id': node.$?.['resource-id'] || '', - class: node.$?.class || '', - bounds: node.$?.bounds || '', - children: [] - } - - // 递归提取 children - for (const value of Object.values(node)) { - if (typeof value === 'object') { - if (Array.isArray(value)) { - for (const child of value) { - if (child?.$) { - info.children.push(extractNodeInfo(child)) - } - } - } else if ((value as any)?.$) { - info.children.push(extractNodeInfo(value)) - } - } - } - - return info - } - - // 遍历整棵树查找符合 resource-id 的节点,并附加子树结构 - const walk = function (node: any) { - if (!node) return - - if (node.$?.['resource-id'] === resourceId) { - nodes.push(extractNodeInfo(node)) - } - - Object.values(node).forEach((child) => { - if (typeof child === 'object') { - if (Array.isArray(child)) { - child.forEach(walk) - } else { - walk(child) - } - } - }) - } - - walk(result) - - resolve({ - udid: data.udid, - resource_id: resourceId, - found: nodes.length > 0, - count: nodes.length, - nodes - }) - } catch (e) { - reject(new Error(`XML 解析失败:${(e as Error).message}`)) - } - }) - }) +// —— 新增:设备级互斥(单飞) —— +const deviceLocks = new Map>(); +async function withDeviceLock(udid: string, fn: () => Promise): Promise { + const prev = deviceLocks.get(udid) || Promise.resolve(); + let release!: (v?: unknown) => void; + const cur = new Promise(res => (release = res)); + deviceLocks.set(udid, prev.then(() => cur)); + try { return await fn(); } + finally { release(); if (deviceLocks.get(udid) === cur) deviceLocks.delete(udid); } } -export function dump( - udid: string, -): Promise<{ udid: string; xml: string }> { - const exePath = path.resolve(__dirname, 'watch_node.exe'); // 替换为你实际打包的 exe 文件名 +// —— 新增:错误识别 & 自救清理 —— +function isUiaConflict(msg: string) { + return /already registered|UiAutomation not connected/i.test(msg); +} +function isNoXml(msg: string) { + return /未返回 xml 内容|no xml/i.test(msg); +} +async function cleanupUia2(udid: string) { + // 按你的环境调整关键字(wetest.uia2 / uiautomator / io.appium.uiautomator2.server) + // Windows 下 adb 在 PATH 中;否则改成绝对路径 + await execFileAsync('adb', ['-s', udid, 'shell', 'pkill', '-f', 'uiautomator']).catch(() => { }); + await execFileAsync('adb', ['-s', udid, 'shell', 'pkill', '-f', 'wetest.uia2']).catch(() => { }); + await execFileAsync('adb', ['-s', udid, 'shell', 'input', 'keyevent', '3']).catch(() => { }); // HOME 稳定焦点 +} - return new Promise((resolve, reject) => { - execFile(exePath, ['dump', udid], (error, stdout, stderr) => { - if (error) { - return reject(new Error(`执行 .exe 文件失败:${error.message}`)); - } - if (stderr) { - return reject(new Error(`.exe 文件输出错误:${stderr}`)); - } +// —— 原有底层调用,稍加 timeout/错误透传 —— +function runWatchExe(args: string[], timeoutMs = 35_000) { + const exePath = path.resolve(__dirname, 'watch_node.exe'); + return execFileAsync(exePath, args, { timeout: timeoutMs, windowsHide: true }); +} - try { - const data = JSON.parse(stdout); - resolve(data); - } catch (e) { - reject(new Error(`解析输出数据失败:${(e as Error).message}\n原始输出: ${stdout}`)); - } - }); +// 你原有的三个“裸”函数保持不变(可改名为 *Raw),供编排器调用: +export function watchNodeRaw(udid: string, resourceId: string, timeout: number) { + return new Promise<{ udid: string; resource_id: string; found: boolean; count: number; nodes: any[] }>(async (resolve, reject) => { + try { + const { stdout, stderr } = await runWatchExe(['watch', udid, resourceId, timeout.toString()], (timeout + 5) * 1000); + if (stderr) return reject(new Error(`.exe 文件输出错误:${stderr}`)); + resolve(JSON.parse(stdout)); + } catch (e: any) { + reject(new Error(`执行 .exe 失败:${e?.message || e}`)); + } }); -} \ No newline at end of file +} + +export function dumpRaw(udid: string) { + return new Promise<{ udid: string; xml: string }>(async (resolve, reject) => { + try { + const { stdout, stderr } = await runWatchExe(['dump', udid], 25_000); + if (stderr) return reject(new Error(`.exe 文件输出错误:${stderr}`)); + resolve(JSON.parse(stdout)); + } catch (e: any) { + reject(new Error(`执行 .exe 失败:${e?.message || e}`)); + } + }); +} + +export function dumpNodeRaw(udid: string, resourceId: string) { + return new Promise<{ + udid: string; resource_id: string; found: boolean; count: number; nodes: any[]; + }>(async (resolve, reject) => { + try { + const { stdout, stderr } = await runWatchExe(['dump', udid], 25_000); + if (stderr) return reject(new Error(`.exe 文件输出错误:${stderr}`)); + const data = JSON.parse(stdout); + if (!data.xml) return reject(new Error(`未返回 xml 内容:${JSON.stringify(data)}`)); + + const result = await xml2js.parseStringPromise(data.xml, { explicitArray: false, attrkey: '$' }); + const nodes: any[] = []; + + const extractNodeInfo = (node: any): any => { + const info: any = { + text: node.$?.text || '', + 'resource-id': node.$?.['resource-id'] || '', + class: node.$?.class || '', + bounds: node.$?.bounds || '', + children: [] + }; + for (const value of Object.values(node)) { + if (typeof value === 'object') { + if (Array.isArray(value)) value.forEach((child) => child?.$ && info.children.push(extractNodeInfo(child))); + else if ((value as any)?.$) info.children.push(extractNodeInfo(value)); + } + } + return info; + }; + + const walk = (node: any) => { + if (!node) return; + if (node.$?.['resource-id'] === resourceId) nodes.push(extractNodeInfo(node)); + Object.values(node).forEach((child) => { + if (typeof child === 'object') { + if (Array.isArray(child)) child.forEach(walk); + else walk(child); + } + }); + }; + + walk(result); + + resolve({ udid: data.udid, resource_id: resourceId, found: nodes.length > 0, count: nodes.length, nodes }); + } catch (e: any) { + reject(new Error(`XML 解析或执行失败:${e?.message || e}`)); + } + }); +} + +// —— 新增:Safe 包装(互斥 + 自救 + 重试),供业务层使用 —— + +// 重试一次即可:初次失败→cleanup→重试 +async function withRecovery(udid: string, fn: () => Promise): Promise { + try { + return await fn(); + } catch (e: any) { + const msg = String(e?.message || e); + if (isUiaConflict(msg) || isNoXml(msg)) { + await cleanupUia2(udid); + await new Promise(r => setTimeout(r, 800)); + return await fn(); + } + throw e; + } +} + +export async function watchNodeSafe(udid: string, resourceId: string, timeout: number) { + return withDeviceLock(udid, () => withRecovery(udid, () => watchNodeRaw(udid, resourceId, timeout))); +} + +export async function dumpSafe(udid: string) { + return withDeviceLock(udid, () => withRecovery(udid, () => dumpRaw(udid))); +} + +export async function dumpNodeSafe(udid: string, resourceId: string) { + return withDeviceLock(udid, () => withRecovery(udid, () => dumpNodeRaw(udid, resourceId))); +} +export { watchNodeSafe as watchNode, dumpNodeSafe as dumpNode, dumpSafe as dump }; \ No newline at end of file