diff --git a/crates/tui/plugins/computer-use/src/backends/win32.mjs b/crates/tui/plugins/computer-use/src/backends/win32.mjs index d73b8bb8e8..18a2057adc 100644 --- a/crates/tui/plugins/computer-use/src/backends/win32.mjs +++ b/crates/tui/plugins/computer-use/src/backends/win32.mjs @@ -10,30 +10,6 @@ import crypto from "node:crypto"; import { spawn } from "node:child_process"; import { run, runOk, ExecError, tryJson } from "../exec.mjs"; -function ps(script, opts = {}) { - const encoded = Buffer.from(script, "utf16le").toString("base64"); - return run("powershell.exe", ["-NoProfile", "-NonInteractive", "-EncodedCommand", encoded], { - timeoutMs: opts.timeoutMs ?? 25_000, - maxBuffer: 32 * 1024 * 1024, - }); -} - -/** ps() but truthful: timeout, nonzero exit, and spawn failure all throw. */ -async function psOk(script, opts = {}) { - const r = await ps(script, opts); - if (r.timedOut) throw new ExecError(`powershell timed out after ${opts.timeoutMs ?? 25_000}ms`, r); - if (r.code !== 0) throw new ExecError(`powershell.exe exited ${r.code}: ${(r.stderr || r.stdout).trim().slice(0, 300)}`, r); - return r; -} - -async function psJson(script, opts = {}) { - const r = await psOk(script, opts); - const out = r.stdout.trim(); - const j = tryJson(out, null); - if (!j) throw new ExecError(`powershell did not return JSON: ${(r.stderr || out).trim().slice(0, 300)}`, r); - return j; -} - const USER32 = ` using System; using System.Runtime.InteropServices; @@ -79,7 +55,36 @@ const MODVK = { ctrl: 0x11, control: 0x11, alt: 0x12, shift: 0x10, win: 0x5b, me // (Add-Type re-definition is tolerated through -ErrorAction SilentlyContinue). const USER32_PRELUDE = `Add-Type -TypeDefinition @'\n${USER32}\n'@ -ErrorAction SilentlyContinue;`; -export function create() { +export function create(opts = {}) { + // Allow tests (and other embedders) to inject a runner so no real + // powershell.exe is spawned. Production uses the imported runner. + const injectedRun = opts.exec && typeof opts.exec.run === "function" ? opts.exec.run : null; + const runner = injectedRun ?? run; + + async function ps(script, o = {}) { + const encoded = Buffer.from(script, "utf16le").toString("base64"); + return runner("powershell.exe", ["-NoProfile", "-NonInteractive", "-EncodedCommand", encoded], { + timeoutMs: o.timeoutMs ?? 25_000, + maxBuffer: 32 * 1024 * 1024, + }); + } + + /** ps() but truthful: timeout, nonzero exit, and spawn failure all throw. */ + async function psOk(script, o = {}) { + const r = await ps(script, o); + if (r.timedOut) throw new ExecError(`powershell timed out after ${o.timeoutMs ?? 25_000}ms`, r); + if (r.code !== 0) throw new ExecError(`powershell.exe exited ${r.code}: ${(r.stderr || r.stdout).trim().slice(0, 300)}`, r); + return r; + } + + async function psJson(script, o = {}) { + const r = await psOk(script, o); + const out = r.stdout.trim(); + const j = tryJson(out, null); + if (!j) throw new ExecError(`powershell did not return JSON: ${(r.stderr || out).trim().slice(0, 300)}`, r); + return j; + } + let lastRaster = null; let recording = null; // {id, pid, file, startedAt, mode} diff --git a/crates/tui/plugins/computer-use/tests/win32.test.mjs b/crates/tui/plugins/computer-use/tests/win32.test.mjs new file mode 100644 index 0000000000..59b31a029a --- /dev/null +++ b/crates/tui/plugins/computer-use/tests/win32.test.mjs @@ -0,0 +1,65 @@ +// Win32 backend tests that run on ANY host via the injectable runner seam +// (`create({ exec })`): the fake runner captures the PowerShell scripts so the +// generated commands can be asserted directly, and no real powershell.exe is +// ever spawned. These pin the failure-truthful and self-contained-action +// behavior merged in #5903 without needing a Windows host or a +// fake-powershell.exe-on-PATH fixture. +import { test } from "node:test"; +import assert from "node:assert/strict"; + +function decodeScript(args) { + const i = args.indexOf("-EncodedCommand"); + if (i === -1) return null; + return Buffer.from(args[i + 1], "base64").toString("utf16le"); +} + +function mockExec({ fail = false } = {}) { + const calls = []; + const run = async (_cmd, args) => { + calls.push({ script: decodeScript(args) }); + if (fail) return { code: 1, stdout: "", stderr: "simulated powershell failure" }; + return { code: 0, stdout: '{"ok": true}\n', stderr: "" }; + }; + return { run, calls }; +} + +test("win32: actions run through an injected runner (no powershell needed)", async () => { + const { run, calls } = mockExec(); + const mod = await import("../src/backends/win32.mjs"); + const b = mod.create({ exec: { run } }); + const r = await b.left_click({ target: { x: 5, y: 6 } }); + assert.equal(r.action_sent, true); + assert.ok(calls.length >= 1, "the injected runner must receive the action command"); +}); + +test("win32: input actions fail truthfully on a nonzero exit", async () => { + const { run } = mockExec({ fail: true }); + const mod = await import("../src/backends/win32.mjs"); + const b = mod.create({ exec: { run } }); + await assert.rejects(() => b.left_click({ target: { x: 1, y: 2 } }), /exited 1/); + await assert.rejects(() => b.left_mouse_down({ target: { x: 1, y: 2 } }), /exited 1/); +}); + +test("win32: targeted left_mouse_down both moves and presses, self-contained", async () => { + const { run, calls } = mockExec(); + const mod = await import("../src/backends/win32.mjs"); + const b = mod.create({ exec: { run } }); + await b.left_mouse_down({ target: { x: 12, y: 34 } }); + const script = calls.at(-1).script; + // Self-contained: the User32 P/Invoke type travels with the action. + assert.ok(script.includes("public static class User32"), "action must define User32 in its own process"); + assert.ok(script.includes("SetCursorPos(12, 34)"), "must move the cursor to the target"); + assert.ok(script.includes("LEFTDOWN"), "must press the left button"); +}); + +test("win32: every User32 action carries the type prelude in-process", async () => { + const { run, calls } = mockExec(); + const mod = await import("../src/backends/win32.mjs"); + const b = mod.create({ exec: { run } }); + await b.mouse_move({ target: { x: 3, y: 4 } }); + await b.key({ text: "a" }); + assert.ok(calls.length >= 2, "two actions should have run through the injected runner"); + for (const call of calls) { + assert.ok(call.script.includes("public static class User32"), "each action must redefine User32 in-process"); + } +});