Files

225 lines
9.8 KiB
JavaScript

// Computer-use e2e quality benchmark.
//
// Measures snapshot/click/type accuracy and latency against a deterministic
// local HTML page in Chrome, with ground truth verified independently via the
// Chrome DevTools Protocol (so accuracy is not measured by the system under
// test).
//
// Requirements (macOS only):
// - The signed helper app with Accessibility + Screen Recording granted:
// run `node apps/desktop/scripts/prepare-computer-use-helper.mjs` and grant
// permissions once via the helper's setup GUI.
// - Google Chrome installed. The test launches a disposable instance with a
// temp profile and closes it afterwards. No accessibility flags are passed;
// the runtime is expected to enable renderer AX itself.
//
// Usage: node packages/handsfree/test/e2e/run.mjs
import { spawn, execSync } from "node:child_process";
import { createReadStream, createWriteStream, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(__dirname, "../../../..");
const HELPER_APP = process.env.OPENWORK_COMPUTER_USE_HELPER
?? join(repoRoot, "apps/desktop/resources/helpers/OpenWork Computer Use.app");
const PAGE = `file://${join(__dirname, "bench.html")}`;
const CDP_PORT = Number(process.env.CU_BENCH_CDP_PORT ?? 9224);
const CHROME_APP = "Google Chrome";
if (process.platform !== "darwin") {
console.log("computer-use e2e benchmark is macOS-only; skipping");
process.exit(0);
}
// --- MCP bridge: launch the helper via Launch Services with FIFO stdio so
// --- TCC permissions attribute to the helper bundle, not this shell.
const fifoDir = mkdtempSync(join(tmpdir(), "cu-e2e-"));
const IN = join(fifoDir, "in.fifo");
const OUT = join(fifoDir, "out.fifo");
execSync(`mkfifo ${IN} ${OUT}`);
try { execSync(`pkill -f 'OpenWork Computer Use.app'`); } catch {}
spawn("open", ["--stdin", IN, "--stdout", OUT, HELPER_APP, "--args", "mcp"], { stdio: "ignore" });
const mcpOut = createReadStream(OUT, { encoding: "utf8" });
const mcpIn = createWriteStream(IN);
let mcpBuf = "";
const mcpPending = new Map();
mcpOut.on("data", (chunk) => {
mcpBuf += chunk;
let idx;
while ((idx = mcpBuf.indexOf("\n")) >= 0) {
const line = mcpBuf.slice(0, idx).trim();
mcpBuf = mcpBuf.slice(idx + 1);
if (!line) continue;
let msg;
try { msg = JSON.parse(line); } catch { continue; }
if (msg.id != null && mcpPending.has(msg.id)) {
mcpPending.get(msg.id)(msg);
mcpPending.delete(msg.id);
}
}
});
let rpcId = 0;
function rpc(method, params, timeoutMs = 30000) {
const id = ++rpcId;
return new Promise((res, rej) => {
const t = setTimeout(() => rej(new Error(`mcp timeout: ${method}`)), timeoutMs);
mcpPending.set(id, (msg) => { clearTimeout(t); res(msg); });
mcpIn.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
});
}
const results = { latency: {}, accuracy: {}, paths: {} };
async function cu(name, args = {}) {
const t0 = performance.now();
const msg = await rpc("tools/call", { name, arguments: args });
(results.latency[name] ??= []).push(performance.now() - t0);
const text = msg.result?.content?.find((c) => c.type === "text")?.text;
const json = JSON.parse(text ?? "{}");
if (json.path) (results.paths[json.path] ??= []).push(name);
return json;
}
// --- disposable Chrome with CDP ground truth ---
const profileDir = mkdtempSync(join(tmpdir(), "cu-e2e-chrome-"));
spawn("open", ["-na", CHROME_APP, "--args",
`--remote-debugging-port=${CDP_PORT}`, `--user-data-dir=${profileDir}`,
"--no-first-run", "--no-default-browser-check", "--window-size=1200,900", PAGE,
], { stdio: "ignore" });
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function cdpTargets() {
for (let i = 0; i < 30; i++) {
try {
const r = await fetch(`http://127.0.0.1:${CDP_PORT}/json`);
return await r.json();
} catch { await sleep(500); }
}
throw new Error(`Chrome CDP endpoint did not come up on :${CDP_PORT}`);
}
await rpc("initialize", { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "cu-e2e", version: "0" } });
mcpIn.write(JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }) + "\n");
const targets = await cdpTargets();
const tab = targets.find((t) => t.type === "page" && t.url.includes("bench.html"));
if (!tab) throw new Error("bench.html tab not found in disposable Chrome");
const ws = new WebSocket(tab.webSocketDebuggerUrl);
await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; });
let wsId = 0;
const wsPending = new Map();
ws.onmessage = (ev) => {
const m = JSON.parse(ev.data);
if (m.id && wsPending.has(m.id)) { wsPending.get(m.id)(m); wsPending.delete(m.id); }
};
const evaljs = (expression) => new Promise((res, rej) => {
const i = ++wsId;
const t = setTimeout(() => rej(new Error("cdp timeout")), 15000);
wsPending.set(i, (m) => { clearTimeout(t); res(m.result?.result?.value); });
ws.send(JSON.stringify({ id: i, method: "Runtime.evaluate", params: { expression, returnByValue: true } }));
});
await sleep(2000);
const findEl = (snap, pred) => snap.elements?.find(pred);
let failures = 0;
const record = (key, ok, total, note = "") => {
results.accuracy[key] = `${ok}/${total}`;
if (ok !== total) { failures++; if (note) console.log(note); }
};
// 1. element recall
let snap = await cu("snapshot", { app: CHROME_APP });
const buttons = ["btn-alpha","btn-bravo","btn-charlie","btn-delta","btn-echo","btn-foxtrot","btn-golf","btn-hotel","btn-india","btn-juliett","btn-kilo","btn-lima"];
const expected = [...buttons, "bench-field", "bench-area", "bench-check"];
const labels = (snap.elements ?? []).map((e) => `${e.label ?? ""} ${e.value ?? ""}`).join(" | ");
const found = expected.filter((x) => labels.includes(x));
record("element_recall", found.length, expected.length, `missing: ${expected.filter((x) => !found.includes(x)).join(", ")}`);
// 2. click accuracy across all three addressing modes, CDP-verified
async function clickRound(key, makeArgs) {
let ok = 0;
await evaljs("window.__events = []");
for (const name of buttons) {
const el = findEl(snap, (e) => e.role === "Button" && (e.label ?? "").includes(name));
if (!el) continue;
const before = (await evaljs("window.__events.length")) ?? 0;
await cu("click", { app: CHROME_APP, ...makeArgs(el) });
await sleep(150);
const last = await evaljs("window.__events[window.__events.length-1]?.id");
const after = await evaljs("window.__events.length");
if (after === before + 1 && last === name) ok++;
else console.log(`${key} MISS: ${name} -> ${last}`);
}
record(key, ok, buttons.length);
}
await clickRound("click_by_ref", (el) => ({ ref: el.ref }));
snap = await cu("snapshot", { app: CHROME_APP });
await clickRound("click_by_image_coord", (el) => ({ x: el.center.imageX, y: el.center.imageY }));
await clickRound("click_by_screen_coord", (el) => ({ screen_x: el.center.screenX, screen_y: el.center.screenY }));
// 3. pid targeting
const apps = await cu("list_apps");
const chrome = (apps.appDetails ?? []).find((a) => a.name === CHROME_APP);
const pidSnap = chrome ? await cu("snapshot", { pid: chrome.pid }) : {};
record("snapshot_by_pid", pidSnap.windowTitle?.includes("CU-BENCH") ? 1 : 0, 1);
// 4. typing accuracy (incl. symbols), CDP-verified
const strings = [
"The quick brown fox jumps over the lazy dog 0123456789",
"~!@#$%^&*()_+-=[]{}|;':\",./<>?",
"CamelCase snake_case kebab-case 42.5%",
];
let typeOk = 0;
for (const text of strings) {
await evaljs("document.getElementById('field').value=''; document.getElementById('field').focus()");
await sleep(100);
await cu("type_text", { app: CHROME_APP, text });
await sleep(300);
if ((await evaljs("document.getElementById('field').value")) === text) typeOk++;
}
record("type_text", typeOk, strings.length);
// 5. set_value with verify + retype fallback
snap = await cu("snapshot", { app: CHROME_APP });
const field = findEl(snap, (e) => (e.label ?? "").includes("bench-field"));
await cu("set_value", { app: CHROME_APP, ref: field.ref, value: "set-value-test-42" });
await sleep(300);
record("set_value", (await evaljs("document.getElementById('field').value")) === "set-value-test-42" ? 1 : 0, 1);
// 6. background modifier combo: cmd+a + retype
await cu("snapshot", { app: CHROME_APP });
await evaljs("document.getElementById('field').focus()");
await cu("press_key", { app: CHROME_APP, combo: "command+a" });
await sleep(100);
await cu("type_text", { app: CHROME_APP, text: "replaced" });
await sleep(300);
record("select_all_replace", (await evaljs("document.getElementById('field').value")) === "replaced" ? 1 : 0, 1);
// --- report ---
console.log("\n========== RESULTS ==========");
console.log("\n-- Accuracy --");
for (const [k, v] of Object.entries(results.accuracy)) console.log(`${k.padEnd(24)} ${v}`);
console.log("\n-- Latency (ms) --");
for (const [k, v] of Object.entries(results.latency)) {
const s = [...v].sort((a, b) => a - b);
const avg = Math.round(s.reduce((a, b) => a + b, 0) / s.length);
console.log(`${k.padEnd(24)} n=${String(s.length).padEnd(4)} avg=${String(avg).padEnd(6)} min=${Math.round(s[0]).toString().padEnd(6)} max=${Math.round(s[s.length - 1])}`);
}
console.log("\n-- Execution paths --");
for (const [k, v] of Object.entries(results.paths)) console.log(`${k.padEnd(24)} ${v.length}x`);
// --- cleanup ---
try { execSync(`pkill -f "user-data-dir=${profileDir}"`); } catch {}
try { execSync(`pkill -f 'OpenWork Computer Use.app'`); } catch {}
await sleep(1000);
try { rmSync(fifoDir, { recursive: true, force: true }); } catch {}
try { rmSync(profileDir, { recursive: true, force: true }); } catch {}
console.log(failures === 0 ? "\nRESULT: PASS" : `\nRESULT: FAIL (${failures} failing checks)`);
process.exit(failures === 0 ? 0 : 1);