electron/cua-launch.test.mjs
Rama main · réplica de lectura del repositorio original.
import { once } from "node:events";
import { mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const fixture = vi.hoisted(() => ({ home: "", script: "", socketReady: false, children: [], calls: [], embeddedDelays: [], hosts: [], hostOptions: [], handlers: new Map() }));
vi.mock("electron", () => ({ app: { isPackaged: false, getPath: () => fixture.home }, ipcMain: { handle: (name, handler) => fixture.handlers.set(name, handler) } }));
vi.mock("@trycua/cua-driver/embedded", () => ({
EmbeddedCuaDriverHost: class {
constructor() { this.delay = fixture.embeddedDelays.shift(); fixture.hosts.push(this); }
static withOptions(options) { fixture.hostOptions.push(options); return new this(); }
waitForExit() { return new Promise((resolve) => { this.exit = resolve; }); }
async start({ signal }) {
if (this.delay === "until-abort") {
await new Promise((_, reject) => signal.addEventListener("abort", () => reject(signal.reason), { once: true }));
} else {
await new Promise((resolve) => setTimeout(resolve, this.delay));
}
return { socketPath: `/fixture/${this.delay}.sock` };
}
stop = vi.fn(async () => {});
uniffiDestroy = vi.fn();
},
}));
vi.mock("@trycua/cua-driver/electron", () => ({
requestMacOSPermissions: () => ({ accessibility: false, screenRecording: false }),
hasRequiredMacOSPermissions: () => fixture.embeddedDelays.length > 0,
}));
vi.mock("node:fs", async (original) => {
const fs = await original();
return { ...fs, default: { ...fs.default, existsSync: (file) => {
if (file === "/Applications/CuaDriver.app/Contents/MacOS/cua-driver") return true;
if (String(file).endsWith("cua-driver.sock")) return fixture.socketReady;
return fs.existsSync(file);
} } };
});
vi.mock("node:net", async (original) => {
const net = await original();
const { EventEmitter } = await import("node:events");
return { ...net, default: { ...net.default, createConnection: () => {
const socket = Object.assign(new EventEmitter(), { destroy: vi.fn() });
queueMicrotask(() => socket.emit("connect"));
return socket;
} } };
});
vi.mock("node:child_process", async (original) => {
const process = await original();
const { promisify } = await import("node:util");
const execFile = (command, args, options, callback) => {
fixture.calls.push({ command, args, options });
// Always run this inert Node child, never open the operator's native app.
const child = process.execFile(globalThis.process.execPath, ["-e", fixture.script], options, (error, stdout, stderr) => {
if (!error && command === "/usr/bin/open") fixture.socketReady = true;
callback(error, stdout, stderr);
});
fixture.children.push(child);
return child;
};
execFile[promisify.custom] = (...args) => {
let child;
const promise = new Promise((resolve, reject) => {
child = execFile(...args, (error, stdout, stderr) => error
? reject(Object.assign(error, { stdout, stderr })) : resolve({ stdout, stderr }));
});
promise.child = child;
return promise;
};
return { ...process, execFile };
});
let cua;
let localOriginModule;
beforeEach(async () => {
fixture.home = mkdtempSync(join(tmpdir(), "omb-cua-async-"));
fixture.script = "setTimeout(() => process.exit(0), 120)";
fixture.socketReady = false;
fixture.children = [];
fixture.calls = [];
fixture.embeddedDelays = [];
fixture.hosts = [];
fixture.hostOptions = [];
fixture.handlers.clear();
vi.stubEnv("OPENMAUSBOT_CUA_EMBEDDED", "1");
vi.stubEnv("CUA_DRIVER_PATH", "/fixture/cua-driver");
vi.resetModules();
cua = await import("./cua.mjs");
localOriginModule = (await import("./local-origin.cjs")).default;
localOriginModule.setLocalOrigin("http://127.0.0.1:19777");
});
afterEach(async () => {
await cua?.stopCua();
for (const child of fixture.children) {
if (child.exitCode !== null || child.signalCode !== null) continue;
const closed = once(child, "close");
child.kill("SIGKILL");
await closed;
}
vi.unstubAllEnvs();
localOriginModule.setLocalOrigin(null);
rmSync(fixture.home, { recursive: true, force: true });
});
describe.skipIf(process.platform !== "darwin")("async standalone CUA launch (isolated commands)", () => {
it("keeps the event loop responsive and publishes the existing descriptor shape", async () => {
let ticks = 0;
const timer = setInterval(() => ticks++, 10);
try {
await expect(cua.startCua()).resolves.toMatchObject({ mode: "standalone", mcpArgs: ["mcp"] });
expect(ticks).toBeGreaterThan(2);
expect(fixture.calls[0]).toMatchObject({ command: "/usr/bin/open", args: ["-a", "CuaDriver"], options: { timeout: 8_000, killSignal: "SIGKILL" } });
} finally { clearInterval(timer); }
});
it("keeps the embedded and fallback launch failures visible", async () => {
fixture.script = "process.stderr.write('fixture launch failure'); process.exit(4)";
const result = await cua.startCua();
expect(result.mode).toBe("unavailable");
expect(result.reason).toContain("embedded host failed");
expect(result.reason).toContain("standalone launch failed");
expect(result.reason).toContain("fixture launch failure");
});
it("cancels a pending launch without publishing a stale ready connection", async () => {
fixture.script = "process.on('SIGTERM', () => {}); setInterval(() => {}, 1000)";
const started = cua.startCua();
const aborted = expect(started).rejects.toMatchObject({ name: "AbortError" });
await expect.poll(() => fixture.children.length).toBe(1);
await cua.stopCua();
await aborted;
await expect.poll(() => fixture.children[0].signalCode).toBe("SIGKILL");
expect(fixture.socketReady).toBe(false);
});
it("bounds a TERM-ignoring launch with forceful timeout", async () => {
fixture.script = "process.on('SIGTERM', () => {}); setInterval(() => {}, 1000)";
await expect(cua.startCua()).resolves.toMatchObject({ mode: "unavailable", reason: expect.stringContaining("standalone launch failed") });
expect(fixture.children[0].signalCode).toBe("SIGKILL");
}, 12_000);
it("does not let an aborted embedded start publish over or stop its replacement", async () => {
fixture.embeddedDelays = [150, 10];
const first = cua.startCua();
const aborted = expect(first).rejects.toMatchObject({ name: "AbortError" });
await expect.poll(() => fixture.hosts.length).toBe(1);
await cua.stopCua();
await expect(cua.startCua()).resolves.toMatchObject({ mode: "embedded", socketPath: "/fixture/10.sock" });
await aborted;
expect(fixture.hosts[0].stop).toHaveBeenCalledOnce();
expect(fixture.hosts[1].stop).not.toHaveBeenCalled();
expect(JSON.parse(readFileSync(join(fixture.home, "cua-connection.json"), "utf8")).socketPath).toBe("/fixture/10.sock");
});
it("passes cancellation to an embedded startup stalled until abort", async () => {
fixture.embeddedDelays = ["until-abort"];
const started = cua.startCua();
const aborted = expect(started).rejects.toMatchObject({ name: "AbortError" });
await expect.poll(() => fixture.hosts.length).toBe(1);
await cua.stopCua();
await aborted;
expect(fixture.hosts[0].stop).toHaveBeenCalledOnce();
expect(fixture.hosts[0].uniffiDestroy).toHaveBeenCalledOnce();
expect(fixture.calls).toHaveLength(0);
});
it("keeps a replacement host when the previous host's Stop finishes late", async () => {
fixture.embeddedDelays = [1, 10];
await cua.startCua();
fixture.hosts[0].stop.mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 150)));
const stopping = cua.stopCua();
await expect(cua.startCua()).resolves.toMatchObject({ socketPath: "/fixture/10.sock" });
await stopping;
expect(fixture.hosts[0].uniffiDestroy).toHaveBeenCalledOnce();
expect(fixture.hosts[1].stop).not.toHaveBeenCalled();
expect(fixture.hosts[1].uniffiDestroy).not.toHaveBeenCalled();
expect(JSON.parse(readFileSync(join(fixture.home, "cua-connection.json"), "utf8")).socketPath).toBe("/fixture/10.sock");
await cua.stopCua();
expect(fixture.hosts[1].stop).toHaveBeenCalledOnce();
});
it.each([false, true])("coalesces concurrent IPC retries and respects explicit Stop during cleanup: %s", async (cancel) => {
fixture.embeddedDelays = [1, 10, 20];
await cua.startCua();
let releaseStop;
fixture.hosts[0].stop.mockImplementation(() => new Promise((resolve) => { releaseStop = resolve; }));
cua.registerCuaIpc();
const retry = fixture.handlers.get("cua:linux-retry");
const event = { senderFrame: { url: "http://127.0.0.1:19777/" } };
let attempts;
try {
attempts = [retry(event), retry(event)];
await new Promise((resolve) => setTimeout(resolve, 30));
expect(fixture.hosts).toHaveLength(1);
expect(fixture.hosts[0].stop).toHaveBeenCalledOnce();
if (cancel) await cua.stopCua(); // the same boundary quit calls
} finally {
releaseStop?.();
fixture.hosts[0].stop.mockResolvedValue();
}
const results = await Promise.all(attempts);
expect(results[0]).toEqual(results[1]);
expect(results[0]).toMatchObject(cancel
? { enabled: false, status: "error", message: "Computer use restart cancelled" }
: { enabled: true, status: "ready" });
expect(fixture.hosts).toHaveLength(cancel ? 1 : 2);
await cua.stopCua();
for (const host of fixture.hosts) {
expect(host.stop).toHaveBeenCalledOnce();
expect(host.uniffiDestroy).toHaveBeenCalledOnce();
}
expect(JSON.parse(readFileSync(join(fixture.home, "cua-connection.json"), "utf8")).mode).toBe("unavailable");
});
it("restarts a helper that died by itself and says so while it is down", async () => {
fixture.embeddedDelays = [1, 1, 1];
const listener = vi.fn();
cua.setCuaStateListener(listener);
await expect(cua.startCua()).resolves.toMatchObject({ mode: "embedded" });
await expect.poll(() => typeof fixture.hosts[0].exit).toBe("function");
fixture.hosts[0].exit({ generation: "a", code: 1, success: false });
await expect.poll(() => JSON.parse(readFileSync(join(fixture.home, "cua-connection.json"), "utf8")).mode).toBe("unavailable");
expect(listener.mock.lastCall[0]).toMatchObject({ mode: "unavailable", reason: expect.stringContaining("restarting") });
await expect.poll(() => fixture.hosts.length, { timeout: 5_000 }).toBe(2);
await expect.poll(() => JSON.parse(readFileSync(join(fixture.home, "cua-connection.json"), "utf8")).mode, { timeout: 5_000 }).toBe("embedded");
expect(fixture.hostOptions[1].socketPath).toBe(fixture.hostOptions[0].socketPath);
expect(fixture.hostOptions[0].socketPath).toContain("cua-omb-");
});
it("does not restart a helper the app stopped", async () => {
fixture.embeddedDelays = [1, 1];
await cua.startCua();
await expect.poll(() => typeof fixture.hosts[0].exit).toBe("function");
await cua.stopCua();
fixture.hosts[0].exit({ generation: "a", code: 0, success: true });
await new Promise((resolve) => setTimeout(resolve, 1_200));
expect(fixture.hosts).toHaveLength(1);
});
it("cancels concurrent IPC retries during replacement startup", async () => {
fixture.embeddedDelays = [1, "until-abort"];
await cua.startCua();
cua.registerCuaIpc();
const retry = fixture.handlers.get("cua:linux-retry");
const event = { senderFrame: { url: "http://127.0.0.1:19777/" } };
const attempts = [retry(event), retry(event)];
await expect.poll(() => fixture.hosts.length).toBe(2);
await cua.stopCua();
const results = await Promise.all(attempts);
expect(results[0]).toEqual(results[1]);
expect(results[0]).toMatchObject({ enabled: false, status: "error" });
for (const host of fixture.hosts) {
expect(host.stop).toHaveBeenCalledOnce();
expect(host.uniffiDestroy).toHaveBeenCalledOnce();
}
expect(JSON.parse(readFileSync(join(fixture.home, "cua-connection.json"), "utf8")).mode).toBe("unavailable");
});
it("reads permission status asynchronously with bounded output", async () => {
fixture.script = "setTimeout(() => console.log(JSON.stringify({ accessibility: true })), 120)";
await expect(cua.cuaPermissionsStatus()).resolves.toEqual({ available: true, accessibility: true });
expect(fixture.calls[0]).toMatchObject({ args: ["permissions", "status", "--json"], options: { timeout: 5_000, maxBuffer: 65_536, killSignal: "SIGKILL" } });
});
});
it("does not add blocking subprocess calls to Electron runtime modules", () => {
const root = dirname(fileURLToPath(import.meta.url));
// These synchronous ownership checks need separate lifecycle work: the boot ID read, the Windows creation-time query, Linux group validation.
const allowed = { "data-dir-lease.mjs": 3, "cua-linux.cjs": 2 };
for (const entry of readdirSync(root, { recursive: true })) {
const name = String(entry).replaceAll("\\", "/");
if (!/\.(?:mjs|cjs)$/.test(name) || /(?:\.test\.|\.node-test\.|^vendor\/|^build-)/.test(name)) continue;
const matches = readFileSync(join(root, name), "utf8").match(/\b(?:spawnSync|execFileSync|execSync)\b/g) ?? [];
expect(matches.length, name).toBeLessThanOrEqual(allowed[name] ?? 0);
}
});