Ctrl K
ComunidadGuías…

electron/cua-launch.test.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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);
  }
});