fix: 窗口组件条件字段渲染+选窗口按钮; imageSelector加文件浏览与屏幕截取; prompt()改confirm(); 后端窗口selector统一读取所有标题字段; 新增桌面键鼠人工验收流程与E2E脚本
This commit is contained in:
parent
68e16282de
commit
b5d6bef369
160
scripts/desktop-e2e.mjs
Normal file
160
scripts/desktop-e2e.mjs
Normal file
@ -0,0 +1,160 @@
|
||||
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 {}
|
||||
}
|
||||
106
scripts/install-desktop-manual-flow.mjs
Normal file
106
scripts/install-desktop-manual-flow.mjs
Normal file
@ -0,0 +1,106 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const appData = process.env.APPDATA || path.join(process.env.USERPROFILE || "", "AppData", "Roaming");
|
||||
const flowsDir = path.join(appData, "rpa-studio", "flows");
|
||||
const id = "flow_manual_desktop_keymouse";
|
||||
const title = "manual-desktop";
|
||||
|
||||
if (!fs.existsSync(flowsDir)) {
|
||||
throw new Error(`找不到流程目录:${flowsDir}。请先启动一次 RPA Studio。`);
|
||||
}
|
||||
|
||||
let y = 80;
|
||||
let sequence = 0;
|
||||
function node(componentId, values, label, outputVar, options = {}) {
|
||||
const item = {
|
||||
id: `n${String(++sequence).padStart(2, "0")}`,
|
||||
componentId,
|
||||
label,
|
||||
position: { x: 260, y },
|
||||
values,
|
||||
options: { timeoutMs: 20000, logException: true, ...(outputVar ? { outputVar } : {}), ...options }
|
||||
};
|
||||
y += 140;
|
||||
return item;
|
||||
}
|
||||
|
||||
function dialog(text, title = "人工确认") {
|
||||
return node("Dialog_Notice", { title, text }, title, undefined, { timeoutMs: 600000 });
|
||||
}
|
||||
|
||||
const nodes = [
|
||||
node("Trigger.Manual", { entryName: "开始桌面键鼠人工测试" }, "人工发起"),
|
||||
node("System_ExecuteCommand", {
|
||||
action: "$old=@(Get-Process notepad -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowTitle -like '*manual-desktop*' }); foreach($p in $old){ $p | Stop-Process -Force }; if($old.Count){ Start-Sleep -Milliseconds 500; 'closed:'+$old.Count } else { 'no-old' }"
|
||||
}, "清理旧测试窗口", "cleanupOld"),
|
||||
node("System_ExecuteCommand", {
|
||||
action: `$d=Join-Path $env:TEMP 'rpa-manual-desktop'; New-Item -ItemType Directory -Force -Path $d | Out-Null; $f=Join-Path $d '${title}.txt'; Set-Content -LiteralPath $f -Value '' -Encoding UTF8; Write-Output $f`
|
||||
}, "准备记事本文件", "noteFile"),
|
||||
node("App_Run", { executeable: "notepad.exe", params: "${noteFile}" }, "启动记事本", "notepadPid"),
|
||||
node("Basic_Delay", { mileSecond: 1200 }, "等待窗口", "waited1"),
|
||||
node("System_ExecuteCommand", {
|
||||
action: `$p=Get-Process notepad -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowHandle -ne 0 -and $_.MainWindowTitle -like '*${title}*' } | Select-Object -First 1; if($p){ Add-Type -MemberDefinition '[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int cmd);' -Name RpaManualMax -Namespace WinRpaManualMax | Out-Null; [void][WinRpaManualMax.RpaManualMax]::ShowWindow($p.MainWindowHandle,3); 'maximized' } else { 'no-window' }`
|
||||
}, "最大化记事本", "maximized"),
|
||||
node("Window_SetActive", { match_title: title }, "自动激活记事本", "activatedTitle", { skipOnError: true }),
|
||||
node("Window_GetActive", {}, "读取前台窗口", "activeTitle1"),
|
||||
node("Window_WindowExists", { switchType: "0", get_type: "2", window_obj: title, match_type: "1", match_title: title }, "检查窗口存在", "windowExists1"),
|
||||
node("Basic_DebugOutput", { msg: "自动激活:${activatedTitle};前台:${activeTitle1};存在:${windowExists1}" }, "输出阶段1结果"),
|
||||
dialog("阶段1 / 窗口能力\n\n预期:\n1. 屏幕上出现并最大化 manual-desktop.txt 记事本;\n2. 自动激活和前台标题包含 manual-desktop;\n3. 窗口存在为 true。\n\n确认无误后点确定。", "阶段1 · 窗口检查"),
|
||||
|
||||
dialog("阶段2 / 键盘准备\n\n请先点确定关闭本弹窗。\n关闭后 3 秒内,用鼠标点击一次记事本编辑区,但不要按键盘。", "阶段2 · 键盘准备"),
|
||||
node("Basic_Delay", { mileSecond: 3000 }, "给手工聚焦留时间", "waited2"),
|
||||
node("Keyboard_Send", { keys: "RPA-KEY-MANUAL-OK", words: "RPA-KEY-MANUAL-OK", interval: "0.01", fuctype: "1" }, "键盘输入英文", "keyboardResult"),
|
||||
node("Keyboard_Send", { keys: "^a^c", words: "^a^c", interval: "0.01", fuctype: "1" }, "全选并复制", "shortcutResult"),
|
||||
node("Clipboard_GetText", {}, "读取剪贴板", "clipboardText"),
|
||||
node("Basic_DebugOutput", { msg: "键盘返回:${keyboardResult};剪贴板:${clipboardText}" }, "输出键盘结果"),
|
||||
dialog("阶段2 / 键盘能力\n\n预期:\n1. 记事本显示 RPA-KEY-MANUAL-OK;\n2. 调试输出中的剪贴板也是 RPA-KEY-MANUAL-OK。\n\n确认后点确定。", "阶段2 · 键盘检查"),
|
||||
|
||||
node("Mouse_MoveTo", { position: "620,420" }, "鼠标移动", "mouseMoveResult"),
|
||||
node("System_ExecuteCommand", {
|
||||
action: "Add-Type -AssemblyName System.Windows.Forms; $p=[System.Windows.Forms.Cursor]::Position; Write-Output \"$($p.X),$($p.Y)\""
|
||||
}, "核对光标位置", "realCursor"),
|
||||
node("Mouse_Click", { position: "650,450" }, "鼠标左键点击", "mouseClickResult"),
|
||||
node("Mouse_DragTo", { Start: "650,450", End: "760,520" }, "鼠标拖拽", "mouseDragResult"),
|
||||
node("Mouse_RollTo", { RollNum: 1 }, "滚轮滚动", "mouseRollResult"),
|
||||
dialog("阶段3 / 鼠标能力\n\n请回忆/观察:\n1. 光标曾移动到屏幕中部;\n2. 记事本编辑区收到左键点击;\n3. 拖拽执行且没有把窗口拖丢;\n4. 滚轮动作执行。\n\n确认后点确定。", "阶段3 · 鼠标检查"),
|
||||
|
||||
node("Element_ExistsGround", { uiSelector: `window=${title};type=Edit;index=1` }, "UIA 元素存在", "uiaExists", { skipOnError: true }),
|
||||
node("Element_SetValueGround", { uiSelector: `window=${title};type=Edit;index=1`, value: "UIA-MANUAL-写入-OK" }, "UIA 写入编辑框", "uiaSetResult", { skipOnError: true }),
|
||||
node("Element_GetValueGround", { uiSelector: `window=${title};type=Edit;index=1` }, "UIA 读取编辑框", "uiaValue", { skipOnError: true }),
|
||||
node("Element_ClickGround", { uiSelector: `window=${title};type=Edit;index=1` }, "UIA 点击编辑框", "uiaClickResult", { skipOnError: true }),
|
||||
node("Basic_DebugOutput", { msg: "UIA 存在:${uiaExists};读取:${uiaValue}" }, "输出UIA结果"),
|
||||
dialog("阶段4 / UIA 能力\n\n预期:\n1. 记事本内容被写入 UIA-MANUAL-写入-OK;\n2. 调试输出中 UIA 存在为 true,读取值一致;\n3. UIA 点击执行后流程继续。\n\n确认后点确定。", "阶段4 · UIA检查"),
|
||||
|
||||
dialog("准备清理:流程会尝试清空内容并关闭记事本。点确定继续。", "清理确认"),
|
||||
node("Window_SetActive", { match_title: title }, "清理前激活", "activatedTitle2", { skipOnError: true }),
|
||||
node("Keyboard_Send", { keys: "^a{DEL}", words: "^a{DEL}", interval: "0.01", fuctype: "1" }, "清空文本", "clearResult"),
|
||||
node("Window_CloseWindow", { title, window_obj: title }, "关闭记事本", "closeResult", { skipOnError: true }),
|
||||
node("Basic_Delay", { mileSecond: 800 }, "等待退出", "waited3"),
|
||||
node("Window_WindowExists", { switchType: "1", get_type: "2", window_obj: title, match_type: "1", match_title: title }, "确认窗口关闭", "windowExists2"),
|
||||
node("Basic_DebugOutput", { msg: "关闭结果:${closeResult};关闭后窗口存在:${windowExists2}" }, "输出清理结果"),
|
||||
dialog("阶段5 / 清理检查\n\n预期:记事本已关闭,关闭后窗口存在为 false。\n\n完成请点确定。", "阶段5 · 清理检查")
|
||||
];
|
||||
|
||||
const flow = {
|
||||
schemaVersion: 1,
|
||||
id,
|
||||
name: "桌面键鼠人工验收",
|
||||
description: "分阶段验证窗口、键盘、鼠标、剪贴板、UIA 与清理;每个阶段有弹窗暂停,便于人工确认。",
|
||||
enabled: true,
|
||||
trigger: { type: "manual", label: "手动运行" },
|
||||
variables: [],
|
||||
nodes,
|
||||
edges: nodes.slice(1).map((target, index) => ({ id: `e${index}`, source: nodes[index].id, target: target.id })),
|
||||
meta: { createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), appVersion: "0.1.0" }
|
||||
};
|
||||
|
||||
const target = path.join(flowsDir, `${id}.json`);
|
||||
fs.writeFileSync(target, JSON.stringify(flow, null, 2), "utf8");
|
||||
console.log(`已写入:${target}`);
|
||||
console.log(`节点数:${nodes.length}`);
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@ -86,10 +86,10 @@ console.log("[package] typecheck");
|
||||
run("npm", ["run", "typecheck"]);
|
||||
console.log("[package] tests");
|
||||
run("npm", ["test"]);
|
||||
console.log("[package] browser e2e");
|
||||
run("npm", ["run", "test:e2e"]);
|
||||
console.log("[package] build");
|
||||
run("npm", ["run", "build"]);
|
||||
console.log("[package] browser e2e");
|
||||
run("npm", ["run", "test:e2e"]);
|
||||
|
||||
const electronDist = path.join(root, "node_modules", "electron", "dist");
|
||||
if (!fs.existsSync(path.join(electronDist, "electron.exe"))) {
|
||||
|
||||
@ -239,7 +239,7 @@ function Find-RpaWindow([string]$winName, [string]$winClass, [string]$processNam
|
||||
$titleSb = New-Object System.Text.StringBuilder 512
|
||||
[void][Win32Rpa.RpaUser32Click]::GetWindowTextW($h, $titleSb, 512)
|
||||
$title = $titleSb.ToString()
|
||||
if ($winName -and $title -ne $winName) { return $true }
|
||||
if ($winName -and $title -notlike "*$winName*") { return $true }
|
||||
$classSb = New-Object System.Text.StringBuilder 256
|
||||
[void][Win32Rpa.RpaUser32Click]::GetClassNameW($h, $classSb, 256)
|
||||
$className = $classSb.ToString()
|
||||
@ -257,7 +257,7 @@ function Find-RpaWindow([string]$winName, [string]$winClass, [string]$processNam
|
||||
if ($script:hitHwnd -ne [IntPtr]::Zero) {
|
||||
try { $win = [System.Windows.Automation.AutomationElement]::FromHandle($script:hitHwnd); if ($win) { return $win } } catch {}
|
||||
}
|
||||
$missing = @($winName, $winClass, $processName) | Where-Object { $_ } -join ","
|
||||
$missing = (@($winName, $winClass, $processName) | Where-Object { $_ }) -join ","
|
||||
throw "window_not_found:$missing"
|
||||
}
|
||||
function Restore-RpaWindow($win) {
|
||||
|
||||
@ -153,22 +153,42 @@ const mouseRoll: CapabilityHandler = async (_ctx, node, args) => {
|
||||
};
|
||||
|
||||
const setActiveWindow: CapabilityHandler = async (_ctx, node, args) => {
|
||||
const selector = text(args, ["match_title", "select_title", "title", "target_window", "window_title"]);
|
||||
const selector = text(args, ["window_obj", "match_title", "select_title", "title", "target_window", "window_title"]);
|
||||
if (!selector) throw new RuntimeError("validation_error", "窗口标题为空", { nodeId: node.id, componentId: node.componentId });
|
||||
const result = await powershell(`${USER32}; $p = Get-Process | Where-Object { $_.MainWindowTitle -and $_.MainWindowTitle -like ${psString(`*${selector}*`)} } | Select-Object -First 1; if (-not $p) { throw '找不到窗口' }; [RpaUser32]::SetForegroundWindow($p.MainWindowHandle); $p.MainWindowTitle`);
|
||||
const result = await powershell(`${USER32}
|
||||
$p = Get-Process | Where-Object { $_.MainWindowTitle -and $_.MainWindowTitle -like ${psString(`*${selector}*`)} } | Select-Object -First 1
|
||||
if (-not $p) { throw '找不到窗口' }
|
||||
Add-Type -MemberDefinition '[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int cmd); [DllImport("user32.dll")] public static extern bool IsIconic(IntPtr h); [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h); [DllImport("user32.dll")] public static extern void keybd_event(byte bVk, byte bScan, uint dwFlags, UIntPtr dwExtraInfo); [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, IntPtr pid); [DllImport("user32.dll")] public static extern bool AttachThreadInput(uint a, uint b, bool attach); [DllImport("kernel32.dll")] public static extern uint GetCurrentThreadId();' -Name RpaFocus2 -Namespace WinRpaFocus2 | Out-Null
|
||||
$h = $p.MainWindowHandle
|
||||
if ([WinRpaFocus2.RpaFocus2]::IsIconic($h)) { [void][WinRpaFocus2.RpaFocus2]::ShowWindow($h, 9) }
|
||||
if ([WinRpaFocus2.RpaFocus2]::GetForegroundWindow() -ne $h) {
|
||||
[void][WinRpaFocus2.RpaFocus2]::keybd_event(0x12, 0, 0, [UIntPtr]::Zero)
|
||||
[void][WinRpaFocus2.RpaFocus2]::SetForegroundWindow($h)
|
||||
[void][WinRpaFocus2.RpaFocus2]::keybd_event(0x12, 0, 2, [UIntPtr]::Zero)
|
||||
Start-Sleep -Milliseconds 200
|
||||
}
|
||||
if ([WinRpaFocus2.RpaFocus2]::GetForegroundWindow() -ne $h) {
|
||||
$fgThread = [WinRpaFocus2.RpaFocus2]::GetWindowThreadProcessId([WinRpaFocus2.RpaFocus2]::GetForegroundWindow(), [IntPtr]::Zero)
|
||||
$thisThread = [WinRpaFocus2.RpaFocus2]::GetCurrentThreadId()
|
||||
$attached = [WinRpaFocus2.RpaFocus2]::AttachThreadInput($thisThread, $fgThread, $true)
|
||||
try { [void][WinRpaFocus2.RpaFocus2]::SetForegroundWindow($h) } finally { if ($attached) { [void][WinRpaFocus2.RpaFocus2]::AttachThreadInput($thisThread, $fgThread, $false) } }
|
||||
Start-Sleep -Milliseconds 200
|
||||
}
|
||||
if ([WinRpaFocus2.RpaFocus2]::GetForegroundWindow() -ne $h) { throw '前台切换失败' }
|
||||
$p.MainWindowTitle`);
|
||||
return result;
|
||||
};
|
||||
|
||||
const getActiveWindow: CapabilityHandler = async () => powershell(`${USER32}; $sb = New-Object System.Text.StringBuilder 512; [RpaUser32]::GetWindowText([RpaUser32]::GetForegroundWindow(),$sb,$sb.Capacity) | Out-Null; $sb.ToString()`);
|
||||
|
||||
const windowExists: CapabilityHandler = async (_ctx, node, args) => {
|
||||
const selector = text(args, ["match_title", "select_title", "title", "target_window", "window_title"]);
|
||||
const selector = text(args, ["window_obj", "match_title", "select_title", "title", "target_window", "window_title"]);
|
||||
if (!selector) throw new RuntimeError("validation_error", "窗口标题为空", { nodeId: node.id, componentId: node.componentId });
|
||||
return (await powershell(`$p = Get-Process | Where-Object { $_.MainWindowTitle -and $_.MainWindowTitle -like ${psString(`*${selector}*`)} } | Select-Object -First 1; [bool]$p`)).toLowerCase() === "true";
|
||||
};
|
||||
|
||||
const closeWindow: CapabilityHandler = async (_ctx, node, args) => {
|
||||
const selector = text(args, ["window_obj", "title", "target_window", "window_title"]);
|
||||
const selector = text(args, ["window_obj", "match_title", "select_title", "title", "target_window", "window_title"]);
|
||||
if (!selector) throw new RuntimeError("validation_error", "窗口标题为空", { nodeId: node.id, componentId: node.componentId });
|
||||
return powershell(`$p = Get-Process | Where-Object { $_.MainWindowTitle -and $_.MainWindowTitle -like ${psString(`*${selector}*`)} } | Select-Object -First 1; if (-not $p) { throw '找不到窗口' }; $p.CloseMainWindow() | Out-Null; $true`);
|
||||
};
|
||||
|
||||
@ -45,7 +45,7 @@ describe("PDF high-level capabilities", () => {
|
||||
const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs");
|
||||
const opened = await pdfjs.getDocument({ data: new Uint8Array(await readFile(encryptedPath)), password: "secret" }).promise;
|
||||
expect(opened.numPages).toBe(1);
|
||||
});
|
||||
}, 60000);
|
||||
});
|
||||
async function mkdtempSafe(): Promise<string> {
|
||||
const directory = await (await import("node:fs/promises")).mkdtemp(path.join(os.tmpdir(), "rpa-pdf-high-"));
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { app } from "electron";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { FlowDefinition, FlowEdge, FlowNode, FlowSummary } from "../shared/types";
|
||||
|
||||
@ -71,12 +72,29 @@ export function createFlow(name = "未命名流程"): FlowDefinition {
|
||||
};
|
||||
}
|
||||
|
||||
/** 演示流程浏览器类型:优先 Chrome,未安装时回退 Edge;两者都缺失则保持 chrome 由能力层给出友好错误 */
|
||||
function demoBrowserType(): string {
|
||||
const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local");
|
||||
const chromePaths = [
|
||||
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
|
||||
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
|
||||
path.join(localAppData, "Google", "Chrome", "Application", "chrome.exe")
|
||||
];
|
||||
if (chromePaths.some((p) => fs.existsSync(p))) return "chrome";
|
||||
const edgePaths = [
|
||||
"C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe",
|
||||
"C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe"
|
||||
];
|
||||
if (edgePaths.some((p) => fs.existsSync(p))) return "edge";
|
||||
return "chrome";
|
||||
}
|
||||
|
||||
export function createDemoFlow(url: string): FlowDefinition {
|
||||
const flow = createFlow("本地网页订单演示");
|
||||
flow.description = "打开本地演示站,填写客户和金额,提交订单并读取结果。";
|
||||
flow.nodes = [
|
||||
flow.nodes[0],
|
||||
{ id: "node_open", componentId: "WebBrowser_CreateV3", position: { x: 180, y: 250 }, values: { type: "chrome", url, isRemote: "0", openNewTab: "1", waitingload: "1" }, options: { timeoutMs: 30000 } },
|
||||
{ id: "node_open", componentId: "WebBrowser_CreateV3", position: { x: 180, y: 250 }, values: { type: demoBrowserType(), url, isRemote: "0", openNewTab: "1", waitingload: "1" }, options: { timeoutMs: 30000 } },
|
||||
{ id: "node_name", componentId: "Element_SetValue_V6", position: { x: 180, y: 390 }, values: { uiSelector: "css:#name", designated_info: "0", designated_obj: "", text: "RPA 客户", input_type: "0", isEnter: "0", interval: "0" }, options: { timeoutMs: 10000 } },
|
||||
{ id: "node_amount", componentId: "Element_SetValue_V6", position: { x: 180, y: 530 }, values: { uiSelector: "css:#amount", designated_info: "0", designated_obj: "", text: "128", input_type: "0", isEnter: "0", interval: "0" }, options: { timeoutMs: 10000 } },
|
||||
{ id: "node_submit", componentId: "Element_Click_V6", position: { x: 180, y: 670 }, values: { uiSelector: "css:#submit", designated_info: "0", designated_obj: "", clickway: "0", clickfunc: "0", operation: "1" }, options: { timeoutMs: 10000 } },
|
||||
|
||||
@ -636,14 +636,18 @@ function FieldControl({
|
||||
onToast("未找到可见窗口", "error");
|
||||
return;
|
||||
}
|
||||
const numbered = list.map((w: { title: string }, i: number) => (i + 1) + ". " + w.title).join("\n");
|
||||
const choice = prompt("选择窗口(输入编号):\n" + numbered);
|
||||
const idx = Number(choice) - 1;
|
||||
if (Number.isInteger(idx) && list[idx]) {
|
||||
onChange(field.key, list[idx].title);
|
||||
onToast("已选择窗口:" + list[idx].title);
|
||||
for (let wi = 0; wi < list.length; wi++) {
|
||||
const w = list[wi] as { title: string; process?: string };
|
||||
const ok = confirm(`窗口 ${wi + 1}/${list.length}:${w.title}(${w.process ?? "未知"})\n\n确定选这个窗口?取消看下一个`);
|
||||
if (ok) {
|
||||
onChange(field.key, w.title);
|
||||
onToast("已选择窗口:" + w.title);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
onToast("未选择窗口");
|
||||
}
|
||||
catch (error) {
|
||||
onToast(error instanceof Error ? error.message : String(error), "error");
|
||||
} finally {
|
||||
setPicking(false);
|
||||
@ -673,11 +677,15 @@ function FieldControl({
|
||||
? "填写完整文件路径,或点右侧按钮选择"
|
||||
: field.widget === "folder"
|
||||
? "填写完整文件夹路径,或点右侧按钮选择"
|
||||
: field.widget === "region" && (field.displayType === "captureWindow" || field.displayType === "openWindowSelect")
|
||||
? "填写窗口标题,或点「选窗口」从可见窗口列表中选择"
|
||||
|
||||
: field.widget === "region"
|
||||
? field.displayType === "screenShot"
|
||||
? "填模板图片路径,或点「屏幕框选」截取屏幕上的目标图案"
|
||||
: "填写区域坐标 JSON,或点「屏幕框选」生成"
|
||||
: "";
|
||||
|
||||
const hint = field.description || fallbackHint;
|
||||
|
||||
return (
|
||||
@ -778,12 +786,27 @@ function FieldControl({
|
||||
placeholder={field.placeholder || ""}
|
||||
onChange={(event) => onChange(field.key, event.target.value)}
|
||||
/>
|
||||
) : field.widget === "region" && (field.displayType === "captureWindow" || field.displayType === "openWindowSelect") ? (
|
||||
<div className="field-row">
|
||||
<input
|
||||
className="text-input"
|
||||
value={textOf(raw)}
|
||||
|
||||
placeholder={field.placeholder || "点右侧「选窗口」从可见窗口列表中选择"}
|
||||
onChange={(event) => onChange(field.key, event.target.value)}
|
||||
/>
|
||||
<VarMenu variables={variables} onPick={insertVariable} />
|
||||
<button type="button" className="mini-btn" disabled={picking} onClick={() => void pickWindowToField()} title="从当前可见窗口列表中选择">
|
||||
{picking ? "选择中" : "选窗口"}
|
||||
</button>
|
||||
</div>
|
||||
) : field.widget === "region" && field.displayType === "screenShot" ? (
|
||||
// 模板图片类字段(Image_* 的图片路径等): 值是文件路径, 框选是把截图存盘后填路径
|
||||
<div className="field-row">
|
||||
<input
|
||||
className="text-input"
|
||||
value={textOf(raw)}
|
||||
|
||||
placeholder={field.placeholder || "填模板图片路径,或点「屏幕框选」截取"}
|
||||
onChange={(event) => onChange(field.key, event.target.value)}
|
||||
/>
|
||||
@ -817,6 +840,21 @@ function FieldControl({
|
||||
onChange={(event) => onChange(field.key, event.target.value)}
|
||||
/>
|
||||
<VarMenu variables={variables} onPick={insertVariable} />
|
||||
{isWindowTarget && /window_obj|match_title|title|window/i.test(field.key) && (
|
||||
<button type="button" className="mini-btn" disabled={picking} onClick={() => void pickWindowToField()} title="从当前可见窗口列表中选择">
|
||||
{picking ? "选择中" : "选窗口"}
|
||||
</button>
|
||||
)}
|
||||
{field.displayType === "imageSelector" && (
|
||||
<>
|
||||
<button type="button" className="mini-btn" onClick={() => void pickPath(false)} title="选择图片文件">
|
||||
<FolderOpen className="icon" style={{ width: 13, height: 13 }} />
|
||||
</button>
|
||||
<button type="button" className="mini-btn" disabled={picking} onClick={() => void pickRegionToField()} title="屏幕截取区域作为图片">
|
||||
{picking ? "截取中" : "屏幕截取"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@ -29,6 +29,20 @@ export function fieldIsActive(componentId: string, key: string, values: Record<s
|
||||
}
|
||||
}
|
||||
if (componentId === "System_ScreenSaverShowV1" && key === "password") return truthy(values.set_password);
|
||||
// Window_*_v1 series: conditional fields based on get_type mode
|
||||
if (/^Window_.*_v1$/.test(componentId) || componentId === "Window_WindowExists" || componentId === "Window_WaitWindow") {
|
||||
const gt = String(values.get_type ?? "");
|
||||
if (gt !== "") {
|
||||
if (key === "target_window") return gt === "0";
|
||||
if (key === "window_obj") return gt === "1";
|
||||
if (key === "match_type" || key === "select_title" || key === "match_title") return gt === "2";
|
||||
}
|
||||
if (gt === "2") {
|
||||
const mt = String(values.match_type ?? "0");
|
||||
if (key === "select_title") return mt === "0";
|
||||
if (key === "match_title") return mt === "1";
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user