Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 30 additions & 25 deletions crates/tui/plugins/computer-use/src/backends/win32.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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}

Expand Down
65 changes: 65 additions & 0 deletions crates/tui/plugins/computer-use/tests/win32.test.mjs
Original file line number Diff line number Diff line change
@@ -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");
}
});
Loading