server/browser-shutdown.e2e.test.ts
Rama main · réplica de lectura del repositorio original.
// Real server, fake Claude CLI, and an agent-browser stub that logs each command it gets and lists the daemons the test marks running.
import { spawn, type ChildProcess } from "node:child_process";
import { appendFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
import { 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);
});
/** A home with the stub engine. A running daemon is a file in `running/`
* plus the pid file agent-browser writes; `close` removes both. */
function engineHome(config: Record<string, unknown> = {}) {
const home = mkdtempSync(join(tmpdir(), "omb-browser-shutdown-"));
homes.push(home);
mkdirSync(join(home, "tmp"), { recursive: true });
mkdirSync(join(home, "running"), { recursive: true });
mkdirSync(join(home, ".agent-browser"), { recursive: true });
const log = join(home, "agent-browser.log");
const engine = join(home, "agent-browser");
writeFileSync(engine, [
"#!/bin/sh",
`printf '%s %s\\n' "$AGENT_BROWSER_SESSION" "$*" >> '${log}'`,
`if [ "$1" = session ]; then printf '{"success":true,"data":{"sessions":['; sep=''; for f in '${home}/running'/*; do [ -e "$f" ] || continue; printf '%s"%s"' "$sep" "\${f##*/}"; sep=','; done; printf ']}}\\n'; fi`,
// The server's PATH holds only node: name rm by its path.
`if [ "$1" = close ]; then /bin/rm -f '${home}/running/'"$AGENT_BROWSER_SESSION" '${home}/.agent-browser/'"$AGENT_BROWSER_SESSION.pid"; fi`,
"exit 0",
].join("\n"), { mode: 0o755 });
writeFileSync(join(home, "config.json"), JSON.stringify({
features: { browser: true },
instances: { claude: { driver: "claudeAgent", displayName: "Verification fixture", config: { cli: FAKE_CLI } } },
...config,
}));
// Each start is a new daemon with a pid of its own, which no process has.
let started = 0;
const run = (session: string) => {
writeFileSync(join(home, "running", session), "");
writeFileSync(join(home, ".agent-browser", `${session}.pid`), String(2_147_483_000 + started++));
};
const commands = () => existsSync(log) ? readFileSync(log, "utf8").trim().split("\n") : [];
return { home, log, engine, run, commands };
}
async function startServer(home: string, engine: 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, home, port), OMB_AGENT_BROWSER_PATH: engine },
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 api = async (method: string, path: string, body?: unknown) => {
const response = await fetch(base + path, {
method, headers: { origin: base, ...(body === undefined ? {} : { "content-type": "application/json" }) },
body: body === undefined ? undefined : JSON.stringify(body),
});
return { status: response.status, body: await response.json() as any };
};
return { child, api };
}
async function browserTurnThenQuit(config: Record<string, unknown> = {}, browserProfile?: string,
beforeQuit?: (api: Awaited<ReturnType<typeof startServer>>["api"], fixture: ReturnType<typeof engineHome>) => Promise<void>) {
const fixture = engineHome(config);
const { child, api } = await startServer(fixture.home, fixture.engine);
const bot = (await api("POST", "/api/bots", { name: "Shutdown probe" })).body.bot;
expect((await api("PATCH", `/api/bots/${bot.id}`, { computer: "browser", browser: true, ...(browserProfile ? { browserProfile } : {}) })).status).toBe(200);
expect((await api("POST", `/api/bots/${bot.id}/messages`, { text: "Check the website." })).status).toBe(202);
await expect.poll(async () => (await api("GET", "/api/bots?messages=0")).body.bots
.find((item: { id: string }) => item.id === bot.id)?.busy, { timeout: 20_000 }).toBe(false);
const session = browserProfile ?? `bot-${bot.id}`;
// The person's Chrome has no saved state of OMB's to prepare, so a turn on
// My Chrome runs no engine command of its own; every other browser does.
const turnCommands = fixture.commands();
// The bot's browser is open when the person quits.
fixture.run(session);
await beforeQuit?.(api, fixture);
appendFileSync(fixture.log, "--- shutdown\n");
await waitForExit(child, { signal: "SIGTERM" });
return { session, turnCommands, commands: readFileSync(fixture.log, "utf8").split("--- shutdown\n")[1].trim().split("\n"),
stillRunning: existsSync(join(fixture.home, "running", session)) };
}
// The stub is a POSIX shell script; Windows resolves engines through a
// separate launch contract.
describe.skipIf(process.platform === "win32")("closing the browsers at shutdown", () => {
it("closes the browser a bot used when the server stops", async () => {
const { session, turnCommands, commands, stillRunning } = await browserTurnThenQuit();
expect(turnCommands.some((line) => line.startsWith(`${session} `))).toBe(true);
expect(commands).toContain(`${session} close`);
expect(stillRunning).toBe(false);
}, 60_000);
it("leaves the person's Chrome running for a profile set to My Chrome", async () => {
const { session, turnCommands, commands, stillRunning } = await browserTurnThenQuit({
browserEngine: { attachCdpUrl: "9222" },
browserProfiles: [{ id: "profile-mine", name: "Mine", browser: "chrome" }],
}, "profile-mine");
expect(turnCommands.filter((line) => line.startsWith(`${session} `))).toEqual([]);
expect(commands).not.toContain(`${session} close`);
expect(stillRunning).toBe(true);
}, 60_000);
it("keeps a built-in profile on its own browser even when a Chrome address is configured", async () => {
const { session, turnCommands, commands } = await browserTurnThenQuit({ browserEngine: { attachCdpUrl: "9222" } });
expect(turnCommands.some((line) => line.startsWith(`${session} `))).toBe(true);
expect(commands).toContain(`${session} close`);
}, 60_000);
it("closes a profile's built-in browser, saving its logins, when the profile switches to My Chrome", async () => {
const work = { id: "profile-work", name: "Work" };
const { session } = await browserTurnThenQuit({ browserProfiles: [work] }, "profile-work", async (api, fixture) => {
appendFileSync(fixture.log, "--- switch\n");
const switched = await api("PATCH", "/api/config", { browserProfiles: [{ ...work, browser: "chrome" }], expectedBrowserProfiles: [work] });
expect(switched.status).toBe(200);
expect(switched.body.browserProfiles).toEqual([{ ...work, browser: "chrome" }]);
await expect.poll(() => readFileSync(fixture.log, "utf8").split("--- switch\n")[1]?.split("\n"), { timeout: 10_000 }).toContain("profile-work close");
await expect.poll(() => existsSync(join(fixture.home, "running", "profile-work"))).toBe(false);
fixture.run("profile-work");
// The list the person saw carries the choice, so switching back is not
// mistaken for another window's edit.
const back = await api("PATCH", "/api/config", { browserProfiles: [work], expectedBrowserProfiles: [{ ...work, browser: "chrome" }] });
expect(back.status).toBe(200);
expect(back.body.browserProfiles).toEqual([work]);
await expect.poll(() => readFileSync(fixture.log, "utf8").split("--- switch\n")[1]?.split("\n").filter((line) => line === "profile-work close").length, { timeout: 10_000 }).toBe(2);
});
expect(session).toBe("profile-work");
}, 60_000);
it("closes the browsers a crashed server left when it starts again", async () => {
const fixture = engineHome();
const first = await startServer(fixture.home, fixture.engine);
const bot = (await first.api("POST", "/api/bots", { name: "Crash probe" })).body.bot;
fixture.run(`bot-${bot.id}`);
fixture.run("someone-elses");
first.child.kill("SIGKILL");
await waitForExit(first.child);
rmSync(fixture.log, { force: true });
await startServer(fixture.home, fixture.engine);
await expect.poll(fixture.commands, { timeout: 10_000 }).toContain(`bot-${bot.id} close`);
expect(fixture.commands()).not.toContain("someone-elses close");
expect(existsSync(join(fixture.home, "running", "someone-elses"))).toBe(true);
}, 60_000);
});