| 1 | // Win32 backend tests that run on ANY host via the injectable runner seam |
| 2 | // (`create({ exec })`): the fake runner captures the PowerShell scripts so the |
| 3 | // generated commands can be asserted directly, and no real powershell.exe is |
| 4 | // ever spawned. These pin the failure-truthful and self-contained-action |
| 5 | // behavior ported from the codewhale-side hardening (injectable runner + |
| 6 | // truthful PowerShell failure reporting) without needing a Windows host or a |
| 7 | // fake-powershell.exe-on-PATH fixture. |
| 8 | import { test } from "node:test"; |
| 9 | import assert from "node:assert/strict"; |
| 10 | |
| 11 | function decodeScript(args) { |
| 12 | const i = args.indexOf("-EncodedCommand"); |
| 13 | if (i === -1) return null; |
| 14 | return Buffer.from(args[i + 1], "base64").toString("utf16le"); |
| 15 | } |
| 16 | |
| 17 | function mockExec({ fail = false } = {}) { |
| 18 | const calls = []; |
| 19 | const run = async (_cmd, args) => { |
| 20 | calls.push({ script: decodeScript(args) }); |
| 21 | if (fail) return { code: 1, stdout: "", stderr: "simulated powershell failure" }; |
| 22 | return { code: 0, stdout: '{"ok": true}\n', stderr: "" }; |
| 23 | }; |
| 24 | return { run, calls }; |
| 25 | } |
| 26 | |
| 27 | test("win32: actions run through an injected runner (no powershell needed)", async () => { |
| 28 | const { run, calls } = mockExec(); |
| 29 | const mod = await import("../src/backends/win32.mjs"); |
| 30 | const b = mod.create({ exec: { run, persistentInputOwner: true } }); |
| 31 | const r = await b.left_click({ target: { x: 5, y: 6 } }); |
| 32 | assert.equal(r.action_sent, true); |
| 33 | assert.ok(calls.length >= 1, "the injected runner must receive the action command"); |
| 34 | }); |
| 35 | |
| 36 | test("win32: input actions fail truthfully on a nonzero exit", async () => { |
| 37 | const { run } = mockExec({ fail: true }); |
| 38 | const mod = await import("../src/backends/win32.mjs"); |
| 39 | const b = mod.create({ exec: { run, persistentInputOwner: true } }); |
| 40 | await assert.rejects(() => b.left_click({ target: { x: 1, y: 2 } }), /exited 1/); |
| 41 | await assert.rejects(() => b.left_mouse_down({ target: { x: 1, y: 2 } }), /exited 1/); |
| 42 | }); |
| 43 | |
| 44 | test("win32: coordinate clicks refuse strategy=a11y instead of silently degrading", async () => { |
| 45 | const { run, calls } = mockExec(); |
| 46 | const mod = await import("../src/backends/win32.mjs"); |
| 47 | const b = mod.create({ exec: { run, persistentInputOwner: true } }); |
| 48 | // left_click refuses synchronously (it is not async), like every fail-closed |
| 49 | // guard in this backend — see backends.test.mjs for the same convention. |
| 50 | assert.throws(() => b.left_click({ target: { x: 1, y: 2 }, strategy: "a11y" }), /macOS-only/); |
| 51 | assert.equal(calls.length, 0, "no command may be spawned for a refused strategy"); |
| 52 | // auto and event remain accepted on this backend. |
| 53 | assert.equal((await b.left_click({ target: { x: 1, y: 2 }, strategy: "event" })).action_sent, true); |
| 54 | }); |
| 55 | |
| 56 | test("win32: targeted left_mouse_down both moves and presses, self-contained", async () => { |
| 57 | const { run, calls } = mockExec(); |
| 58 | const mod = await import("../src/backends/win32.mjs"); |
| 59 | const b = mod.create({ exec: { run, persistentInputOwner: true } }); |
| 60 | await b.left_mouse_down({ target: { x: 12, y: 34 } }); |
| 61 | const script = calls.at(-1).script; |
| 62 | // Self-contained: the User32 P/Invoke type travels with the action. |
| 63 | assert.ok(script.includes("public static class User32"), "action must define User32 in its own process"); |
| 64 | assert.ok(script.includes("SetCursorPos(12, 34)"), "must move the cursor to the target"); |
| 65 | assert.ok(script.includes("LEFTDOWN"), "must press the left button"); |
| 66 | }); |
| 67 | |
| 68 | test("win32: every User32 action carries the type prelude in-process", async () => { |
| 69 | const { run, calls } = mockExec(); |
| 70 | const mod = await import("../src/backends/win32.mjs"); |
| 71 | const b = mod.create({ exec: { run, persistentInputOwner: true } }); |
| 72 | await b.mouse_move({ target: { x: 3, y: 4 } }); |
| 73 | await b.key({ text: "a" }); |
| 74 | assert.ok(calls.length >= 2, "two actions should have run through the injected runner"); |
| 75 | for (const call of calls) { |
| 76 | assert.ok(call.script.includes("public static class User32"), "each action must redefine User32 in-process"); |
| 77 | } |
| 78 | }); |
| 79 | |
| 80 | test("win32: typing rejects timeout, cancellation and process errors even with success output", async () => { |
| 81 | const mod = await import("../src/backends/win32.mjs"); |
| 82 | for (const [result, expected] of [ |
| 83 | [{ code: 0, timedOut: true }, /timed out/], |
| 84 | [{ code: 0, aborted: true }, /cancelled/], |
| 85 | [{ code: 1, stderr: "SendInput inserted 0 of 2 events" }, /inserted 0 of 2/], |
| 86 | ]) { |
| 87 | const b = mod.create({ exec: { run: async () => ({ stdout: '{"ok":true}', stderr: "", ...result }) } }); |
| 88 | await assert.rejects(b.type({ text: "A中😀" }), expected); |
| 89 | } |
| 90 | }); |
| 91 | |
| 92 | test("win32: Unicode text is data in the shared input script, including shell-looking text", async () => { |
| 93 | const { run, calls } = mockExec(); |
| 94 | const mod = await import("../src/backends/win32.mjs"); |
| 95 | const b = mod.create({ exec: { run } }); |
| 96 | const text = "A中😀'; throw 'should stay text'; $env:SECRET"; |
| 97 | assert.deepEqual(await b.type({ text }), { action_sent: true, chars: text.length, strategy: "unicode-sendinput" }); |
| 98 | const script = calls[0].script; |
| 99 | assert.ok(!script.includes(text), "text must not become PowerShell source"); |
| 100 | const encoded = script.match(/FromBase64String\('([^']+)'\)/)?.[1]; |
| 101 | assert.equal(Buffer.from(encoded, "base64").toString("utf16le"), text); |
| 102 | assert.match(script, /\[User32\]::SendString\(\$text\)/); |
| 103 | assert.ok(!script.includes("class TypeText"), "typing must use the same full INPUT union as keys"); |
| 104 | const count = calls.length; |
| 105 | assert.equal((await b.type({ text: "" })).action_sent, false); |
| 106 | assert.equal(calls.length, count); |
| 107 | }); |
| 108 | |
| 109 | test("win32: unsupported characters cannot become unrelated virtual keys", async () => { |
| 110 | const { run, calls } = mockExec(); |
| 111 | const mod = await import("../src/backends/win32.mjs"); |
| 112 | const b = mod.create({ exec: { run, persistentInputOwner: true } }); |
| 113 | for (const text of ["!", "_", "中", "ß", "ctrl+!", "bogus+a"]) { |
| 114 | await assert.rejects(b.key({ text }), /unknown key combination/); |
| 115 | await assert.rejects(b.hold_key({ text, duration: 0.05 }), /unknown key combination/); |
| 116 | } |
| 117 | assert.equal(calls.length, 0, "refused keys must not spawn input or cleanup"); |
| 118 | for (const text of ["ctrl+A", "9", "alt+tab", "shift"]) assert.equal((await b.key({ text })).action_sent, true); |
| 119 | }); |
| 120 | |
| 121 | test("win32: failed chord delivery releases only owned keys, with no replay", async () => { |
| 122 | const mod = await import("../src/backends/win32.mjs"); |
| 123 | const calls = []; |
| 124 | const b = mod.create({ exec: { run: async (_cmd, args) => { |
| 125 | calls.push(decodeScript(args)); |
| 126 | return calls.length === 1 |
| 127 | ? { code: 1, stdout: "", stderr: "SendInput inserted 1 of 6 events" } |
| 128 | : { code: 0, stdout: "", stderr: "" }; |
| 129 | } } }); |
| 130 | await assert.rejects(b.key({ text: "ctrl+shift+a" }), /inserted 1 of 6/); |
| 131 | assert.equal(calls.length, 2); |
| 132 | const cleanup = calls[1].split("'@ -ErrorAction Stop;")[1]; |
| 133 | assert.match(cleanup, /SendKey\(65, 2\).*SendKey\(16, 2\).*SendKey\(17, 2\)/s); |
| 134 | assert.doesNotMatch(cleanup, /SendKey\(\d+, 0\)|SendString|\$seq/, "recovery must not repeat down events"); |
| 135 | await b.releaseInput(); |
| 136 | assert.equal(calls.length, 2, "successful cleanup clears ownership"); |
| 137 | }); |
| 138 | |
| 139 | test("win32: failed cleanup retains ownership and both failures for a later release", async () => { |
| 140 | const mod = await import("../src/backends/win32.mjs"); |
| 141 | const calls = []; |
| 142 | const run = async (_cmd, args) => { |
| 143 | calls.push(decodeScript(args)); |
| 144 | return calls.length <= 2 |
| 145 | ? { code: 1, stdout: "", stderr: calls.length === 1 ? "SendInput inserted 1 of 4 events" : "SendInput inserted 0 of 1 events" } |
| 146 | : { code: 0, stdout: "", stderr: "" }; |
| 147 | }; |
| 148 | const b = mod.create({ exec: { run } }); |
| 149 | await assert.rejects(b.key({ text: "ctrl+a" }), (err) => { |
| 150 | assert.equal(err.name, "ExecError"); |
| 151 | assert.match(err.message, /inserted 1 of 4.*release failed:.*inserted 0 of 1/); |
| 152 | assert.match(err.cause.message, /inserted 1 of 4/); |
| 153 | assert.match(err.cleanupError.message, /inserted 0 of 1/); |
| 154 | return true; |
| 155 | }); |
| 156 | await mod.create({ exec: { run } }).releaseInput(); |
| 157 | assert.equal(calls.length, 2, "another backend must not release this owner's keys"); |
| 158 | await b.releaseInput(); |
| 159 | assert.equal(calls.length, 3, "failed releases remain owned for retry"); |
| 160 | assert.match(calls[2], /SendKey\(65, 2\).*SendKey\(17, 2\)/s); |
| 161 | await b.releaseInput(); |
| 162 | assert.equal(calls.length, 3); |
| 163 | }); |
| 164 | |
| 165 | test("win32: wheel packets preserve signed DWORD bits in both axes and clamp notches", async () => { |
| 166 | const { run, calls } = mockExec(); |
| 167 | const mod = await import("../src/backends/win32.mjs"); |
| 168 | const b = mod.create({ exec: { run } }); |
| 169 | for (const [direction, amount, vertical, horizontal] of [ |
| 170 | ["down", 3, "4294966936", "0"], ["up", 3, "360", "0"], |
| 171 | ["left", 3, "0", "4294966936"], ["right", 3, "0", "360"], |
| 172 | ["down", 100, "4294963696", "0"], ["up", 0, "120", "0"], |
| 173 | ]) { |
| 174 | await b.scroll({ target: { x: 1, y: 2 }, direction, amount }); |
| 175 | const script = calls.at(-1).script; |
| 176 | assert.ok(script.includes(`::WHEEL, 0, 0, ${vertical}, [UIntPtr]::Zero)`)); |
| 177 | assert.ok(script.includes(`::HWHEEL, 0, 0, ${horizontal}, [UIntPtr]::Zero)`)); |
| 178 | assert.doesNotMatch(script, /-band 0xFFFFFFFF/); |
| 179 | } |
| 180 | }); |
| 181 | |
| 182 | test('win32: single-monitor discovery preserves an array and negative raster origins', async t => { |
| 183 | const fs = await import('node:fs'); const os = await import('node:os'); const path = await import('node:path'); |
| 184 | const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cu-win-capture-')); |
| 185 | t.after(() => fs.rmSync(dir, { recursive: true, force: true })); |
| 186 | const file = path.join(dir, 'shot.png'); |
| 187 | const scripts = []; |
| 188 | const mod = await import('../src/backends/win32.mjs'); |
| 189 | const b = mod.create({ exec: { run: async (_cmd, args) => { |
| 190 | const script = decodeScript(args); scripts.push(script); |
| 191 | if (script.includes('CopyFromScreen')) { fs.writeFileSync(file, 'fixture'); return { code: 0, stdout: JSON.stringify({x:-1920,y:-100,w:1920,h:1080}), stderr:'' }; } |
| 192 | return { code:0, stdout: JSON.stringify({ displays:[{name:'one',primary:true,x:-1920,y:-100,w:1920,h:1080}] }), stderr:'' }; |
| 193 | } } }); |
| 194 | assert.equal((await b.list_displays()).length, 1); |
| 195 | assert.match(scripts[0], /displays = \$arr/); |
| 196 | const shot = await b.screenshot({path:file}); |
| 197 | assert.deepEqual(shot.points, {x:-1920,y:-100,w:1920,h:1080}); |
| 198 | await assert.rejects(b.screenshot({region:[0,0,-1,5]}), /region/); |
| 199 | for (const result of [{code:0,timedOut:true},{code:0,aborted:true},{code:1}]) { |
| 200 | const broken = mod.create({ exec:{run:async()=>({stdout:'{"x":0,"y":0,"w":1,"h":1}',stderr:'capture failure',...result})} }); |
| 201 | await assert.rejects(broken.screenshot({path:file})); |
| 202 | await assert.rejects(broken.open_application({name:'notepad'})); |
| 203 | } |
| 204 | }); |
| 205 | |
| 206 | test('win32: semantic mutations carry window and leaf identities and traverse zero child indices', async () => { |
| 207 | const mod = await import('../src/backends/win32.mjs'); |
| 208 | const scripts=[]; |
| 209 | const b=mod.create({exec:{run:async(_cmd,args)=>{scripts.push(decodeScript(args));return {code:0,stdout:'{"ok":true,"verified":true}',stderr:''};}}}); |
| 210 | const target={path:[0,0,0],runtime_id:[42,10,1],window_runtime_id:[42,10],app_ref:{name:'Fixture'},windowIndex:0,role:'Edit',label:''}; |
| 211 | assert.equal((await b.set_value({target,value:'漢😀'})).verified,true); |
| 212 | await b.perform_action({target,action:'Invoke'}); |
| 213 | for(const script of scripts){ |
| 214 | assert.match(script,/\$step = 1/); assert.doesNotMatch(script,/if \(\$i -eq 0\)/); |
| 215 | assert.match(script,/observed element was replaced/); assert.match(script,/observed window no longer exists/); |
| 216 | assert.ok(script.indexOf('element_stale') < script.indexOf('$vp.SetValue') || !script.includes('$vp.SetValue')); |
| 217 | } |
| 218 | await assert.rejects(b.perform_action({target,action:"';Invoke-Expression evil"}), /unsupported UIA action/); |
| 219 | }); |
| 220 | |
| 221 | test('win32: CLIXML reports the actual PowerShell error rather than its serialization envelope', async () => { |
| 222 | const mod=await import('../src/backends/win32.mjs'); |
| 223 | const b=mod.create({exec:{run:async()=>({code:1,stdout:'',stderr:'#< CLIXML\n<Objs><S S="Error">native failure <target>_x000D__x000A_</S></Objs>'})}}); |
| 224 | await assert.rejects(b.type({text:'x'}), error => /native failure <target>/.test(error.message) && !/CLIXML|<Objs>/.test(error.message)); |
| 225 | }); |
| 226 |