Ctrl K
ComunidadGuías…

server/cli-lifecycle.test.ts

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

Archivo original ↗Preguntar a KiBot
import { EventEmitter } from "node:events";
import type { ChildProcess } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { hostname } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { isWorkspaceRunning, runOnboardingCommand, runServe, type CliOptions } from "./cli.ts";
import { qrToString } from "./cli-pairing.ts";

const mocks = vi.hoisted(() => ({
  // the fleet path is off in these tests: no credential in the environment
  fleetCredential: vi.fn(() => null),
  fleetAccess: vi.fn(),
  FLEET_CREDENTIAL_ENV: "OMB_INSTALLATION_CREDENTIAL",
  denyLogOpen: false,
  spawn: vi.fn(),
  tailscaleStatus: vi.fn(), tailscaleServe: vi.fn(), tailscaleStop: vi.fn(), clearLeftoverTailscaleServe: vi.fn(), tailnetListenFallback: vi.fn(),
  createTunnelAccount: vi.fn(), createTunnelOrigin: vi.fn(), cleanupTunnelOrigin: vi.fn(),
  describeTunnelAccount: vi.fn(), tunnelAccess: vi.fn(), ensureCloudflared: vi.fn(),
  guardianEntry: vi.fn(), startTunnel: vi.fn(),
}));
vi.mock("node:child_process", async (original) => ({
  ...await original<typeof import("node:child_process")>(), spawn: mocks.spawn,
}));
vi.mock("node:fs", async (original) => {
  const fs = await original<typeof import("node:fs")>();
  return {
    ...fs,
    openSync: (...args: Parameters<typeof fs.openSync>) => {
      if (mocks.denyLogOpen && String(args[0]).endsWith(".log")) {
        throw Object.assign(new Error("Fixture log permission denied"), { code: "EACCES" });
      }
      return fs.openSync(...args);
    },
  };
});
vi.mock("./tailscale.ts", () => ({
  tailscaleStatus: mocks.tailscaleStatus, tailscaleServe: mocks.tailscaleServe, tailnetListenFallback: mocks.tailnetListenFallback,
  clearLeftoverTailscaleServe: mocks.clearLeftoverTailscaleServe, explainTailscaleFailure: (failure: string) => failure,
}));
vi.mock("./tunnel.ts", () => ({ ...mocks, describeTunnelState: () => "Fixture tunnel" }));
vi.mock("./browser-engine.ts", () => ({
  browserEngineStatus: () => ({ kind: "ready" }), describeBrowserEngine: () => "Fixture browser ready",
}));
vi.mock("./cli-setup.ts", () => ({
  runSetup: () => { throw new Error("An existing workspace must not rerun setup"); },
}));

const workspaceId = "4e406646-1030-4613-8878-e227ab722ffc";
const childPid = process.pid + 1;
const ttyDescriptors = [process.stdin, process.stdout].map((stream) => Object.getOwnPropertyDescriptor(stream, "isTTY"));
let dataDir: string;
let options: CliOptions;
let signalListeners: Map<NodeJS.Signals, Set<unknown>>;
const children: ChildProcess[] = [];

function childProcess(): ChildProcess {
  const child = Object.assign(new EventEmitter(), {
    pid: childPid,
    kill: vi.fn((signal: NodeJS.Signals) => {
      queueMicrotask(() => child.emit("exit", null, signal));
      return true;
    }),
  }) as unknown as ChildProcess;
  children.push(child);
  return child;
}

// Invoke only the handler installed by runServe, never signal the test runner
// or another process. The child is an EventEmitter, not a real subprocess.
function interrupt(): void {
  const handlers = process.listeners("SIGINT").filter((handler) => !signalListeners.get("SIGINT")?.has(handler));
  expect(handlers).toHaveLength(1);
  handlers[0]!("SIGINT");
}

beforeEach(() => {
  vi.resetAllMocks();
  mocks.denyLogOpen = false;
  dataDir = mkdtempSync(join(process.env.HOME!, "cli-lifecycle-"));
  options = { command: "serve", port: 18451, dataDir, tailscale: false, tunnel: false, client: false, pair: false, json: false, guided: true, open: false };
  signalListeners = new Map(["SIGINT", "SIGTERM"].map((signal) => [signal as NodeJS.Signals, new Set(process.listeners(signal))]));
  mocks.spawn.mockImplementation(() => childProcess());
  mocks.tailscaleStatus.mockResolvedValue({ status: { cli: "/fixture/tailscale", dnsName: "fixture.tail.test", addresses: [], backendState: "Running" } });
  mocks.tailscaleServe.mockResolvedValue({ origin: "https://fixture.tail.test", stop: mocks.tailscaleStop });
  mocks.tailscaleStop.mockResolvedValue(undefined);
  mocks.tailnetListenFallback.mockReturnValue(null);
  mocks.clearLeftoverTailscaleServe.mockResolvedValue(false);
  mocks.createTunnelAccount.mockReturnValue({ credentials: { status: "available", read: () => ({}) }, service: { retry: async () => ({}), restore: async () => ({}), dispose: vi.fn() } });
  mocks.describeTunnelAccount.mockReturnValue({ email: "fixture@example.test" });
  mocks.tunnelAccess.mockReturnValue({ endpoint: "https://fixture.openmausbot.test" });
  mocks.ensureCloudflared.mockResolvedValue("/fixture/cloudflared");
  mocks.guardianEntry.mockReturnValue("/fixture/guardian");
  mocks.createTunnelOrigin.mockReturnValue({ socketPath: join(dataDir, "fixture.sock") });
  mocks.startTunnel.mockReturnValue({ started: Promise.resolve(), stop: vi.fn().mockResolvedValue(undefined) });
  vi.stubGlobal("fetch", vi.fn(() => { throw new Error("Unexpected fixture request"); }));
});

afterEach(() => {
  for (const child of children.splice(0)) child.emit("exit", 0, null);
  for (const [signal, before] of signalListeners) {
    for (const listener of process.listeners(signal)) if (!before.has(listener)) process.removeListener(signal, listener);
  }
  vi.unstubAllGlobals();
  vi.unstubAllEnvs();
  vi.restoreAllMocks();
  for (const [index, stream] of [process.stdin, process.stdout].entries()) {
    const descriptor = ttyDescriptors[index];
    if (descriptor) Object.defineProperty(stream, "isTTY", descriptor);
    else Reflect.deleteProperty(stream, "isTTY");
  }
  rmSync(dataDir, { recursive: true, force: true });
});

describe("CLI startup lifecycle", () => {
  it("does not enable Tailscale or spawn a server when guided logs cannot be opened", async () => {
    mocks.denyLogOpen = true;
    vi.stubGlobal("fetch", vi.fn().mockResolvedValue(Response.json({}, { status: 503 })));
    await expect(runServe({ ...options, tailscale: true }, vi.fn())).rejects.toMatchObject({ code: "EACCES" });
    expect(mocks.tailscaleServe).not.toHaveBeenCalled();
    expect(mocks.tailscaleStop).not.toHaveBeenCalled();
    expect(mocks.spawn).not.toHaveBeenCalled();
  });

  it("turns Tailscale back off if spawning the server fails after enabling serve", async () => {
    vi.stubGlobal("fetch", vi.fn().mockResolvedValue(Response.json({}, { status: 503 })));
    mocks.spawn.mockImplementation(() => { throw new Error("Fixture spawn failure"); });
    await expect(runServe({ ...options, tailscale: true }, vi.fn())).rejects.toThrow("Fixture spawn failure");
    expect(mocks.tailscaleServe).toHaveBeenCalledOnce();
    expect(mocks.tailscaleStop).toHaveBeenCalledOnce();
  });

  it("starts the server on the tailnet when tailscale serve can't front it, with nothing of Tailscale's to stop", async () => {
    mocks.tailscaleServe.mockResolvedValue({ failure: "https-not-enabled" });
    mocks.tailnetListenFallback.mockReturnValue({ origin: "http://fixture.tail.ts.net:18451", env: { OMB_TAILNET_NAME: "fixture.tail.ts.net" }, line: "tailscale: listening" });
    let started: NodeJS.ProcessEnv = {};
    mocks.spawn.mockImplementation((_command: string, _args: string[], spawned: { env: NodeJS.ProcessEnv }) => { started = spawned.env; throw new Error("Fixture spawn failure"); });
    const log = vi.fn();
    await expect(runServe({ ...options, tailscale: true }, log)).rejects.toThrow("Fixture spawn failure");
    expect(started).toMatchObject({ OMB_TAILNET_NAME: "fixture.tail.ts.net", OMB_PUBLIC_URL: "http://fixture.tail.ts.net:18451" });
    expect(log).toHaveBeenCalledWith("tailscale: listening");
    expect(mocks.tailscaleStop).not.toHaveBeenCalled();
  });

  it("stops with Tailscale's reason when listening on the tailnet can't help either", async () => {
    mocks.tailscaleServe.mockResolvedValue({ failure: "unknown" });
    await expect(runServe({ ...options, tailscale: true }, vi.fn())).rejects.toThrow("--tailscale: ");
    expect(mocks.spawn).not.toHaveBeenCalled();
  });

  it.each([
    { outcome: "success", result: { origin: "https://fixture.tail.test", stop: mocks.tailscaleStop }, stops: 1 },
    { outcome: "failure", result: { failure: "unknown" }, stops: 0 },
  ])("cleans up and never spawns when Tailscale returns $outcome after startup cancellation", async ({ result, stops }) => {
    vi.stubGlobal("fetch", vi.fn().mockResolvedValue(Response.json({}, { status: 503 })));
    mocks.tailscaleServe.mockImplementation(async () => {
      await Promise.resolve();
      interrupt();
      return result;
    });
    expect(await runServe({ ...options, tailscale: true }, vi.fn())).toBe(130);
    expect(mocks.tailscaleServe).toHaveBeenCalledOnce();
    expect(mocks.tailscaleStop).toHaveBeenCalledTimes(stops);
    expect(mocks.spawn).not.toHaveBeenCalled();
    for (const [signal, before] of signalListeners) {
      expect(process.listeners(signal).filter((handler) => !before.has(handler))).toEqual([]);
    }
  });

  it("does not start a tunnel after SIGINT during the final readiness response", async () => {
    let healthRequests = 0;
    vi.stubGlobal("fetch", vi.fn(async (url: string | URL | Request) => {
      expect(String(url)).toBe(`http://127.0.0.1:${options.port}/api/health`);
      healthRequests++;
      if (healthRequests === 1) return Response.json({}, { status: 503 });
      if (healthRequests === 3) interrupt();
      return Response.json({ app: "openmausbot", pid: childPid });
    }));
    expect(await runServe({ ...options, tunnel: true }, vi.fn())).toBe(0);
    expect(healthRequests).toBe(3);
    expect(mocks.startTunnel).not.toHaveBeenCalled();
    expect(children[0]!.kill).toHaveBeenCalledWith("SIGTERM");
    expect(mocks.cleanupTunnelOrigin).toHaveBeenCalledOnce();
  });

  it("hands the running tunnel to background endpoint recovery and takes it back on stop", async () => {
    let healthRequests = 0;
    // Nothing answers before serve starts its server; then it is up.
    vi.stubGlobal("fetch", vi.fn(async () => (++healthRequests === 1
      ? Response.json({}, { status: 503 })
      : Response.json({ app: "openmausbot", pid: childPid }))));
    const service = { retry: vi.fn(async () => ({})), restore: vi.fn(), dispose: vi.fn() };
    mocks.createTunnelAccount.mockReturnValue({ credentials: { status: "available", read: () => ({}) }, service });
    const running = { started: Promise.resolve(), stop: vi.fn().mockResolvedValue(undefined) };
    mocks.startTunnel.mockImplementation(() => {
      setTimeout(interrupt, 10);
      return running;
    });
    let servingAtRestore: unknown = "not called";
    service.restore.mockImplementation(async () => {
      servingAtRestore = recovery().running();
      return {};
    });
    const recovery = () => mocks.createTunnelAccount.mock.calls[0]![0].recovery as { running(): unknown };

    expect(await runServe({ ...options, tunnel: true }, vi.fn())).toBe(0);
    expect(mocks.startTunnel).toHaveBeenCalledOnce();
    expect(service.restore).toHaveBeenCalledOnce();
    expect(servingAtRestore).toBe(running);
    expect(recovery().running()).toBeNull();
    expect(service.dispose).toHaveBeenCalled();
    expect(running.stop).toHaveBeenCalledOnce();
  });

  it("reopens a matching desktop workspace even when its server PID differs from the lease owner", async () => {
    writeFileSync(join(dataDir, "environment-id"), workspaceId);
    writeFileSync(join(dataDir, "openmausbot-server.lease"), JSON.stringify({
      version: 1, pid: process.pid, host: hostname(), token: workspaceId, createdAt: Date.now(),
    }));
    vi.stubEnv("OMB_DATA_DIR", process.env.OMB_DATA_DIR);
    for (const stream of [process.stdin, process.stdout]) Object.defineProperty(stream, "isTTY", { value: true, configurable: true });
    vi.stubGlobal("fetch", vi.fn(async (url: string | URL | Request) => String(url).endsWith("/api/health")
      ? Response.json({ app: "openmausbot", pid: childPid })
      : Response.json({ environmentId: workspaceId })));
    const start = vi.fn();
    const open = vi.fn().mockResolvedValue(true);
    const io = { log: vi.fn(), error: vi.fn(), ask: vi.fn() };
    expect(await runOnboardingCommand({ ...options, command: "start", open: true }, io, start, { open })).toBe(0);
    expect(open).toHaveBeenCalledWith(options.port);
    expect(start).not.toHaveBeenCalled();
    expect(mocks.spawn).not.toHaveBeenCalled();
  });

  it("does not mistake another workspace on the same port for this one", async () => {
    writeFileSync(join(dataDir, "environment-id"), workspaceId);
    vi.stubGlobal("fetch", vi.fn().mockResolvedValueOnce(Response.json({ app: "openmausbot", pid: childPid }))
      .mockResolvedValueOnce(Response.json({ environmentId: "different-workspace" })));
    expect(await isWorkspaceRunning(options)).toBe(false);
  });

  it.each([true, false])("with no way in: the guided start signs a browser here in; serve lists the ways in and mints nothing (guided: %s)", async (guided) => {
    let healthRequests = 0;
    const code = "ABCD-EFGH-JKLM";
    const pairings: unknown[] = [];
    vi.stubGlobal("fetch", vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
      const address = String(url);
      if (address.endsWith("/api/health")) {
        return ++healthRequests === 1 ? Response.json({}, { status: 503 }) : Response.json({ app: "openmausbot", pid: childPid });
      }
      if (address.endsWith("/.well-known/openmausbot/environment")) return Response.json({ environmentId: workspaceId, label: "fixture box" });
      expect(address).toBe(`http://127.0.0.1:${options.port}/api/auth/pairing`);
      expect(init?.method).toBe("POST");
      pairings.push(JSON.parse(String(init?.body)));
      return Response.json({ code, url: null, expiresAt: Date.now() + 120_000 });
    }));
    const log = vi.fn((line: string) => { if (line.includes(guided ? "Keep this terminal open" : "Sign in from a browser on this machine")) interrupt(); });
    expect(await runServe({ ...options, guided, pair: true }, log)).toBe(0);
    const output = log.mock.calls.map(([line]) => line).join("\n");
    expect(output).toContain('server "fixture box"');
    expect(pairings).toEqual(guided ? [{}] : []);
    expect(output.includes(`http://127.0.0.1:${options.port}/pair#code=${code}`)).toBe(guided);
    expect(output.includes("Sign in from a browser on this computer")).toBe(guided);
    expect(output.includes("serve --lan                      this network only, not encrypted")).toBe(!guided);
  });

  it.each(["mismatched", "unreachable"])("never mints a phone code for a %s public endpoint", async (failure) => {
    let healthRequests = 0;
    const requests: string[] = [];
    vi.stubGlobal("fetch", vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
      const address = String(url);
      requests.push(address);
      expect(init?.method).not.toBe("POST");
      if (address.endsWith("/api/health")) {
        return ++healthRequests === 1 ? Response.json({}, { status: 503 }) : Response.json({ app: "openmausbot", pid: childPid });
      }
      if (address.startsWith("https://")) {
        if (failure === "unreachable") throw new Error("Fixture endpoint offline");
        return Response.json({ environmentId: "another-workspace" });
      }
      return Response.json({ environmentId: workspaceId });
    }));
    const log = vi.fn((line: string) => { if (line.includes("Keep this terminal open")) interrupt(); });
    expect(await runServe({ ...options, pair: true, phone: "android", publicUrl: "https://fixture.example.test" }, log)).toBe(0);
    expect(log).toHaveBeenCalledWith(expect.stringContaining("no phone pairing code was created"));
    expect(requests.some((url) => url.includes("/api/auth/pairing"))).toBe(false);
    expect(requests).toContain("https://fixture.example.test/.well-known/openmausbot/environment");
  });

  it("offers Android client pairing only after verifying the same workspace, without claiming it is connected", async () => {
    const origin = "https://fixture.example.test";
    const code = "ABCD-EFGH-JKLM";
    const expiresAt = Date.now() + 300_000;
    const pairingUrl = `${origin}/pair#code=${code}`;
    // A real server mints both encodings of one window; Android can only scan the openmausbot:// one.
    const credential = `omb_pair_${"a".repeat(43)}`;
    const inviteUrl = `openmausbot://pair?address=${encodeURIComponent(origin)}&token=${credential}&name=fixture`;
    let healthRequests = 0;
    const fetcher = vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
      const address = String(url);
      if (address.endsWith("/api/health")) {
        return ++healthRequests === 1 ? Response.json({}, { status: 503 }) : Response.json({ app: "openmausbot", pid: childPid });
      }
      if (address === `http://127.0.0.1:${options.port}/api/auth/pairing`) {
        expect(init?.method).toBe("POST");
        expect(JSON.parse(String(init?.body))).toEqual({ label: "Android", scopes: ["client"] });
        // A server started without OMB_PUBLIC_URL: it mints the credential but
        // cannot name itself, so it returns no links at all. The CLI was told
        // the public address with --public-url and must build both from that.
        return Response.json({ code, url: null, expiresAt, credential, serverName: "fixture", hint: "set OMB_PUBLIC_URL" });
      }
      expect(address).toMatch(/\/\.well-known\/openmausbot\/environment$/);
      expect(init?.method).not.toBe("POST");
      expect(init?.body).toBeUndefined();
      return Response.json({ environmentId: workspaceId });
    });
    vi.stubGlobal("fetch", fetcher);
    const log = vi.fn((line: string) => { if (line.includes("Keep this terminal open")) interrupt(); });
    expect(await runServe({ ...options, pair: true, phone: "android", publicUrl: origin }, log)).toBe(0);
    const requests = fetcher.mock.calls.map(([url]) => String(url));
    const pairingRequest = `http://127.0.0.1:${options.port}/api/auth/pairing`;
    expect(requests.filter((url) => url === pairingRequest)).toHaveLength(1);
    expect(requests).toContain(`http://127.0.0.1:${options.port}/.well-known/openmausbot/environment`);
    expect(requests).toContain(`${origin}/.well-known/openmausbot/environment`);
    expect(requests.indexOf(`${origin}/.well-known/openmausbot/environment`)).toBeLessThan(requests.indexOf(pairingRequest));
    const output = log.mock.calls.map(([line]) => line).join("\n");
    expect(output).toContain(`Pairing code   ${code}   works once, until`);
    expect(output).toContain("· chat and approvals");
    expect(output).toContain(`Link           ${pairingUrl}`);
    expect(output).toMatch(/[▀▄█]/);
    expect(output).toContain(`Or open ${origin}/pair on your phone and enter the code.`);
    expect(output).toContain("On Android, open the OpenMausBot app and scan the QR with its pairing scanner.");
    expect(output).toContain(qrToString(inviteUrl));
    expect(output).not.toContain(qrToString(pairingUrl));
    expect(output).toContain("client access: chat and approvals, not settings or pairing administration");
    expect(output).toContain("Scanning a QR does not mean the phone is paired.");
    expect(output).toContain("Waiting for you to connect on the phone.");
    expect(output).not.toMatch(/pairing complete|phone (?:is |successfully )?connected|paired successfully/i);
  });
});