PRA-only-demo/scripts/desktop-e2e.mjs

161 lines
10 KiB
JavaScript

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 {}
}