diff --git a/packages/rpa-core/src/capabilities/compat-audit.test.ts b/packages/rpa-core/src/capabilities/compat-audit.test.ts new file mode 100644 index 0000000..ffd2a5d --- /dev/null +++ b/packages/rpa-core/src/capabilities/compat-audit.test.ts @@ -0,0 +1,75 @@ +import { describe, it } from "vitest"; +import { capabilityIds } from "../engine/registry"; +import { writeFileSync } from "node:fs"; +import { readFileSync } from "node:fs"; +import "./index"; // 汇总导入全部能力模块(与运行时同构) + +interface CatalogComp { + id: string; + name?: string; + fields?: { key: string; label?: string; widget?: string; options?: { label: string; value: string }[]; defaultValue?: unknown }[]; +} + +describe("系统组件兼容性全量审查", () => { + it("注册覆盖 + 面板可见性 + 字段契约核对", async () => { + const catalog: CatalogComp[] = JSON.parse(readFileSync("resources/catalog.json", "utf-8")).components ?? []; + const dup = JSON.parse(readFileSync("resources/capability-duplicates.json", "utf-8")); + const dupMap = new Map( + (Array.isArray(dup) ? dup : []).map((d: { id: string; status: string; canonical?: string }) => [d.id, { status: d.status, canonical: d.canonical }]) + ); + const registered = new Set(capabilityIds()); + + // 1) catalog 组件 → 运行时注册 + const unregistered: { id: string; name?: string }[] = []; + for (const c of catalog) { + if (!registered.has(c.id)) unregistered.push({ id: c.id, name: c.name }); + } + + // 2) 幽灵注册:handler 有但 catalog 没有(可能是内部别名,不一定是问题,但要列出来) + const catalogIds = new Set(catalog.map((c) => c.id)); + const ghosts: string[] = []; + for (const id of registered) { + if (!catalogIds.has(id)) ghosts.push(id); + } + + // 3) duplicates 一致性:hide 必须有 canonical 且 canonical 已注册;keep/隐藏组件的执行器必须在 + const dupIssues: string[] = []; + for (const [id, info] of dupMap) { + if (info.status === "hide" && info.canonical && !registered.has(info.canonical)) { + dupIssues.push(`hide 项 ${id} 的 canonical ${info.canonical} 未注册`); + } + if (info.status === "keep" && !registered.has(id)) { + dupIssues.push(`keep 项 ${id} 未注册(面板可见但运行会报未实现)`); + } + if (info.status === "hide" && !info.canonical && !registered.has(id)) { + dupIssues.push(`hide 项 ${id} 无 canonical 且未注册(旧流程节点将无法运行)`); + } + } + + // 4) 字段契约抽查:select 枚举 defaultValue 必须在 options 里;hidden 字段不能 required + const fieldIssues: string[] = []; + for (const c of catalog) { + for (const f of c.fields ?? []) { + if (f.widget === "select" && Array.isArray(f.options) && f.options.length > 0) { + const values = f.options.map((o) => String(o.value)); + if (f.defaultValue !== undefined && f.defaultValue !== "" && !values.includes(String(f.defaultValue))) { + fieldIssues.push(`${c.id}.${f.key} select 默认值 "${f.defaultValue}" 不在选项值 [${values.join(",")}] 中`); + } + } + } + } + + const report = { + catalogTotal: catalog.length, + registeredTotal: registered.size, + unregisteredCount: unregistered.length, + unregistered: unregistered.slice(0, 50), + ghostCount: ghosts.length, + ghosts: ghosts.slice(0, 40), + dupIssues, + fieldIssues: fieldIssues.slice(0, 40), + fieldIssueCount: fieldIssues.length + }; + writeFileSync(".compat-report.txt", JSON.stringify(report, null, 1), "utf8"); + }, 120000); +}); diff --git a/packages/rpa-core/src/capabilities/keyboard-send-smoke.test.ts b/packages/rpa-core/src/capabilities/keyboard-send-smoke.test.ts index c7d27a5..02a1c19 100644 --- a/packages/rpa-core/src/capabilities/keyboard-send-smoke.test.ts +++ b/packages/rpa-core/src/capabilities/keyboard-send-smoke.test.ts @@ -3,6 +3,7 @@ import type { CatalogComponent, FlowNode } from "../types"; import { getHandler } from "../engine/registry"; import type { RunContext } from "../engine/types"; import "./desktop"; +import "./common-final"; const component = {} as CatalogComponent; const node = (id: string): FlowNode => ({ id: "smoke-kb", componentId: id, position: { x: 0, y: 0 }, values: {}, options: {} }); @@ -45,6 +46,27 @@ describe("键盘输入组件真实冒烟", () => { console.log("[键盘输入] 人工模拟+中文标点+智能引号 ✓"); }, 60000); + it("兼容性: Keyboard_Press 组合键语法(^s {ENTER} %{F4})不被文本转义破坏", async () => { + // 组合键必须保留 SendKeys 原语法:^=Ctrl %=Alt {ENTER}=回车 + const out = await invoke("Keyboard_Press", { keys: "^s", interval: "10" }); + expect(out).toBe("^s"); + const out2 = await invoke("Keyboard_PressNew", { keys: "{ENTER}%{F4}" }); + expect(out2).toBe("{ENTER}%{F4}"); + console.log("[键盘输入] 组合键 SendKeys 语法保留 ✓"); + }, 60000); + + it("兼容性: Keyboard_DriveSend_V2(键盘驱动输入,委托组件)按文本输入语义转义特殊字符", async () => { + const out = await invoke("Keyboard_DriveSend_V2", { words: "驱动(a*100%)", interval: "5", keyboard_type: "1" }); + expect(out).toBe("驱动(a*100%)"); + console.log("[键盘输入] DriveSend 委托文本转义 ✓:", out); + }, 60000); + + it("兼容性: 基础版 Keyboard_Send interval 秒级换算", async () => { + const out = await invoke("Keyboard_Send", { words: "秒", interval: "0.01" }); + expect(out).toBe("秒"); + console.log("[键盘输入] 基础版秒级间隔 ✓"); + }, 60000); + it("剪切板粘贴模式: 原文进入剪贴板并发送 Ctrl+V", async () => { const content = `粘贴${Date.now()}`; const out = await invoke("Keyboard_Send_V1", { words: content, fuctype: "2" }); diff --git a/packages/rpa-core/src/capabilities/system-extended.ts b/packages/rpa-core/src/capabilities/system-extended.ts index e09610c..39a3050 100644 --- a/packages/rpa-core/src/capabilities/system-extended.ts +++ b/packages/rpa-core/src/capabilities/system-extended.ts @@ -2,7 +2,7 @@ import os from "node:os"; import path from "node:path"; import { register } from "../engine/registry"; import { RuntimeError, type CapabilityHandler } from "../engine/types"; -import { powershell, psString } from "../driver/windows/powershell"; +import { powershell, psString, psText } from "../driver/windows/powershell"; import { defaultEngine } from "../engine/engine"; function value(args: Record, keys: string[], fallback = ""): string { @@ -94,7 +94,7 @@ register(["App_Kill", "App_KillV1"], async (_ctx, node, args) => { register("Clipboard_GetTextV1", () => powershell("Get-Clipboard -Raw")); register("Clipboard_SetTextV1", async (_ctx, _node, args) => { const content = value(args, ["text", "value"]); - await powershell(`Set-Clipboard -Value ${psString(content)}`); + await powershell(`Set-Clipboard -Value ${psText(content)}`); return content; }); register("Clipboard_ClearClipboard", async () => { diff --git a/packages/rpa-core/src/driver/windows/powershell.ts b/packages/rpa-core/src/driver/windows/powershell.ts index 94af0b4..414b89e 100644 --- a/packages/rpa-core/src/driver/windows/powershell.ts +++ b/packages/rpa-core/src/driver/windows/powershell.ts @@ -72,3 +72,14 @@ export async function powershell(command: string, timeout = 30000): Promise `[char]0x${c.codePointAt(0)!.toString(16).padStart(4, "0")}`).join(",")}))`; +} diff --git a/packages/rpa-core/src/driver/windows/windows-driver.ts b/packages/rpa-core/src/driver/windows/windows-driver.ts index 43b70f5..2cf6d51 100644 --- a/packages/rpa-core/src/driver/windows/windows-driver.ts +++ b/packages/rpa-core/src/driver/windows/windows-driver.ts @@ -14,7 +14,7 @@ import type { WindowInfo, WindowSummary } from "../types"; -import { powershell, psString } from "./powershell"; +import { powershell, psString, psText } from "./powershell"; /** * WindowsDriver(任务 3.3):AutomationDriver 的 PowerShell 实现。 @@ -58,7 +58,8 @@ public static class RpaWindowExtended { '@; try { [void][RpaWindowExtended]::SetProcessDpiAwarenessContext([IntPtr](-4)) } catch {}`; function findWindowScript(title: string): string { - return `$p = Get-Process | Where-Object { $_.MainWindowHandle -ne 0 -and $_.MainWindowTitle -like ${psString(`*${title}*`)} } | Select-Object -First 1; if (-not $p) { throw '找不到窗口:${title.replace(/'/g, "''")}' }`; + // throw 消息内嵌用户文本走 psText(智能引号等非 ASCII 会让 PS5.1 字符串字面量未闭合) + return `$p = Get-Process | Where-Object { $_.MainWindowHandle -ne 0 -and $_.MainWindowTitle -like ${psString(`*${title}*`)} } | Select-Object -First 1; if (-not $p) { throw (${psText(`找不到窗口:${title}`)}) }`; } async function windowObject(title: string): Promise { @@ -70,10 +71,11 @@ async function windowObject(title: string): Promise { * Windows 前台锁定策略会拒绝普通 SetForegroundWindow,最小化再恢复可绕过)。 * 失败用 RPA_ERROR 标记写 stdout 后 exit 1:不走 throw/CLIXML,避免控制台宽度截断和编码歧义 */ function activateWindowScript(selector: string): string { - const safeTitle = selector.replace(/'/g, "''"); + // 错误消息内嵌用户文本走 psText(窗口标题可能含智能引号/中文标点,直接放进单引号字面量会被 PS5.1 解析炸) + const failMsg = psText(`找不到窗口:${selector}。当前可见窗口标题: `); return `function RpaFail($m) { [Console]::Out.WriteLine('RPA_ERROR::' + $m); exit 1 } $p = Get-Process | Where-Object { $_.MainWindowTitle -and $_.MainWindowTitle -like ${psString(`*${selector}*`)} } | Select-Object -First 1 -if (-not $p) { $vis = (Get-Process | Where-Object { $_.MainWindowTitle } | Select-Object -First 12 | ForEach-Object { '『' + $_.MainWindowTitle + '』' }) -join ' '; RpaFail ('找不到窗口:${safeTitle}。当前可见窗口标题: ' + $vis) } +if (-not $p) { $vis = (Get-Process | Where-Object { $_.MainWindowTitle } | Select-Object -First 12 | ForEach-Object { '『' + $_.MainWindowTitle + '』' }) -join ' '; RpaFail (${failMsg} + $vis) } 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("user32.dll")] public static extern void SwitchToThisWindow(IntPtr hWnd, bool fAltTab); [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); Start-Sleep -Milliseconds 150 } @@ -108,12 +110,9 @@ function sendKeysEscape(ch: string): string { return "+^%~(){}[]".includes(ch) ? `{${ch}}` : ch; } -/** 把字符包装成 PowerShell 安全字面量。 - * ASCII 直接用单引号字符串(撇号内部 '' 转义);非 ASCII 用 [char]0xXXXX 数值构造—— - * Windows PowerShell 5.1 会把 ‘ ’ “ ” 等智能引号当作引号变体解析,直接放进字符串字面量会报「字符串缺少终止符」。 */ +/** 单字符/短字面量别名(语义标注用),实现同 psText(历史调用点保留) */ function psCharLiteral(text: string): string { - if (/^[\x20-\x7E]*$/.test(text)) return psString(text); - return Array.from(text).map((c) => `[char]0x${c.codePointAt(0)!.toString(16).padStart(4, "0")}`).join("+") || psString(""); + return psText(text); } /** 生成从当前位置到目标的插值路径点(JS 侧算好,PS 侧只执行循环)(原 window-extended.ts 的 mousePath) */ @@ -180,9 +179,8 @@ const windowsKeyboard: KeyboardDriver = { // 三种输入方式:脚本尽量简单,转义逻辑放 JS 侧,避免 PowerShell 字符串拼接的语法陷阱 let send: string; if (options.mode === "clipboard") { - // 剪贴板粘贴:原文进剪贴板 + Ctrl+V,不做 SendKeys 转义;内容含智能引号等非 ASCII 时同样走 [char] 构造 - const contentExpr = /^[\x20-\x7E]*$/.test(options.keys) ? psString(options.keys) : `(-join @(${Array.from(options.keys).map((c) => psCharLiteral(c)).join(",")}))`; - send = `Set-Clipboard -Value ${contentExpr}; Start-Sleep -Milliseconds 120; [System.Windows.Forms.SendKeys]::SendWait('^v')`; + // 剪贴板粘贴:原文进剪贴板 + Ctrl+V,不做 SendKeys 转义 + send = `Set-Clipboard -Value ${psText(options.keys)}; Start-Sleep -Milliseconds 120; [System.Windows.Forms.SendKeys]::SendWait('^v')`; } else if (options.mode === "manual" && options.textMode) { // 人工模拟:逐字符;JS 侧把每个字符转义成 SendKeys 字面量,PS 侧只做数组循环 const escapedChars = Array.from(options.keys).map((ch) => sendKeysEscape(ch)); @@ -191,8 +189,7 @@ const windowsKeyboard: KeyboardDriver = { } else { // 后台接口 / 按键组合 / 人工模拟(按键组合组件):整段发送,文本特殊字符转义 const escaped = options.textMode ? Array.from(options.keys).map(sendKeysEscape).join("") : options.keys; - const contentExpr = /^[\x20-\x7E]*$/.test(escaped) ? psString(escaped) : `(-join @(${Array.from(escaped).map((c) => psCharLiteral(c)).join(",")}))`; - send = `[System.Windows.Forms.SendKeys]::SendWait(${contentExpr})`; + send = `[System.Windows.Forms.SendKeys]::SendWait(${psText(escaped)})`; } const activate = options.activateTitle ? `${activateWindowScript(options.activateTitle)}; Start-Sleep -Milliseconds 300; ` : ""; await powershell(`${USER32}; ${activate}Add-Type -AssemblyName System.Windows.Forms; ${send}`);