| 1 | // Actual backend dispatch with an injected runner. Windows-only cases execute |
| 2 | // source-derived PowerShell after replacing every desktop access with fixtures. |
| 3 | import assert from "node:assert/strict"; |
| 4 | import { spawnSync } from "node:child_process"; |
| 5 | import path from "node:path"; |
| 6 | import { test } from "node:test"; |
| 7 | import win32 from "../src/backends/win32.mjs"; |
| 8 | |
| 9 | const WINDOWS = { skip: process.platform !== "win32" && "Requires Windows PowerShell; synthetic window metadata only", timeout: 60_000 }; |
| 10 | const FORBIDDEN = /DllImport|LibraryImport|\bextern\b|user32\.dll|GetDelegateForFunctionPointer|NativeLibrary|SendKeys|UIAutomationClient|UIAutomationTypes|System\.Windows\.(?:Automation|Forms)|CopyFromScreen/iu; |
| 11 | const FRAME = "CU_TARGETING_FIXTURE:"; |
| 12 | |
| 13 | function decode(command, args) { |
| 14 | assert.equal(command, "powershell.exe"); |
| 15 | assert.deepEqual(args.slice(0, 3), ["-NoProfile", "-NonInteractive", "-EncodedCommand"]); |
| 16 | assert.equal(args.length, 4); |
| 17 | return Buffer.from(args[3], "base64").toString("utf16le"); |
| 18 | } |
| 19 | |
| 20 | function capture(result = { found: true, name: "Fixture", elements: [], windows: [] }) { |
| 21 | const scripts = []; |
| 22 | const backend = win32.create({ exec: { run: async (command, args) => { |
| 23 | scripts.push(decode(command, args)); |
| 24 | return { code: 0, stdout: JSON.stringify(result), stderr: "" }; |
| 25 | } } }); |
| 26 | return { backend, scripts }; |
| 27 | } |
| 28 | |
| 29 | function replaceOne(script, from, to) { |
| 30 | assert.equal(script.split(from).length, 2, `Expected exactly one fixture boundary: ${from}`); |
| 31 | return script.replace(from, to); |
| 32 | } |
| 33 | |
| 34 | function windowFixture(original, rows) { |
| 35 | let script = replaceOne(original, "Add-Type -AssemblyName System.Windows.Forms;", ""); |
| 36 | const blocks = [...script.matchAll(/Add-Type -TypeDefinition @'\n([\s\S]*?)\n'@;/gu)]; |
| 37 | assert.equal(blocks.length, 1); |
| 38 | const imports = [...blocks[0][1].matchAll(/\[DllImport\("user32\.dll"\)\] static extern [^;]+? (\w+)\([^;]+;/gu)].map((match) => match[1]); |
| 39 | assert.deepEqual(imports.sort(), ["EnumWindows", "GetWindowRect", "GetWindowText", "GetWindowTextLength", "GetWindowThreadProcessId", "IsWindowVisible"].sort()); |
| 40 | // Replace the complete native declaration block, preserving the production |
| 41 | // PowerShell parsing/serialization tail and backend JavaScript mapping. |
| 42 | script = script.replace(blocks[0][0], `Add-Type -TypeDefinition @' |
| 43 | using System.Collections.Generic; |
| 44 | public static class WinEnum { |
| 45 | public static List<string> List() { return new List<string> { ${rows.map((row) => JSON.stringify(row)).join(", ")} }; } |
| 46 | } |
| 47 | '@;`); |
| 48 | assert.doesNotMatch(script, FORBIDDEN); |
| 49 | return script; |
| 50 | } |
| 51 | |
| 52 | function stateFixture(original, names) { |
| 53 | const walk = "foreach ($t in $targets) {"; |
| 54 | assert.equal(original.split(walk).length, 2); |
| 55 | // Keep the production name decoding, equality filter and ambiguity check. |
| 56 | // Stop before the content walk: the fixture supplies root-window names only. |
| 57 | let script = original.slice(0, original.indexOf(walk)); |
| 58 | script = replaceOne(script, "Add-Type -AssemblyName UIAutomationClient;", ""); |
| 59 | script = replaceOne(script, "Add-Type -AssemblyName UIAutomationTypes;", ""); |
| 60 | const provider = /Add-Type -ReferencedAssemblies[^\n]+[\s\S]*?\[CUAutomationProviders\]::Register\(\);/u; |
| 61 | assert.match(script, provider); |
| 62 | script = script.replace(provider, ""); |
| 63 | script = replaceOne(script, "$root = [System.Windows.Automation.AutomationElement]::RootElement;", ""); |
| 64 | const data = Buffer.from(JSON.stringify(names), "utf16le").toString("base64"); |
| 65 | script = replaceOne(script, "$targets = @($root.FindAll([System.Windows.Automation.TreeScope]::Children, [System.Windows.Automation.Condition]::TrueCondition));", |
| 66 | `$fixtureNames = [Text.Encoding]::Unicode.GetString([Convert]::FromBase64String('${data}')) | ConvertFrom-Json; |
| 67 | $targets = @($fixtureNames | ForEach-Object { [pscustomobject]@{ Current = [pscustomobject]@{ Name = [string]$_ } } });`); |
| 68 | script += ` |
| 69 | $selected = @($targets | ForEach-Object { $_.Current.Name }); |
| 70 | $name = $null; if ($selected.Count -gt 0) { $name = $selected[0] } |
| 71 | Write-Output (@{ found = $selected.Count -gt 0; name = $name; elements = @() } | ConvertTo-Json -Compress);`; |
| 72 | assert.doesNotMatch(script, FORBIDDEN); |
| 73 | return script; |
| 74 | } |
| 75 | |
| 76 | function encodedFixture(script) { |
| 77 | const wrapped = `[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false); |
| 78 | $fixtureOutput = New-Object 'System.Collections.Generic.List[string]'; $fixtureError = $null; |
| 79 | try { |
| 80 | & { |
| 81 | ${script} |
| 82 | } | ForEach-Object { [void]$fixtureOutput.Add([string]$_); }; |
| 83 | } catch { $fixtureError = $_.Exception.ToString(); } |
| 84 | Write-Output ('${FRAME}' + (@{ output = @($fixtureOutput.ToArray()); error = $fixtureError } | ConvertTo-Json -Depth 6 -Compress)); |
| 85 | if ($null -ne $fixtureError) { exit 86; } |
| 86 | `; |
| 87 | assert.doesNotMatch(wrapped, FORBIDDEN, "No native import or desktop access may survive fixture preparation"); |
| 88 | const encoded = Buffer.from(wrapped, "utf16le").toString("base64"); |
| 89 | assert.ok(encoded.length < 30_000, "The fixture must fit the Windows command-line limit"); |
| 90 | return encoded; |
| 91 | } |
| 92 | |
| 93 | function nativeFixture(prepare) { |
| 94 | return win32.create({ exec: { run: async (command, args) => { |
| 95 | assert.equal(process.platform, "win32", "Only Windows can execute the prepared fixture"); |
| 96 | const encoded = encodedFixture(prepare(decode(command, args))); |
| 97 | const executable = path.join(process.env.SystemRoot || "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe"); |
| 98 | const result = spawnSync(executable, ["-NoProfile", "-NonInteractive", "-EncodedCommand", encoded], { |
| 99 | encoding: "utf8", windowsHide: true, timeout: 25_000, maxBuffer: 4 * 1024 * 1024, |
| 100 | }); |
| 101 | assert.ifError(result.error); |
| 102 | assert.equal(result.signal, null); |
| 103 | const frames = result.stdout.split(/\r?\n/u).filter((line) => line.startsWith(FRAME)); |
| 104 | assert.equal(frames.length, 1, `Expected one fixture receipt: ${result.stderr}`); |
| 105 | const trace = JSON.parse(frames[0].slice(FRAME.length)); |
| 106 | return { code: result.status, stdout: trace.output.join("\n"), stderr: trace.error || result.stderr }; |
| 107 | } } }); |
| 108 | } |
| 109 | |
| 110 | test("Windows window listing and screenshots reject every explicit selector before a runner call", async () => { |
| 111 | const { backend, scripts } = capture(); |
| 112 | for (const tool of ["list_windows", "screenshot"]) { |
| 113 | for (const key of ["app_ref", "window_id"]) { |
| 114 | for (const value of [undefined, null, false, 0, -1, 1, 1.5, "Fixture", [], {}, { name: "Fixture" }, { pid: 123 }]) { |
| 115 | await assert.rejects(backend[tool]({ [key]: value }), { code: "unsupported_selector" }); |
| 116 | } |
| 117 | } |
| 118 | } |
| 119 | assert.equal(scripts.length, 0, "Unsupported selection must not execute even a discovery or capture command"); |
| 120 | }); |
| 121 | |
| 122 | test("Windows state rejects malformed and unsupported references before a runner call", async () => { |
| 123 | const { backend, scripts } = capture(); |
| 124 | for (const app_ref of [undefined, null, false, 0, "Fixture", [], {}, { name: "" }, { name: " \n" }, { name: 123 }, { name: null }, { pid: 123 }, { bundle_id: "test.fixture" }, { name: "Fixture", pid: 123 }, { name: "Fixture", extra: true }, { app_ref: { name: "Fixture" } }]) { |
| 125 | await assert.rejects(backend.get_app_state({ app_ref }), { code: "unsupported_selector" }); |
| 126 | } |
| 127 | for (const window_id of [undefined, null, 0, 1, "0", {}]) { |
| 128 | await assert.rejects(backend.get_app_state({ app_ref: { name: "Fixture" }, window_id }), { code: "unsupported_selector" }); |
| 129 | } |
| 130 | assert.equal(scripts.length, 0); |
| 131 | }); |
| 132 | |
| 133 | test("Windows semantic actions refuse ignored target identities before any UIA mutation", async () => { |
| 134 | const { backend, scripts } = capture(); |
| 135 | for (const tool of ["set_value", "perform_action"]) { |
| 136 | for (const key of ["app_ref", "windowIndex", "window_id"]) { |
| 137 | for (const value of [undefined, null, 0, 1, -1, {}, { name: "Fixture" }, { pid: 123 }]) { |
| 138 | await assert.rejects(backend[tool]({ target: { path: [0], [key]: value }, value: "fixture", action: "Invoke" }), { code: "unsupported_selector" }); |
| 139 | } |
| 140 | } |
| 141 | } |
| 142 | assert.equal(scripts.length, 0, "An unsupported element identity must not read or mutate another UIA window"); |
| 143 | }); |
| 144 | |
| 145 | test("Windows state preserves omitted selection and carries exact window names as data", async () => { |
| 146 | const { backend, scripts } = capture(); |
| 147 | await backend.get_app_state({}); |
| 148 | const name = "O'Brien [*]? | 中; $env:SECRET"; |
| 149 | await backend.get_app_state({ app_ref: { name } }); |
| 150 | const script = scripts.at(-1); |
| 151 | const data = script.match(/FromBase64String\('([^']*)'\)/u)[1]; |
| 152 | assert.equal(Buffer.from(data, "base64").toString("utf16le"), name); |
| 153 | assert.ok(!script.includes(name)); |
| 154 | assert.match(script, /\[string\]::Equals\(\$_\.Current\.Name, \$filter, \[StringComparison\]::OrdinalIgnoreCase\)/u); |
| 155 | assert.doesNotMatch(script, /-notlike|-like/u); |
| 156 | assert.ok(script.indexOf("$targets.Count -gt 1") < script.indexOf("foreach ($t in $targets)")); |
| 157 | }); |
| 158 | |
| 159 | test("Windows window-list script and JavaScript mapping retain complete window metadata", async () => { |
| 160 | const { backend, scripts } = capture({ windows: { pid2: 123, geom: "-10,-20,300,400", title: "O'Brien | [*]?" } }); |
| 161 | assert.deepEqual(await backend.list_windows({}), { windows: [{ pid: 123, title: "O'Brien | [*]?", position: { x: -10, y: -20 }, size: { w: 300, h: 400 } }] }); |
| 162 | assert.match(scripts[0], /\$json = \[WinEnum\]::List\(\) \|/u); |
| 163 | }); |
| 164 | |
| 165 | test("Windows observation fixture removes native access and fits its encoded envelope", async () => { |
| 166 | const { backend, scripts } = capture(); |
| 167 | await backend.list_windows({}); |
| 168 | await backend.get_app_state({ app_ref: { name: "Fixture" } }); |
| 169 | for (const script of [windowFixture(scripts[0], ["123|-10,-20,300,400|Fixture"]), stateFixture(scripts[1], ["Other", "Fixture"])]) { |
| 170 | assert.doesNotMatch(script, FORBIDDEN); |
| 171 | assert.ok(encodedFixture(script).length < 30_000); |
| 172 | } |
| 173 | assert.throws(() => windowFixture(scripts[0].replace("GetWindowRect(IntPtr", "UnknownNative(IntPtr"), [])); |
| 174 | assert.throws(() => encodedFixture(windowFixture(scripts[0], []) + "\n[System.Windows.Automation.AutomationElement]::RootElement")); |
| 175 | }); |
| 176 | |
| 177 | test("Windows PowerShell parses real window-list output for zero, one and multiple windows", WINDOWS, async () => { |
| 178 | for (const rows of [[], ["123|-10,-20,300,400|O'Brien [*]? | 中"], ["123|0,0,300,400|First", "999|-500,10,200,100|Second"]]) { |
| 179 | const backend = nativeFixture((script) => windowFixture(script, rows)); |
| 180 | const result = await backend.list_windows({}); |
| 181 | assert.equal(result.windows.length, rows.length); |
| 182 | assert.deepEqual(result.windows, rows.map((row) => { |
| 183 | const first = row.indexOf("|"); const second = row.indexOf("|", first + 1); |
| 184 | const [x, y, w, h] = row.slice(first + 1, second).split(",").map(Number); |
| 185 | return { pid: Number(row.slice(0, first)), title: row.slice(second + 1), position: { x, y }, size: { w, h } }; |
| 186 | })); |
| 187 | } |
| 188 | }); |
| 189 | |
| 190 | test("Windows PowerShell matches literal complete window names and preserves omission", WINDOWS, async () => { |
| 191 | const name = "O'Brien [*]? | 中; $env:SECRET"; |
| 192 | const backend = nativeFixture((script) => stateFixture(script, ["Other", name, "Longer " + name])); |
| 193 | assert.equal((await backend.get_app_state({ app_ref: { name } })).name, name); |
| 194 | assert.equal((await backend.get_app_state({})).name, "Other"); |
| 195 | const casing = nativeFixture((script) => stateFixture(script, ["Other", "FIXTURE"])); |
| 196 | assert.equal((await casing.get_app_state({ app_ref: { name: "fixture" } })).name, "FIXTURE"); |
| 197 | }); |
| 198 | |
| 199 | test("Windows PowerShell refuses substring-only and ambiguous exact names", WINDOWS, async () => { |
| 200 | const missing = nativeFixture((script) => stateFixture(script, ["Other", "Fixture extended"])); |
| 201 | await assert.rejects(missing.get_app_state({ app_ref: { name: "Fixture" } }), /application window not found/u); |
| 202 | const ambiguous = nativeFixture((script) => stateFixture(script, ["Fixture", "FIXTURE"])); |
| 203 | await assert.rejects(ambiguous.get_app_state({ app_ref: { name: "Fixture" } }), /More than one application window/u); |
| 204 | }); |
| 205 |