225 lines
9.8 KiB
JavaScript
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);
|