Ctrl K
ComunidadGuías…

server/boot-readiness.e2e.test.ts

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

Archivo original ↗Preguntar a KiBot
// Real server, fake Claude CLI with a slow `auth status` (each probe may take up to its 8 s timeout).
import { spawn, type ChildProcess } from "node:child_process";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { DatabaseSync } from "node:sqlite";
import { fileURLToPath } from "node:url";
import { afterEach, expect, it } from "vitest";
import { runControlOmb, verificationServerEnvironment } from "../scripts/control-omb.ts";
import { removeTempDir, waitForExit } from "./testing/cleanup.ts";
import { freePortBlock } from "./testing/ports.ts";

const ROOT = fileURLToPath(new URL("..", import.meta.url));
const FAKE_CLI = join(ROOT, "server/testing/fake-claude-cli.ts");

const homes: string[] = [];
const children: ChildProcess[] = [];

afterEach(async () => {
  for (const child of children.splice(0)) await waitForExit(child, { signal: "SIGTERM" });
  for (const home of homes.splice(0)) await removeTempDir(home);
});

function freshHome(more: Record<string, unknown> = {}): string {
  const home = mkdtempSync(join(tmpdir(), "omb-boot-readiness-"));
  homes.push(home);
  mkdirSync(join(home, "tmp"), { recursive: true });
  writeFileSync(join(home, "config.json"), JSON.stringify({
    instances: { claude: { driver: "claudeAgent", displayName: "Verification fixture", config: { cli: FAKE_CLI } }, ...more },
  }));
  return home;
}

async function boot(home: string, knobs: Record<string, string> = {}) {
  const port = await freePortBlock([0, 1]);
  const base = `http://127.0.0.1:${port}`;
  const child = spawn(process.execPath, ["--experimental-strip-types", join(ROOT, "server/index.ts")], {
    cwd: ROOT,
    env: verificationServerEnvironment({ ...process.env, ...knobs }, home, port),
    stdio: ["ignore", "pipe", "pipe"],
  });
  children.push(child);
  let output = "";
  child.stdout!.on("data", (chunk) => { output += chunk; });
  child.stderr!.on("data", (chunk) => { output += chunk; });
  await expect.poll(async () => {
    if (child.exitCode !== null) throw new Error(`server exited during boot:\n${output}`);
    try { return (await fetch(base + "/api/health")).ok; } catch { return false; }
  }, { timeout: 20_000, interval: 50 }).toBe(true);
  const get = async (path: string) => {
    const response = await fetch(base + path, { headers: { origin: base } });
    expect(response.status).toBe(200);
    return response.json() as Promise<any>;
  };
  const control = (...args: string[]) => runControlOmb([...args, "--url", base]) as Promise<any>;
  const stop = () => waitForExit(child, { signal: "SIGTERM" });
  return { get, control, stop };
}

it("makes a first run's starter bot on the engine a new bot gets", async () => {
  const server = await boot(freshHome());
  const { bots } = await server.get("/api/bots?messages=0");
  expect(bots).toHaveLength(1);
  expect(bots[0].modelSelection).toMatchObject({ instanceId: "claude", model: expect.stringMatching(/.+/) });
});

it("answers on a later start while an engine CLI is still being probed, and runs turns once it has answered", async () => {
  const home = freshHome();
  const first = await boot(home);
  const [starter] = (await first.get("/api/bots?messages=0")).bots;
  const member = (await first.control("new-bot", "--name", "Room member")).bot;
  const room = (await first.control("new-channel", "--name", "Start room", "--members", member.id)).channel;
  await first.stop();

  const probes = join(home, "probes.log");
  const release = join(home, "release-auth");
  const dump = join(home, "fake-claude-dump.json");
  const server = await boot(home, { FAKE_CLAUDE_PROBE_LOG: probes, FAKE_CLAUDE_HOLD_AUTH: release });
  // The probe the server is still waiting on is alive: health did not wait
  // for it (the server would have killed it at its timeout first).
  let held = 0;
  await expect.poll(() => {
    held = existsSync(probes) ? Number(/^auth (\d+)$/m.exec(readFileSync(probes, "utf8"))?.[1] ?? 0) : 0;
    return held;
  }, { timeout: 10_000 }).toBeGreaterThan(0);
  expect(() => process.kill(held, 0)).not.toThrow();

  await server.control("send", "--bot", starter.id, "--task", starter.threadId, "--text", "Right after the start");
  await server.control("send-channel", "--channel", room.id, "--text", "Right after the start, in a room");
  await new Promise((resolve) => setTimeout(resolve, 1_500));
  expect(existsSync(dump)).toBe(false);
  expect(() => process.kill(held, 0)).not.toThrow();
  writeFileSync(release, "");

  expect((await server.control("wait", "--bot", starter.id, "--task", starter.threadId, "--timeout", "30")).status).toBe("settled");
  expect((await server.control("wait", "--channel", room.id, "--timeout", "30")).status).toBe("settled");
  const seen = JSON.parse(readFileSync(dump, "utf8"));
  expect(seen.argv[seen.argv.indexOf("--autocompact") + 1]).toBe("200000");
  const { instances } = await server.get("/api/instances");
  expect(instances.find((instance: { instanceId: string }) => instance.instanceId === "claude")?.snapshot)
    .toMatchObject({ state: "available", authenticated: true });
}, 60_000);

it.skipIf(process.platform === "win32")("runs a turn on a later start once its own engine has answered, while another engine's CLI is still being probed", async () => {
  const scratch = mkdtempSync(join(tmpdir(), "omb-boot-slow-engine-"));
  homes.push(scratch);
  const slowProbes = join(scratch, "probes.log"), slowRelease = join(scratch, "release-version");
  const slowCli = join(scratch, "slow-claude");
  writeFileSync(slowCli, `#!/bin/sh\nFAKE_CLAUDE_PROBE_LOG='${slowProbes}' FAKE_CLAUDE_HOLD_VERSION='${slowRelease}' exec '${FAKE_CLI}' "$@"\n`, { mode: 0o755 });
  writeFileSync(slowRelease, "");
  const home = freshHome({ slow: { driver: "claudeAgent", displayName: "Slow fixture", config: { cli: slowCli } } });
  const first = await boot(home);
  const [starter] = (await first.get("/api/bots?messages=0")).bots;
  expect(starter.modelSelection.instanceId).toBe("claude");
  await first.stop();

  rmSync(slowRelease);
  rmSync(slowProbes, { force: true });
  const dump = join(home, "fake-claude-dump.json");
  const server = await boot(home);
  let held = 0;
  await expect.poll(() => {
    held = existsSync(slowProbes) ? Number(/^version (\d+)$/m.exec(readFileSync(slowProbes, "utf8"))?.[1] ?? 0) : 0;
    return held;
  }, { timeout: 10_000 }).toBeGreaterThan(0);

  await server.control("send", "--bot", starter.id, "--task", starter.threadId, "--text", "Right after the start");
  expect((await server.control("wait", "--bot", starter.id, "--task", starter.threadId, "--timeout", "6")).status).toBe("settled");
  expect(() => process.kill(held, 0)).not.toThrow();
  const seen = JSON.parse(readFileSync(dump, "utf8"));
  expect(seen.argv[seen.argv.indexOf("--autocompact") + 1]).toBe("200000");
  writeFileSync(slowRelease, "");
}, 60_000);

it("starts the words a later start restored before a message sent while the engines are read", async () => {
  const home = freshHome();
  const first = await boot(home);
  const [starter] = (await first.get("/api/bots?messages=0")).bots;
  await first.stop();
  // Words still queued for the conversation when the last run stopped.
  const db = new DatabaseSync(join(home, "messages.db"));
  try {
    db.prepare("INSERT INTO chat_followups(id, kind, owner_id, thread_id, send_id, status, payload) VALUES (?, 'bot', ?, ?, NULL, 'pending', ?)")
      .run("queued_before_restart", starter.id, starter.threadId, JSON.stringify({ text: "Queued before the restart" }));
  } finally { db.close(); }

  const probes = join(home, "probes.log");
  const release = join(home, "release-auth");
  const server = await boot(home, { FAKE_CLAUDE_PROBE_LOG: probes, FAKE_CLAUDE_HOLD_AUTH: release });
  await expect.poll(() => existsSync(probes) && /^auth \d+$/m.test(readFileSync(probes, "utf8")), { timeout: 10_000 }).toBe(true);
  await server.control("send", "--bot", starter.id, "--task", starter.threadId, "--text", "Sent while the engines are read");
  writeFileSync(release, "");

  const said = async () => ((await server.get(`/api/threads/${starter.threadId}/messages?limit=100`)).messages as Array<{ role: string; text?: string }>)
    .filter((message) => message.role === "user").map((message) => message.text);
  await expect.poll(said, { timeout: 30_000 }).toHaveLength(2);
  expect(await said()).toEqual(["Queued before the restart", "Sent while the engines are read"]);
  expect((await server.control("wait", "--bot", starter.id, "--task", starter.threadId, "--timeout", "30")).status).toBe("settled");
}, 60_000);