import { spawn, spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; const root = process.cwd(); const debugPort = 9233; const profile = fs.mkdtempSync(path.join(os.tmpdir(), "rpa-studio-desktop-e2e-")); const workDir = path.join(os.tmpdir(), "rpa-desktop-test"); const noteFile = path.join(workDir, "rpa-desktop-test.txt"); const titleMarker = "rpa-desktop-test"; const typedText = "RPA-Keyboard-Mouse-Test-OK"; const uiaText = "UIA-SetValue-写入验证"; const candidates = [process.env.RPA_ELECTRON_DIST, "C:/electron-rpa/dist", path.join(root, "node_modules", "electron", "dist")].filter(Boolean); const dist = candidates.find((candidate) => fs.existsSync(path.join(candidate, "electron.exe"))); if (!dist) throw new Error("找不到 Electron 运行时,请设置 RPA_ELECTRON_DIST"); const env = { ...process.env, RPA_E2E: "1" }; delete env.ELECTRON_RUN_AS_NODE; const child = spawn(path.join(dist, "electron.exe"), [root, "--no-sandbox", `--remote-debugging-port=${debugPort}`, `--user-data-dir=${profile}`], { cwd: root, env, stdio: ["ignore", "pipe", "pipe"], windowsHide: true }); let stderr = ""; child.stderr.on("data", (chunk) => { stderr += String(chunk); }); async function waitForTarget() { const deadline = Date.now() + 30000; while (Date.now() < deadline) { if (child.exitCode !== null) throw new Error(`RPA Studio 提前退出:${child.exitCode}\n${stderr}`); try { const targets = await (await fetch(`http://127.0.0.1:${debugPort}/json/list`)).json(); const page = targets.find((item) => item.type === "page" && item.title === "RPA Studio"); if (page) return page; } catch { /* wait for CDP */ } await new Promise((resolve) => setTimeout(resolve, 200)); } throw new Error(`等待 RPA Studio CDP 超时\n${stderr}`); } async function evaluate(page, expression) { const socket = new WebSocket(page.webSocketDebuggerUrl); await new Promise((resolve, reject) => { socket.onopen = resolve; socket.onerror = reject; }); const result = await new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error("E2E Runtime.evaluate 超时")), 180000); socket.onmessage = (event) => { const message = JSON.parse(event.data); if (message.id !== 1) return; clearTimeout(timer); if (message.error) reject(new Error(JSON.stringify(message.error))); else if (message.result?.exceptionDetails) reject(new Error(message.result.exceptionDetails.text || "E2E 页面异常")); else resolve(message.result?.result?.value); }; socket.send(JSON.stringify({ id: 1, method: "Runtime.evaluate", params: { expression, returnByValue: true, awaitPromise: true } })); }); socket.close(); return result; } function node(componentId, values, label, outputVar, timeoutMs = 20000) { return { componentId, values, label, position: { x: 220, y: 0 }, options: { timeoutMs, logException: true, ...(outputVar ? { outputVar } : {}) } }; } const maximizeScript = "Add-Type -MemberDefinition '[DllImport(\"user32.dll\")] public static extern bool ShowWindow(IntPtr h, int cmd);' -Name RpaMax -Namespace WinRpaMax | Out-Null; $p = Get-Process notepad | Where-Object { $_.MainWindowHandle -ne 0 } | Select-Object -First 1; if ($p) { [WinRpaMax.RpaMax]::ShowWindow($p.MainWindowHandle, 3) | Out-Null; 'maximized' } else { 'no-window' }"; const cursorPosScript = "Add-Type -AssemblyName System.Windows.Forms; $p=[System.Windows.Forms.Cursor]::Position; \"$($p.X),$($p.Y)\""; const nodes = [ node("Trigger.Manual", { entryName: "开始桌面键鼠实测" }, "人工发起"), node("System_ExecuteCommand", { action: `New-Item -ItemType Directory -Force -Path '${workDir}' | Out-Null; Set-Content -LiteralPath '${noteFile}' -Value '' -Encoding UTF8` }, "准备测试文件"), node("App_Run", { executeable: "notepad.exe", params: noteFile }, "启动记事本", "notepadPid"), node("Basic_Delay", { mileSecond: 1500 }, "等待窗口出现"), node("System_ExecuteCommand", { action: maximizeScript }, "最大化记事本", "maximized", 15000), node("Window_SetActive", { match_title: titleMarker }, "激活记事本", "activatedTitle"), node("Window_GetActive", {}, "读取前台窗口", "activeTitle1"), node("Window_WindowExists", { match_title: titleMarker }, "窗口存在", "exists1"), node("Keyboard_Send", { keys: typedText }, "键盘输入英文", "typed"), node("Keyboard_Send", { keys: "^a^c" }, "全选并复制"), node("Clipboard_GetText", {}, "读取剪贴板", "clipboard1"), node("Mouse_MoveTo", { position: "400,300" }, "鼠标移动", "movedTo"), node("System_ExecuteCommand", { action: cursorPosScript }, "读取真实光标位置", "cursorPos", 15000), node("Mouse_RollTo", { RollNum: 1 }, "滚轮滚动", "rolled"), node("Window_SetActive", { match_title: titleMarker }, "重新激活记事本", "activatedTitle2"), node("Element_ExistsGround", { uiSelector: `window=${titleMarker};type=Edit;index=1` }, "UIA 元素存在", "uiaExists"), node("Element_SetValueGround", { uiSelector: `window=${titleMarker};type=Edit;index=1`, value: uiaText }, "UIA 写入文本"), node("Keyboard_Send", { keys: "^a^c" }, "再次全选复制"), node("Clipboard_GetText", {}, "读取剪贴板二", "clipboard2"), node("Element_GetValueGround", { uiSelector: `window=${titleMarker};type=Edit;index=1` }, "UIA 读取文本", "uiaValue"), node("Element_ClickGround", { uiSelector: `window=${titleMarker};type=Edit;index=1` }, "UIA 点击编辑区", "clickResult"), node("Window_SetActive", { match_title: titleMarker }, "清理前重新激活", "activatedTitle3"), node("Keyboard_Send", { keys: "^a{DEL}" }, "清空文本避免保存提示"), node("Window_CloseWindow", { title: titleMarker }, "关闭记事本", "closed"), node("Basic_Delay", { mileSecond: 800 }, "等待退出"), node("Window_WindowExists", { match_title: titleMarker }, "窗口已消失", "exists2") ]; const expression = `(async () => { const flow = await window.rpa.flows.create("桌面键鼠实测"); const list = ${JSON.stringify(nodes)}; flow.nodes = list.map((item, index) => ({ id: "n" + String(index + 1).padStart(2, "0"), ...item, position: { x: 220, y: 80 + index * 140 } })); flow.edges = flow.nodes.slice(1).map((item, index) => ({ id: "e" + index, source: flow.nodes[index].id, target: item.id })); await window.rpa.flows.save(flow); const events = []; const off = window.rpa.run.onEvent((event) => events.push(event)); const started = await window.rpa.run.start(flow); const finished = await new Promise((resolve, reject) => { const deadline = Date.now() + 150000; const timer = setInterval(() => { const event = events.find((item) => item.type === "run.finished"); if (event) { clearInterval(timer); resolve(event); } else if (Date.now() > deadline) { clearInterval(timer); reject(new Error("桌面键鼠流程超时")); } }, 100); }); off(); await window.rpa.flows.delete(flow.id); const byNode = {}; for (const event of events) if (event.type === "node.finished") byNode[event.nodeId] = event; return { runId: started.runId, status: finished.status, durationMs: finished.durationMs, byNode, logs: events.filter((e) => e.type === "log").map((e) => e.message) }; })()`; let page; try { page = await waitForTarget(); const result = await evaluate(page, expression); if (result?.status !== "succeeded") throw new Error(`桌面键鼠流程失败:${JSON.stringify(result, null, 2)}`); const byNode = result.byNode || {}; const out = (id) => byNode[id]?.output; const failedNodes = Object.entries(byNode).filter(([, event]) => event.status !== "succeeded"); const checks = [ ["App_Run 返回 PID", Number(out("n03")) > 0, out("n03")], ["最大化记事本", out("n05") === "maximized", out("n05")], ["Window_SetActive 激活成功", String(out("n06") || "").includes(titleMarker), out("n06")], ["Window_GetActive 前台=记事本", String(out("n07") || "").includes(titleMarker), out("n07")], ["Window_WindowExists=true", out("n08") === true, out("n08")], ["Keyboard_Send 输入回显", out("n09") === typedText, out("n09")], ["剪贴板=键盘输入内容", out("n11") === typedText, out("n11")], ["Mouse_MoveTo 返回坐标", JSON.stringify(out("n12")) === JSON.stringify({ x: 400, y: 300 }), out("n12")], ["系统真实光标=目标位置", out("n13") === "400,300", out("n13")], ["Mouse_RollTo 滚动执行", out("n14") === 120, out("n14")], ["重复激活仍成功", String(out("n15") || "").includes(titleMarker), out("n15")], ["UIA 元素存在", out("n16") === true, out("n16")], ["UIA SetValue 后剪贴板一致", out("n19") === uiaText, out("n19")], ["UIA GetValue 读取一致", out("n20") === uiaText, out("n20")], ["UIA Click 执行", out("n21") && typeof out("n21") === "object", out("n21")], ["Window_CloseWindow 关闭", out("n24") === true, out("n24")], ["关闭后窗口不存在", out("n26") === false, out("n26")] ]; const problems = checks.filter(([, ok]) => !ok).map(([name, , actual]) => `${name} → ${JSON.stringify(actual)}`); if (problems.length || failedNodes.length) { throw new Error(`桌面键鼠实测未通过:\n${[...failedNodes.map(([id, event]) => `${id} ${event.status}: ${JSON.stringify(event.error || event.output)}`), ...problems].join("\n")}\n日志:\n${(result.logs || []).join("\n")}`); } console.log(JSON.stringify({ ok: true, runId: result.runId, durationMs: result.durationMs, checks: checks.map(([name]) => name) }, null, 2)); } finally { if (process.platform === "win32") { spawnSync("taskkill", ["/IM", "notepad.exe", "/F"], { windowsHide: true, stdio: "ignore" }); if (child.pid) spawnSync("taskkill", ["/PID", String(child.pid), "/T", "/F"], { windowsHide: true, stdio: "ignore" }); } else child.kill("SIGKILL"); await new Promise((resolve) => setTimeout(resolve, 500)); try { fs.rmSync(profile, { recursive: true, force: true, maxRetries: 12, retryDelay: 250 }); } catch {} try { fs.rmSync(workDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 300 }); } catch {} }