diff --git a/src/main/capabilities/compat-audit.test.ts b/src/main/capabilities/compat-audit.test.ts new file mode 100644 index 0000000..ffd2a5d --- /dev/null +++ b/src/main/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/src/main/capabilities/desktop.ts b/src/main/capabilities/desktop.ts index e369ed2..c05b306 100644 --- a/src/main/capabilities/desktop.ts +++ b/src/main/capabilities/desktop.ts @@ -141,13 +141,19 @@ function sendKeysEscape(ch: string): string { } /** - * 把字符包装成 PowerShell 安全字面量。 - * ASCII 直接用单引号字符串(撇号内部 '' 转义);非 ASCII 用 [char]0xXXXX 数值构造—— + * 把文本包装成 PowerShell 安全表达式。 + * ASCII 直接用单引号字符串(撇号内部 '' 转义);含非 ASCII 用 [char]0xXXXX 数值构造—— * Windows PowerShell 5.1 会把 ‘ ’ “ ” 等智能引号当作引号变体解析,直接放进字符串字面量会报「字符串缺少终止符」。 + * 所有内嵌用户文本的脚本生成点(Set-Clipboard/SendWait/throw 消息等)都应使用本函数而非 psString。 */ -function psCharLiteral(text: string): string { +export function psText(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 `(-join @(${Array.from(text).map((c) => `[char]0x${c.codePointAt(0)!.toString(16).padStart(4, "0")}`).join(",")}))`; +} + +/** 单字符/短字面量的别名(语义标注用),实现同 psText */ +function psCharLiteral(text: string): string { + return psText(text); } const keyboardSend: CapabilityHandler = async (_ctx, node, args) => { @@ -168,9 +174,8 @@ const keyboardSend: CapabilityHandler = async (_ctx, node, args) => { // 三种输入方式:脚本尽量简单,转义逻辑放 JS 侧,避免 PowerShell 字符串拼接的语法陷阱 let send: string; if (mode === "2") { - // 剪贴板粘贴:原文进剪贴板 + Ctrl+V,不做 SendKeys 转义;内容含智能引号等非 ASCII 时同样走 [char] 构造 - const contentExpr = /^[\x20-\x7E]*$/.test(keys) ? psString(keys) : `(-join @(${Array.from(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(keys)}; Start-Sleep -Milliseconds 120; [System.Windows.Forms.SendKeys]::SendWait('^v')`; } else if (mode === "1" && isTextSend) { // 人工模拟:逐字符;JS 侧把每个字符转义成 SendKeys 字面量,PS 侧只做数组循环 // 数组元素必须用 psCharLiteral:ASCII 走引号字符串,非 ASCII 走 [char]0xXXXX(避开 PS5.1 智能引号解析陷阱) @@ -180,8 +185,7 @@ const keyboardSend: CapabilityHandler = async (_ctx, node, args) => { } else { // 后台接口 / 按键组合 / 人工模拟(按键组合组件):整段发送,文本特殊字符转义 const escaped = isTextSend ? Array.from(keys).map(sendKeysEscape).join("") : 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)})`; } await powershell(`${USER32}; ${activate}Add-Type -AssemblyName System.Windows.Forms; ${send}`); await manualDelay(); @@ -237,10 +241,11 @@ const mouseRoll: CapabilityHandler = async (_ctx, node, args) => { * 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 } @@ -306,14 +311,14 @@ const runApp: CapabilityHandler = async (_ctx, node, args) => { const clipboardGet: CapabilityHandler = async () => powershell("Get-Clipboard -Raw"); const clipboardSet: CapabilityHandler = async (_ctx, _node, args) => { const content = text(args, ["text", "value"]); - await powershell(`Set-Clipboard -Value ${psString(content)}`); + await powershell(`Set-Clipboard -Value ${psText(content)}`); return content; }; const notice: CapabilityHandler = async (_ctx, _node, args) => { const title = text(args, ["title"], "提醒"); const content = text(args, ["text", "message"]); - await powershell(`Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.MessageBox]::Show(${psString(content)},${psString(title)}) | Out-Null`); + await powershell(`Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.MessageBox]::Show(${psText(content)},${psText(title)}) | Out-Null`); return true; }; diff --git a/src/main/capabilities/system-extended.ts b/src/main/capabilities/system-extended.ts index 3beffc8..a23c7fa 100644 --- a/src/main/capabilities/system-extended.ts +++ b/src/main/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 "./desktop"; +import { powershell, psString, psText } from "./desktop"; function value(args: Record, keys: string[], fallback = ""): string { for (const key of keys) { @@ -43,7 +43,8 @@ public static class RpaWindowApi { '@`; 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}`)}) }`; } register("System_GetEnv", (_ctx, node, args) => { @@ -105,7 +106,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/src/main/capabilities/window-extended.ts b/src/main/capabilities/window-extended.ts index 2e3ff9b..166967f 100644 --- a/src/main/capabilities/window-extended.ts +++ b/src/main/capabilities/window-extended.ts @@ -1,6 +1,6 @@ import { register } from "../engine/registry"; import { RuntimeError, type CapabilityHandler } from "../engine/types"; -import { powershell, psString } from "./desktop"; +import { powershell, psString, psText } from "./desktop"; import { screen } from "electron"; const WINDOW_API = `Add-Type @' @@ -50,7 +50,8 @@ function requiredTitle(args: Record, nodeId: string, componentI } function findWindow(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}`)}) }`; } function parsePoint(candidate: unknown, label: string, nodeId: string, componentId: string): [number, number] {