| 1 | // Windows backend input truthfulness tests. No Windows box (or GUI) is |
| 2 | // involved: a fake powershell.exe is placed on PATH, captures the decoded |
| 3 | // -EncodedCommand payload of every spawn, and exits with a code the test |
| 4 | // controls — mirroring how the failure-truthfulness was verified when the |
| 5 | // fix was developed on the codewhale side before this port. |
| 6 | import { test as nodeTest } from "node:test"; |
| 7 | // This transport fixture is a POSIX shell executable, not a Windows PE file. |
| 8 | // Windows executes the generated commands against managed stubs in |
| 9 | // win32-native-contract.test.mjs; portable runner tests cover every host. |
| 10 | const test = (name, fn) => nodeTest(name, { skip: process.platform === "win32" ? "POSIX fake-executable fixture; use the Windows managed-stub suite" : false }, fn); |
| 11 | import assert from "node:assert/strict"; |
| 12 | import fs from "node:fs"; |
| 13 | import os from "node:os"; |
| 14 | import path from "node:path"; |
| 15 | import win32 from "../src/backends/win32.mjs"; |
| 16 | import { ExecError } from "../src/exec.mjs"; |
| 17 | |
| 18 | // Fake powershell.exe logic. It lives in a .mjs file because Node's loader |
| 19 | // refuses to execute a script whose name ends in .exe; powershell.exe itself |
| 20 | // is a #!/bin/sh launcher that execs node on this file (extension is |
| 21 | // irrelevant to the kernel, only to Node's module loader). |
| 22 | const FAKE_PS_MJS = ` |
| 23 | import fs from "node:fs"; |
| 24 | const args = process.argv.slice(2); |
| 25 | const i = args.indexOf("-EncodedCommand"); |
| 26 | const script = i >= 0 ? Buffer.from(args[i + 1], "base64").toString("utf16le") : ""; |
| 27 | if (process.env.CU_FAKE_PS_CAPTURE) { |
| 28 | fs.appendFileSync(process.env.CU_FAKE_PS_CAPTURE, JSON.stringify({ args, script }) + "\\n"); |
| 29 | } |
| 30 | if (process.env.CU_FAKE_PS_STDOUT) process.stdout.write(process.env.CU_FAKE_PS_STDOUT); |
| 31 | if (process.env.CU_FAKE_PS_STDERR) process.stderr.write(process.env.CU_FAKE_PS_STDERR); |
| 32 | process.exit(Number(process.env.CU_FAKE_PS_EXIT || 0)); |
| 33 | `; |
| 34 | |
| 35 | /** |
| 36 | * Install a controllable fake powershell.exe on PATH (or, with onPath:false, |
| 37 | * guarantee no powershell.exe at all) and restore every mutation in t.after. |
| 38 | * Returns calls(): the decoded {args, script} of each spawn so far. |
| 39 | */ |
| 40 | function fakePowershell(t, { exit = 0, stdout = "", stderr = "", onPath = true } = {}) { |
| 41 | const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cu-win32-test-")); |
| 42 | const capture = path.join(dir, "captured.jsonl"); |
| 43 | if (onPath) { |
| 44 | const mjs = path.join(dir, "ps-fake.mjs"); |
| 45 | fs.writeFileSync(mjs, FAKE_PS_MJS); |
| 46 | const bin = path.join(dir, "powershell.exe"); |
| 47 | fs.writeFileSync(bin, `#!/bin/sh\nexec node ${JSON.stringify(mjs)} "$@"\n`); |
| 48 | fs.chmodSync(bin, 0o755); |
| 49 | } |
| 50 | const saved = { |
| 51 | PATH: process.env.PATH, |
| 52 | CU_FAKE_PS_EXIT: process.env.CU_FAKE_PS_EXIT, |
| 53 | CU_FAKE_PS_STDOUT: process.env.CU_FAKE_PS_STDOUT, |
| 54 | CU_FAKE_PS_STDERR: process.env.CU_FAKE_PS_STDERR, |
| 55 | CU_FAKE_PS_CAPTURE: process.env.CU_FAKE_PS_CAPTURE, |
| 56 | }; |
| 57 | process.env.PATH = onPath ? `${dir}${path.delimiter}${process.env.PATH}` : dir; |
| 58 | process.env.CU_FAKE_PS_EXIT = String(exit); |
| 59 | process.env.CU_FAKE_PS_STDOUT = stdout; |
| 60 | process.env.CU_FAKE_PS_STDERR = stderr; |
| 61 | process.env.CU_FAKE_PS_CAPTURE = capture; |
| 62 | t.after(() => { |
| 63 | process.env.PATH = saved.PATH; |
| 64 | for (const [k, v] of Object.entries({ |
| 65 | CU_FAKE_PS_EXIT: saved.CU_FAKE_PS_EXIT, |
| 66 | CU_FAKE_PS_STDOUT: saved.CU_FAKE_PS_STDOUT, |
| 67 | CU_FAKE_PS_STDERR: saved.CU_FAKE_PS_STDERR, |
| 68 | CU_FAKE_PS_CAPTURE: saved.CU_FAKE_PS_CAPTURE, |
| 69 | })) { |
| 70 | if (v === undefined) delete process.env[k]; |
| 71 | else process.env[k] = v; |
| 72 | } |
| 73 | fs.rmSync(dir, { recursive: true, force: true }); |
| 74 | }); |
| 75 | const calls = () => |
| 76 | fs.existsSync(capture) |
| 77 | ? fs.readFileSync(capture, "utf8").split("\n").filter(Boolean).map((l) => JSON.parse(l)) |
| 78 | : []; |
| 79 | return { calls }; |
| 80 | } |
| 81 | |
| 82 | test("win32: targeted left_mouse_down both moves and presses in one self-contained command", async (t) => { |
| 83 | const fake = fakePowershell(t); |
| 84 | const backend = win32.create({ exec: { persistentInputOwner: true } }); |
| 85 | const r = await backend.left_mouse_down({ target: { x: 12, y: 34 } }); |
| 86 | assert.deepEqual(r, { action_sent: true }); |
| 87 | const calls = fake.calls(); |
| 88 | assert.equal(calls.length, 1, "exactly one PowerShell process — no bootstrap process is relied on"); |
| 89 | assert.ok(calls[0].args.includes("-EncodedCommand"), "script travels as an encoded command"); |
| 90 | const script = calls[0].script; |
| 91 | assert.match(script, /Add-Type -TypeDefinition/, "the action command carries the type definition itself"); |
| 92 | assert.match(script, /public static class User32/); |
| 93 | assert.match(script, /\[User32\]::SetCursorPos\(12, 34\)/, "targeted press moves the cursor"); |
| 94 | assert.match(script, /\[User32\]::mouse_event\(\[User32\]::LEFTDOWN/, "targeted press also presses"); |
| 95 | }); |
| 96 | |
| 97 | test("win32: PowerShell exit != 0 becomes an error, never action_sent:true", async (t) => { |
| 98 | fakePowershell(t, { exit: 1, stderr: "unable to find type [User32]" }); |
| 99 | const backend = win32.create({ exec: { persistentInputOwner: true } }); |
| 100 | const cases = [ |
| 101 | ["left_mouse_down", () => backend.left_mouse_down({ target: { x: 1, y: 2 } })], |
| 102 | ["mouse_move", () => backend.mouse_move({ target: { x: 1, y: 2 } })], |
| 103 | ["key", () => backend.key({ text: "enter" })], |
| 104 | ]; |
| 105 | for (const [name, fn] of cases) { |
| 106 | await assert.rejects(fn(), (e) => { |
| 107 | assert.ok(e instanceof ExecError, `${name} rejects with ExecError`); |
| 108 | assert.match(e.message, /exited 1/, `${name} reports the nonzero exit code`); |
| 109 | assert.match(e.message, /unable to find type/, `${name} surfaces PowerShell stderr`); |
| 110 | return true; |
| 111 | }, `${name} must not report success when PowerShell exits nonzero`); |
| 112 | } |
| 113 | }); |
| 114 | |
| 115 | test("win32: spawn failure (powershell.exe missing) becomes an error, never action_sent:true", async (t) => { |
| 116 | fakePowershell(t, { onPath: false }); |
| 117 | const backend = win32.create({ exec: { persistentInputOwner: true } }); |
| 118 | await assert.rejects(backend.left_mouse_down({ target: { x: 1, y: 2 } }), (e) => { |
| 119 | assert.ok(e instanceof ExecError); |
| 120 | assert.match(e.message, /exited -1|ENOENT/); |
| 121 | return true; |
| 122 | }); |
| 123 | }); |
| 124 | |
| 125 | test("win32: successful input still reports success", async (t) => { |
| 126 | const fake = fakePowershell(t); |
| 127 | const backend = win32.create({ exec: { persistentInputOwner: true } }); |
| 128 | assert.deepEqual(await backend.mouse_move({ target: { x: 5, y: 6 } }), { action_sent: true, at: { x: 5, y: 6 } }); |
| 129 | const click = await backend.left_click({ target: { x: 9, y: 8 } }); |
| 130 | assert.equal(click.action_sent, true); |
| 131 | const clickScript = fake.calls()[1].script; |
| 132 | assert.match(clickScript, /\[User32\]::SetCursorPos\(9, 8\)/); |
| 133 | assert.match(clickScript, /\[User32\]::LEFTDOWN/); |
| 134 | assert.match(clickScript, /\[User32\]::LEFTUP/); |
| 135 | }); |
| 136 | |
| 137 | test("win32: every User32-backed action carries the Add-Type definition in its own process", async (t) => { |
| 138 | const fake = fakePowershell(t); |
| 139 | const backend = win32.create({ exec: { persistentInputOwner: true } }); |
| 140 | const at = { x: 3, y: 4 }; |
| 141 | const actions = [ |
| 142 | ["mouse_move", () => backend.mouse_move({ target: at })], |
| 143 | ["left_mouse_down", () => backend.left_mouse_down({ target: at })], |
| 144 | ["left_mouse_up", () => backend.left_mouse_up()], |
| 145 | ["left_click", () => backend.left_click({ target: at })], |
| 146 | ["double_click", () => backend.double_click({ target: at })], |
| 147 | ["right_click", () => backend.right_click({ target: at })], |
| 148 | ["middle_click", () => backend.middle_click({ target: at })], |
| 149 | ["left_click_drag", () => backend.left_click_drag({ from_target: at, to: { x: 10, y: 11 } })], |
| 150 | ["scroll", () => backend.scroll({ target: at, direction: "down", amount: 2 })], |
| 151 | ["key", () => backend.key({ text: "ctrl+a" })], |
| 152 | ["hold_key", () => backend.hold_key({ text: "a", duration: 0.05 })], |
| 153 | ]; |
| 154 | for (const [, fn] of actions) await fn(); |
| 155 | const calls = fake.calls(); |
| 156 | assert.equal(calls.length, actions.length, "exactly one self-contained PowerShell process per action"); |
| 157 | for (const [i, [name]] of actions.entries()) { |
| 158 | assert.match(calls[i].script, /Add-Type -TypeDefinition/, `${name} is self-contained`); |
| 159 | assert.match(calls[i].script, /public static class User32/, `${name} defines User32 inline`); |
| 160 | } |
| 161 | }); |
| 162 | |
| 163 | test("win32: cursor_position is self-contained and parses JSON output", async (t) => { |
| 164 | const fake = fakePowershell(t, { stdout: '{"x": 11, "y": 22}' }); |
| 165 | const backend = win32.create({ exec: { persistentInputOwner: true } }); |
| 166 | assert.deepEqual(await backend.cursor_position(), { x: 11, y: 22 }); |
| 167 | const script = fake.calls()[0].script; |
| 168 | assert.match(script, /Add-Type -TypeDefinition/, "cursor_position carries the type definition"); |
| 169 | assert.match(script, /GetCursorPos/); |
| 170 | }); |
| 171 |