Files
sawyerhood__dev-browser/test/e2e/lifecycle.test.ts
2026-09-04 00:07:23 +00:00

561 lines
25 KiB
TypeScript

/**
* End-to-end: daemon/browser lifecycle, info subcommands, usage/help, --connect.
*/
import { test, expect, describe, beforeAll, afterAll } from "bun:test";
import * as fs from "node:fs";
import * as net from "node:net";
import * as os from "node:os";
import * as path from "node:path";
import puppeteer, { type Browser } from "puppeteer-core";
import { makeCliEnv, type CliEnv } from "../helpers/cli.ts";
import { startServer, sleep, type FixtureServer } from "../helpers/server.ts";
import { findChrome } from "../../src/shared/chrome.ts";
import { VERSION } from "../../src/shared/version.ts";
let cli: CliEnv;
let srv: FixtureServer;
const H = ["--headless"];
beforeAll(async () => {
cli = makeCliEnv("dev-browser-e2e-life-");
srv = await startServer({ "/": "<!doctype html><title>Life</title><p>x</p>" });
});
afterAll(async () => {
await srv.stop();
await cli.cleanup();
});
function pidAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
async function waitFor(cond: () => boolean, ms: number): Promise<boolean> {
const end = Date.now() + ms;
while (Date.now() < end) {
if (cond()) return true;
await sleep(50);
}
return cond();
}
describe("usage, version, help (no daemon needed)", () => {
test("unknown flag -> exit 2 with a dev-browser: message", async () => {
const r = await cli.run(["--bogus"]);
expect(r.code).toBe(2);
expect(r.stdout).toBe("");
expect(r.stderr).toBe("dev-browser: unknown flag --bogus\n");
});
test("unknown command -> exit 2", async () => {
const r = await cli.run(["frob"]);
expect(r.code).toBe(2);
expect(r.stderr).toMatch(/unknown command "frob"/);
});
test("--version", async () => {
const r = await cli.run(["--version"]);
expect(r).toEqual({ code: 0, stdout: `dev-browser ${VERSION}\n`, stderr: "" });
});
test("--help contains USAGE and the version", async () => {
const r = await cli.run(["--help"]);
expect(r.code).toBe(0);
expect(r.stdout).toContain("USAGE");
expect(r.stdout).toContain(VERSION);
expect(r.stdout).not.toContain("{{VERSION}}");
const r2 = await cli.run(["help"]);
expect(r2.stdout).toBe(r.stdout);
});
test("help <topic> prints only that section or a topic list", async () => {
const r = await cli.run(["help", "connect"]);
expect(r.code).toBe(0);
if (r.stdout.startsWith("## connect")) {
// exactly one section
expect(r.stdout.match(/^## /gm)!.length).toBe(1);
} else {
expect(r.stdout).toMatch(/^No help topic "connect"\. Topics: /);
}
const r2 = await cli.run(["help", "quickstart"]);
expect(r2.code).toBe(0);
expect(r2.stdout.startsWith("## quickstart")).toBe(true);
expect(r2.stdout.match(/^## /gm)!.length).toBe(1);
});
test("--timeout validation is a usage error", async () => {
const r = await cli.run(["--timeout", "0", "-e", "1"]);
expect(r.code).toBe(2);
expect(r.stderr).toMatch(/--timeout/);
});
test("bad --idle-timeout is a usage error", async () => {
const r = await cli.run(["--idle-timeout", "soon", "-e", "1"]);
expect(r.code).toBe(2);
expect(r.stderr).toMatch(/invalid duration/);
});
test("chrome --list prints at least one candidate", async () => {
const r = await cli.run(["chrome", "--list"]);
expect(r.code).toBe(0);
const lines = r.stdout.trim().split("\n");
expect(lines.length).toBeGreaterThanOrEqual(1);
expect(lines[0]).toMatch(/^(env|config|installed|system|playwright)\s+\//);
});
test("chrome with an unknown flag is a usage error", async () => {
const r = await cli.run(["chrome", "--wat"]);
expect(r.code).toBe(2);
});
test("install-skill writes SKILL.md under HOME", async () => {
const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), "dev-browser-home-"));
try {
const r = await cli.run(["install-skill", "--claude"], { env: { HOME: fakeHome } });
expect(r.code).toBe(0);
const file = path.join(fakeHome, ".claude", "skills", "dev-browser", "SKILL.md");
expect(r.stdout).toBe(`wrote ${file}\n`);
expect(fs.existsSync(file)).toBe(true);
const content = fs.readFileSync(file, "utf8");
expect(content.length).toBeGreaterThan(20);
expect(content).not.toContain("{{VERSION}}");
expect(fs.existsSync(path.join(fakeHome, ".codex"))).toBe(false);
// no flags: all three targets
const r2 = await cli.run(["install-skill"], { env: { HOME: fakeHome } });
expect(r2.code).toBe(0);
expect(fs.existsSync(path.join(fakeHome, ".codex", "skills", "dev-browser", "SKILL.md"))).toBe(true);
expect(fs.existsSync(path.join(fakeHome, ".agents", "skills", "dev-browser", "SKILL.md"))).toBe(true);
// unknown target
const r3 = await cli.run(["install-skill", "--vim"], { env: { HOME: fakeHome } });
expect(r3.code).toBe(2);
} finally {
fs.rmSync(fakeHome, { recursive: true, force: true });
}
});
});
describe("daemon and browser lifecycle", () => {
test("first run spawns the daemon: pid file, socket, log", async () => {
const r = await cli.run([...H, "-e", "'up'"]);
expect(r).toEqual({ code: 0, stdout: "up\n", stderr: "" });
expect(fs.existsSync(path.join(cli.home, "daemon.sock"))).toBe(true);
const pid = Number(fs.readFileSync(path.join(cli.home, "daemon.pid"), "utf8"));
expect(pidAlive(pid)).toBe(true);
expect(fs.readFileSync(path.join(cli.home, "daemon.log"), "utf8")).toMatch(/listening on/);
// spawn lock is released
expect(fs.existsSync(path.join(cli.home, "daemon.lock"))).toBe(false);
});
test("browsers output shape", async () => {
const r = await cli.run(["browsers"]);
expect(r.code).toBe(0);
expect(r.stderr).toBe("");
expect(r.stdout).toMatch(/^default:headless headless connected \d+ page\(s\) idle \d+s\/30m\n$/);
const j = await cli.run(["browsers", "--json"]);
const frames = j.stdout.trim().split("\n").map((l) => JSON.parse(l));
const data = frames.find((f) => f.type === "data");
expect(data.payload).toHaveLength(1);
expect(data.payload[0]).toMatchObject({ key: "default:headless", kind: "launch", name: "default", headless: true, connected: true, idleTimeoutMs: 30 * 60 * 1000 });
expect(typeof data.payload[0].wsEndpoint).toBe("string");
expect(typeof data.payload[0].pages).toBe("number");
});
test("status output shape", async () => {
const r = await cli.run(["status"]);
expect(r.code).toBe(0);
const lines = r.stdout.split("\n");
expect(lines[0]).toMatch(new RegExp(`^daemon pid \\d+, v${VERSION.replace(/\\./g, "\\\\.")}, up \\d+s$`));
expect(lines[1]).toBe(`socket ${path.join(cli.home, "daemon.sock")}`);
expect(lines[2]).toBe(`log ${path.join(cli.home, "daemon.log")}`);
expect(lines[3]).toBe("runs 0 active");
expect(lines[4]).toBe("browsers 1");
expect(lines[5]).toMatch(/^ default:headless headless connected/);
expect(r.stdout).toContain("log tail:");
const j = await cli.run(["status", "--json"]);
const data = j.stdout.trim().split("\n").map((l) => JSON.parse(l)).find((f) => f.type === "data");
expect(Object.keys(data.payload).sort()).toEqual(["activeRuns", "browsers", "logPath", "logTail", "pid", "socketPath", "uptimeMs", "version"]);
expect(data.payload.version).toBe(VERSION);
expect(data.payload.activeRuns).toBe(0);
});
test("--idle-timeout is reflected in browsers", async () => {
const r = await cli.run([...H, "--idle-timeout", "0", "-e", "1"]);
expect(r.code).toBe(0);
const b = await cli.run(["browsers"]);
expect(b.stdout).toMatch(/idle \d+s\n$/); // no "/30m" suffix when disabled
await cli.run([...H, "--idle-timeout", "30m", "-e", "1"]);
});
test("idle reaper closes a launched browser after --idle-timeout", async () => {
const r = await cli.run([...H, "--browser", "reap", "--idle-timeout", "1s", "-e", "1"]);
expect(r.code).toBe(0);
let b = await cli.run(["browsers"]);
expect(b.stdout).toMatch(/reap:headless headless connected \d+ page\(s\) idle \d+s\/1s/);
const end = Date.now() + 8000;
do {
await sleep(400);
b = await cli.run(["browsers"]);
} while (b.stdout.includes("reap:headless") && Date.now() < end);
expect(b.stdout).not.toContain("reap:headless");
expect(fs.readFileSync(path.join(cli.home, "daemon.log"), "utf8")).toMatch(/idle reaper closing reap:headless/);
});
test("a second profile name is a separate browser; stop NAME stops only it", async () => {
const r = await cli.run([...H, "--browser", "work", "-e", "(await browser.getPage('w')).url()"]);
expect(r).toEqual({ code: 0, stdout: "about:blank\n", stderr: "" });
const b = await cli.run(["browsers"]);
expect(b.stdout.trim().split("\n").length).toBe(2);
expect(b.stdout).toContain("work:headless headless");
expect(fs.existsSync(path.join(cli.home, "browsers", "work", "profile-headless"))).toBe(true);
const s = await cli.run(["stop", "work"]);
expect(s).toEqual({ code: 0, stdout: "stopped 1 browser(s)\n", stderr: "" });
const b2 = await cli.run(["browsers"]);
expect(b2.stdout.trim().split("\n").length).toBe(1);
expect(b2.stdout).not.toContain("work");
const s2 = await cli.run(["stop", "work"]);
expect(s2.code).toBe(1);
expect(s2.stdout).toBe('no browser named "work" is running\n');
});
test("stop default -> browsers says none; names file survives; getPage recreates", async () => {
const r = await cli.run(H, {
stdin: `const p = await browser.getPage("surv")\nawait p.goto(${JSON.stringify(srv.url("/"))})\nawait p.title()`,
});
expect(r).toEqual({ code: 0, stdout: "Life\n", stderr: "" });
const pagesFile = path.join(cli.home, "pages", "default__headless.json");
expect(JSON.parse(fs.readFileSync(pagesFile, "utf8")).surv).toBeDefined();
const s = await cli.run(["stop", "default"]);
expect(s).toEqual({ code: 0, stdout: "stopped 1 browser(s)\n", stderr: "" });
const b = await cli.run(["browsers"]);
expect(b).toEqual({ code: 0, stdout: "no browsers running\n", stderr: "" });
const p = await cli.run(["pages"]);
expect(p).toEqual({ code: 0, stdout: "no browsers running\n", stderr: "" });
// daemon is still alive
const pid = Number(fs.readFileSync(path.join(cli.home, "daemon.pid"), "utf8"));
expect(pidAlive(pid)).toBe(true);
// the names file still exists (Chrome died with stop, so the target is gone)
expect(fs.existsSync(pagesFile)).toBe(true);
// a fresh getPage recreates the page under the same name (blank), and the map is updated
const r2 = await cli.run([...H, "-e", `const p = await browser.getPage("surv"); p.url()`]);
expect(r2).toEqual({ code: 0, stdout: "about:blank\n", stderr: "" });
const map = JSON.parse(fs.readFileSync(pagesFile, "utf8"));
expect(map.surv).toMatch(/^[0-9A-F]{32}$/);
});
test("killing the daemon with SIGTERM: next run spawns a fresh daemon", async () => {
const pidFile = path.join(cli.home, "daemon.pid");
const oldPid = Number(fs.readFileSync(pidFile, "utf8"));
expect(pidAlive(oldPid)).toBe(true);
process.kill(oldPid, "SIGTERM");
expect(await waitFor(() => !pidAlive(oldPid), 10_000)).toBe(true);
// clean shutdown removes socket + pid file
expect(fs.existsSync(path.join(cli.home, "daemon.sock"))).toBe(false);
expect(fs.existsSync(pidFile)).toBe(false);
const r = await cli.run([...H, "-e", "'reborn'"]);
expect(r).toEqual({ code: 0, stdout: "reborn\n", stderr: "" });
const newPid = Number(fs.readFileSync(pidFile, "utf8"));
expect(newPid).not.toBe(oldPid);
expect(pidAlive(newPid)).toBe(true);
});
test("SIGKILLed daemon leaves a stale socket; the client recovers", async () => {
const pidFile = path.join(cli.home, "daemon.pid");
const oldPid = Number(fs.readFileSync(pidFile, "utf8"));
process.kill(oldPid, "SIGKILL");
expect(await waitFor(() => !pidAlive(oldPid), 10_000)).toBe(true);
expect(fs.existsSync(path.join(cli.home, "daemon.sock"))).toBe(true); // stale
// Chrome is orphaned (still holds the profile lock); the new daemon must reclaim the profile.
const r = await cli.run([...H, "-e", "(await browser.getPage('after-kill')).url()"]);
expect(r).toEqual({ code: 0, stdout: "about:blank\n", stderr: "" });
expect(Number(fs.readFileSync(pidFile, "utf8"))).not.toBe(oldPid);
expect(fs.readFileSync(path.join(cli.home, "daemon.log"), "utf8")).toMatch(/killing orphan Chrome/);
});
test("stop with no name stops everything and the daemon; auto-restarts on the next call", async () => {
const pidFile = path.join(cli.home, "daemon.pid");
const oldPid = Number(fs.readFileSync(pidFile, "utf8"));
const s = await cli.run(["stop"]);
expect(s.code).toBe(0);
expect(s.stdout).toMatch(/^stopped \d+ browser\(s\) and the daemon\n$/);
expect(await waitFor(() => !pidAlive(oldPid), 10_000)).toBe(true);
const r = await cli.run(["status"]);
expect(r.code).toBe(0);
expect(r.stdout).toContain("browsers 0");
});
test("concurrent cold start: parallel clients share one daemon", async () => {
await cli.run(["stop"]);
await sleep(300);
const rs = await Promise.all([1, 2, 3, 4].map((n) => cli.run([...H, "-e", `${n}`])));
for (const [i, r] of rs.entries()) expect(r).toEqual({ code: 0, stdout: `${i + 1}\n`, stderr: "" });
const log = fs.readFileSync(path.join(cli.home, "daemon.log"), "utf8");
// only one browser launch for all four
const s = await cli.run(["browsers"]);
expect(s.stdout.trim().split("\n").length).toBe(1);
expect(log).toMatch(/listening on/);
});
});
describe("hung scripts", () => {
test("detached continuations cannot use browser registry operations after a run ends", async () => {
srv.set("/gate-slow", async () => {
await sleep(150);
return "ok";
});
const r = await cli.run([...H, "-b", "gate", "-e", `
await browser.getPage("gate-victim");
void fetch(${JSON.stringify(srv.url("/gate-slow"))})
.then(() => browser.listPages())
.then(() => fetch(${JSON.stringify(srv.url("/gate-list-leaked"))}))
.catch(() => {});
void fetch(${JSON.stringify(srv.url("/gate-slow"))})
.then(() => browser.closePage("gate-victim"))
.then(() => fetch(${JSON.stringify(srv.url("/gate-close-leaked"))}))
.catch(() => {});
"scheduled"`]);
expect(r.code).toBe(0);
await sleep(350);
expect(srv.hits).not.toContain("GET /gate-list-leaked");
expect(srv.hits).not.toContain("GET /gate-close-leaked");
const pages = await cli.run([...H, "-b", "gate", "-e", `await browser.listPages()`]);
expect(JSON.parse(pages.stdout).some((p: { name: string | null }) => p.name === "gate-victim")).toBe(true);
await cli.run(["stop", "gate"]);
});
test("a finite busy loop past the deadline still returns; the daemon stays healthy", async () => {
const r = await cli.run([...H, "--timeout", "1", "-e", "const t = Date.now(); let i = 0; while (Date.now() - t < 2000) i++; 'done'"]);
// the event loop was blocked so the deadline timer could not fire first; the value wins
expect(r.code === 0 || r.code === 124).toBe(true);
const s = await cli.run(["status"]);
expect(s.stdout).toContain("runs 0 active");
});
test("an infinite loop: the client watchdog kills the hung daemon; the next call restarts it", async () => {
const pid = Number(fs.readFileSync(path.join(cli.home, "daemon.pid"), "utf8"));
const t0 = Date.now();
const r = await cli.run([...H, "--timeout", "1", "-e", "while (true) {}"], { timeoutMs: 40_000 });
expect(r.code).toBe(1);
expect(r.stderr).toMatch(/no response from daemon for 16s; killed the hung daemon/);
expect(Date.now() - t0).toBeLessThan(25_000);
expect(await waitFor(() => !pidAlive(pid), 5000)).toBe(true);
const r2 = await cli.run([...H, "-e", "'back'"]);
expect(r2).toEqual({ code: 0, stdout: "back\n", stderr: "" });
expect(Number(fs.readFileSync(path.join(cli.home, "daemon.pid"), "utf8"))).not.toBe(pid);
}, 45_000);
});
describe("protocol: version handshake", () => {
test("daemon answers a mismatched hello with a version error and honors shutdown", async () => {
await cli.run(["status"]); // make sure a daemon is up
const sockPath = path.join(cli.home, "daemon.sock");
const pid = Number(fs.readFileSync(path.join(cli.home, "daemon.pid"), "utf8"));
const frames: Array<Record<string, unknown>> = [];
await new Promise<void>((resolve, reject) => {
const sock = net.createConnection(sockPath);
let buf = "";
sock.setEncoding("utf8");
sock.on("data", (c: string) => {
buf += c;
let i: number;
while ((i = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, i);
buf = buf.slice(i + 1);
if (line.trim()) frames.push(JSON.parse(line));
}
if (frames.some((f) => f.type === "done") && frames.length === 3) {
sock.write(JSON.stringify({ type: "shutdown" }) + "\n");
}
});
sock.on("end", () => sock.end());
sock.on("close", () => resolve());
sock.on("error", reject);
sock.write(JSON.stringify({ type: "hello", version: "0.0.0-test", protocol: 1 }) + "\n");
sock.write(JSON.stringify({ type: "status" }) + "\n");
setTimeout(() => {
sock.destroy();
reject(new Error("timeout"));
}, 10_000);
});
expect(frames[0]).toMatchObject({ type: "hello", version: VERSION, protocol: 1, pid });
expect(frames[1]).toMatchObject({ type: "error", kind: "version", name: "VersionMismatch", daemonVersion: VERSION });
expect((frames[1] as { message: string }).message).toContain("0.0.0-test");
expect(frames[2]).toMatchObject({ type: "done", exitCode: 1 });
// the status request after a mismatched hello must not have been served
expect(frames.some((f) => f.type === "data")).toBe(false);
expect(await waitFor(() => !pidAlive(pid), 10_000)).toBe(true);
// a normal client transparently gets a fresh daemon
const r = await cli.run(["status"]);
expect(r.code).toBe(0);
expect(Number(fs.readFileSync(path.join(cli.home, "daemon.pid"), "utf8"))).not.toBe(pid);
});
test("hello frame is the first line; a bad first frame yields a protocol error", async () => {
await cli.run(["status"]);
const sockPath = path.join(cli.home, "daemon.sock");
const frames: Array<Record<string, unknown>> = [];
await new Promise<void>((resolve, reject) => {
const sock = net.createConnection(sockPath);
let buf = "";
sock.setEncoding("utf8");
sock.on("data", (c: string) => {
buf += c;
let i: number;
while ((i = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, i);
buf = buf.slice(i + 1);
if (line.trim()) frames.push(JSON.parse(line));
}
});
sock.on("end", () => sock.end());
sock.on("close", () => resolve());
sock.on("error", reject);
sock.write("this is not json\n");
setTimeout(() => {
sock.destroy();
reject(new Error("timeout"));
}, 10_000);
});
expect(frames.some((f) => f.type === "error" && f.name === "ProtocolError")).toBe(true);
expect(frames[frames.length - 1]).toMatchObject({ type: "done", exitCode: 1 });
});
});
describe("client: version mismatch recovery", () => {
test("an old daemon is asked to exit and the request is retried on a fresh one", async () => {
await cli.run(["stop"]);
const sockPath = path.join(cli.home, "daemon.sock");
expect(await waitFor(() => !fs.existsSync(sockPath), 10_000)).toBe(true);
// Fake "old daemon": speaks the protocol but with another version.
const seen: string[] = [];
const fake = net.createServer((sock) => {
sock.setEncoding("utf8");
let buf = "";
sock.on("data", (c: string) => {
buf += c;
let i: number;
while ((i = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, i);
buf = buf.slice(i + 1);
if (!line.trim()) continue;
const msg = JSON.parse(line) as { type: string };
seen.push(msg.type);
if (msg.type === "hello") {
sock.write(JSON.stringify({ type: "hello", version: "0.0.1-old", protocol: 1, pid: process.pid }) + "\n");
sock.write(JSON.stringify({ type: "error", kind: "version", name: "VersionMismatch", message: "old", daemonVersion: "0.0.1-old" }) + "\n");
sock.write(JSON.stringify({ type: "done", exitCode: 1, durationMs: 0 }) + "\n");
} else if (msg.type === "shutdown") {
sock.write(JSON.stringify({ type: "done", exitCode: 0, durationMs: 0 }) + "\n");
sock.end();
fake.close();
try {
fs.unlinkSync(sockPath);
} catch {
/* ignore */
}
} else {
// a run request must NOT be served by the old daemon; we answer with garbage output to detect it
sock.write(JSON.stringify({ type: "stdout", data: "FROM OLD DAEMON\n" }) + "\n");
}
}
});
});
await new Promise<void>((resolve) => fake.listen(sockPath, resolve));
try {
const r = await cli.run([...H, "-e", "'fresh'"]);
expect(r).toEqual({ code: 0, stdout: "fresh\n", stderr: "" });
expect(seen[0]).toBe("hello");
expect(seen).toContain("shutdown");
const pid = Number(fs.readFileSync(path.join(cli.home, "daemon.pid"), "utf8"));
expect(pidAlive(pid)).toBe(true);
} finally {
fake.close();
}
});
});
describe("--connect to an existing Chrome", () => {
let ext: Browser | null = null;
let port = 0;
let userDataDir = "";
beforeAll(async () => {
const chrome = findChrome();
if (!chrome) throw new Error("no Chrome");
port = await new Promise<number>((resolve) => {
const s = net.createServer();
s.listen(0, "127.0.0.1", () => {
const p = (s.address() as net.AddressInfo).port;
s.close(() => resolve(p));
});
});
userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "dev-browser-ext-chrome-"));
ext = await puppeteer.launch({
executablePath: chrome.path,
headless: true,
args: ["--no-sandbox", `--remote-debugging-port=${port}`, `--user-data-dir=${userDataDir}`],
});
const page = await ext.newPage();
await page.goto(srv.url("/"));
await cli.run(["stop", "default"]); // only the cdp entry should be listed below
});
afterAll(async () => {
await cli.run(["stop"]).catch(() => {});
if (ext) await ext.close().catch(() => {});
fs.rmSync(userDataDir, { recursive: true, force: true });
});
test("--connect PORT", async () => {
const r = await cli.run(["--connect", String(port), "-e", `const p = await browser.getPage("x"); await p.goto(${JSON.stringify(srv.url("/"))}); await p.title()`]);
expect(r).toEqual({ code: 0, stdout: "Life\n", stderr: "" });
const b = await cli.run(["browsers"]);
// the key is canonicalized to the resolved ws endpoint so that "9222", "http://..." and "auto" share one entry
expect(b.stdout).toMatch(new RegExp(`^cdp:ws://127\\.0\\.0\\.1:${port}/devtools/browser/[-0-9a-f]+#[0-9a-f]{16} cdp connected \\d+ page\\(s\\) idle \\d+s\\n$`));
});
test("--connect http://127.0.0.1:PORT reaches the same browser and the same named page", async () => {
const r = await cli.run(["--connect", `http://127.0.0.1:${port}`, "-e", `const p = await browser.getPage("x"); await p.title()`]);
expect(r).toEqual({ code: 0, stdout: "Life\n", stderr: "" });
const b = await cli.run(["browsers"]);
expect(b.stdout.trim().split("\n").length).toBe(1);
// the external Chrome sees exactly one extra tab named by dev-browser (plus its own initial + /test page)
const urls = (await ext!.pages()).map((p) => p.url());
expect(urls.filter((u) => u === srv.url("/")).length).toBe(2);
});
test("--connect auto finds it via the remembered chrome-ports.json", async () => {
fs.writeFileSync(path.join(cli.home, "chrome-ports.json"), JSON.stringify({ t: { port, pid: 0, profile: "x", at: Date.now() } }));
const r = await cli.run(["--connect", "-e", `const p = await browser.getPage("x"); await p.title()`]);
expect(r).toEqual({ code: 0, stdout: "Life\n", stderr: "" });
const b = await cli.run(["browsers"]);
expect(b.stdout.trim().split("\n").length).toBe(1);
});
test("dev-browser pages --connect lists the external Chrome's tabs", async () => {
const r = await cli.run(["pages", "--connect", String(port)]);
expect(r.code).toBe(0);
expect(r.stdout.split("\n")[0]).toMatch(new RegExp(`^cdp:ws://127\\.0\\.0\\.1:${port}/devtools/browser/[-0-9a-f]+#[0-9a-f]{16}:$`));
expect(r.stdout).toContain(` x ${srv.url("/")} "Life"`);
});
test("stop by cdp key disconnects without killing Chrome", async () => {
const s = await cli.run(["stop", `cdp:${port}`]);
expect(s).toEqual({ code: 0, stdout: "stopped 1 browser(s)\n", stderr: "" });
expect(ext!.connected).toBe(true);
expect((await ext!.pages()).length).toBeGreaterThan(0);
});
test("--connect to a dead port is a clean error", async () => {
const r = await cli.run(["--connect", "http://127.0.0.1:1", "-e", "1"]);
expect(r.code).toBe(1);
expect(r.stderr).toMatch(/^CdpConnectError: Could not read http:\/\/127\.0\.0\.1:1\/json\/version/);
expect(r.stdout).toBe("");
});
});