返回 CodeWhale
win32-targeting.test.mjs
根目录 / crates / tui / plugins / computer-use / tests / win32-targeting.test.mjs
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
205 lines Plain Text