mirror of
https://github.com/SawyerHood/dev-browser.git
synced 2026-09-20 13:44:44 +08:00
16dec89cfe
* doobie: skeleton — client, daemon, vm runner, page helpers, build Co-Authored-By: Claude <noreply@anthropic.com> * doobie: snapshot engine, waitForLoad, docs, tests, bench, Bun-native fast client Co-Authored-By: Claude <noreply@anthropic.com> * doobie: fix round 1 from adversarial review (transport backpressure, profiles per mode, page mutex, bringToFront, zombie runs, shot DPR, dialogs, snapshot names/frames/boxes, docs) Co-Authored-By: Claude <noreply@anthropic.com> * docs: handoff notes Co-Authored-By: Claude <noreply@anthropic.com> * launch: mark clean exit to skip session restore, cache --no-sandbox; untrack build artifact Co-Authored-By: Claude <noreply@anthropic.com> * snapshot: drop [cursor=pointer] on inherently interactive roles, no row content names (HN interactive 29k -> 21k chars) Co-Authored-By: Claude <noreply@anthropic.com> * ci: bench tolerance on shared runners; release: npm publish only with NPM_TOKEN Co-Authored-By: Claude <noreply@anthropic.com> * mcp: stdio MCP server over the daemon frames (doobie mcp); ci: explicit test timeout Co-Authored-By: Claude <noreply@anthropic.com> * docs: bb integration note (socket source contract) Co-Authored-By: Claude <noreply@anthropic.com> * fix round 2: front lock (no stale cache), run gate covers handles/frames/popups, --connect extends only touched tabs, doobie chrome verifies launch, self-healing shim + devDependencies, snapshot name fallbacks/pointer inheritance/shadow refs, docs Co-Authored-By: Claude <noreply@anthropic.com> * daemon: drain active requests before exit; tests: realpath-safe and node-optional packaging tests; chrome: share sandbox helpers Co-Authored-By: Claude <noreply@anthropic.com> * relative file paths resolve against the caller's cwd; readFile("downloads/<name>"); TimeoutError for ref waits Co-Authored-By: Claude <noreply@anthropic.com> * runtime: share host Error constructors with the script realm Co-Authored-By: Claude <noreply@anthropic.com> * shim: sh/JS polyglot so bun-only machines run it; v0.1.1 Co-Authored-By: Claude <noreply@anthropic.com> * launch: automation profile prefs (leak-detection dialog off, no password/autofill UI) — fixes dead input after logins in new headless Co-Authored-By: Claude <noreply@anthropic.com> * v0.1.2 Co-Authored-By: Claude <noreply@anthropic.com> * client: retry when racing a shutting-down daemon (flaky stop/status on CI) Co-Authored-By: Claude <noreply@anthropic.com> * feat!: make doobie the dev-browser 1.0 runtime * ci: use Node 24 GitHub actions * fix: isolate authenticated CDP sessions * fix: close run gate escape paths * fix: preserve UTF-8 across protocol chunks * test: stabilize browser context gate coverage * test: isolate browser context gate coverage --------- Co-authored-by: Sawyer Hood <kirbyhood@gmail.com> Co-authored-by: Claude <noreply@anthropic.com>
279 lines
13 KiB
TypeScript
279 lines
13 KiB
TypeScript
/**
|
|
* End-to-end: script runtime guard rails through the real CLI + daemon +
|
|
* headless Chrome. Background tabs, zombie scripts after deadline/disconnect,
|
|
* listener/timer cleanup, timeout messages, dialogs, page-console hygiene,
|
|
* result formatting, stale refs, trailing object literals.
|
|
*/
|
|
import { test, expect, describe, beforeAll, afterAll } from "bun:test";
|
|
import { makeCliEnv, type CliEnv } from "../helpers/cli.ts";
|
|
import { startServer, sleep, type FixtureServer } from "../helpers/server.ts";
|
|
|
|
let cli: CliEnv;
|
|
let srv: FixtureServer;
|
|
const H = ["--headless"];
|
|
const q = (s: string) => JSON.stringify(s);
|
|
|
|
beforeAll(async () => {
|
|
cli = makeCliEnv("dev-browser-e2e-rt2-");
|
|
srv = await startServer({
|
|
"/": "<!doctype html><title>Home</title><h1 id=t>hello</h1>",
|
|
"/btn": "<!doctype html><title>Btn</title><button id=b onclick=\"window.clicked=(window.clicked||0)+1\">go</button>",
|
|
"/dialogs": `<!doctype html><title>Dialogs</title>
|
|
<button id=alert onclick="alert('hello'); window.afterAlert = true">a</button>
|
|
<button id=confirm onclick="window.answer = confirm('sure?')">c</button>`,
|
|
"/res": "<!doctype html><title>Res</title><script src='/missing.js'></script><p>x</p>",
|
|
"/spam": "<!doctype html><script>for (let i = 0; i < 40; i++) console.error('spam ' + i)</script>",
|
|
"/tick": "ok",
|
|
});
|
|
const r = await cli.run([...H, "-e", "1"]);
|
|
expect(r.code).toBe(0);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await srv.stop();
|
|
await cli.cleanup();
|
|
});
|
|
|
|
describe("background tabs", () => {
|
|
test("click/shot on a named page that is no longer the front tab completes", async () => {
|
|
const script = `const a = await browser.getPage("bgA")
|
|
await a.goto(${q(srv.url("/btn"))})
|
|
const b = await browser.getPage("bgB")
|
|
await b.goto(${q(srv.url("/btn"))})
|
|
await b.bringToFront()
|
|
await new Promise(r => setTimeout(r, 700))
|
|
const t0 = Date.now()
|
|
await a.click("#b")
|
|
const h = await a.$("#b"); await h.click()
|
|
await a.mouse.click(2, 2)
|
|
const s = await a.shot()
|
|
const dt = Date.now() - t0
|
|
await new Promise(r => setTimeout(r, 700))
|
|
const t1 = Date.now()
|
|
await b.click("#b")
|
|
const dt2 = Date.now() - t1;
|
|
({ clicked: await a.evaluate(() => window.clicked), w: s.width, fast: dt < 3000 && dt2 < 3000 })`;
|
|
const r = await cli.run([...H, "-t", "15"], { stdin: script });
|
|
expect(r.stderr).toBe("");
|
|
expect(r.code).toBe(0);
|
|
const v = JSON.parse(r.stdout.split("\n").filter((l) => !l.startsWith("[image]")).join("\n"));
|
|
expect(v).toEqual({ clicked: 2, w: 1280, fast: true });
|
|
}, 30_000);
|
|
});
|
|
|
|
describe("zombie scripts", () => {
|
|
test("after a deadline the script stops at its next page call and the page is free for the next script", async () => {
|
|
const zombie = `const p = await browser.getPage("zombie")
|
|
await p.goto(${q(srv.url("/"))})
|
|
for (;;) { await p.evaluate(() => { document.title = "zombie" }) }`;
|
|
const r = await cli.run([...H, "-t", "1"], { stdin: zombie });
|
|
expect(r.code).toBe(124);
|
|
expect(r.stderr.split("\n")[0]).toBe("TimeoutError: Timed out after 1s (deadline) while in page.evaluate()");
|
|
const next = `const p = await browser.getPage("zombie")
|
|
await p.evaluate(() => { document.title = "mine" })
|
|
await new Promise(r => setTimeout(r, 700))
|
|
await p.title()`;
|
|
const r2 = await cli.run([...H, "-t", "10"], { stdin: next });
|
|
expect(r2.stderr).toBe("");
|
|
expect(r2.stdout).toBe("mine\n");
|
|
const st = await cli.run(["status"]);
|
|
expect(st.stdout).toMatch(/runs\s+0 active|activeRuns.*0|0 active/);
|
|
}, 30_000);
|
|
|
|
test("a zombie that swallows errors and retries does not wedge the daemon", async () => {
|
|
const zombie = `const p = await browser.getPage("zombie")
|
|
for (;;) { try { await p.evaluate(() => 1) } catch {} }`;
|
|
const r = await cli.run([...H, "-t", "1"], { stdin: zombie });
|
|
expect(r.code).toBe(124);
|
|
for (let i = 0; i < 3; i++) {
|
|
const t0 = Date.now();
|
|
const r2 = await cli.run([...H, "-e", "'alive'"]);
|
|
expect(r2.stdout).toBe("alive\n");
|
|
expect(Date.now() - t0).toBeLessThan(3000);
|
|
await sleep(300);
|
|
}
|
|
}, 30_000);
|
|
|
|
test("timeout message names the in-flight call with its selector; plain sleeps keep the old message", async () => {
|
|
const script = `const p = await browser.getPage("zombie")
|
|
await p.waitForSelector("#never", { timeout: 10000 })`;
|
|
const r = await cli.run([...H, "-t", "1"], { stdin: script });
|
|
expect(r.code).toBe(124);
|
|
expect(r.stderr.split("\n")[0]).toBe('TimeoutError: Timed out after 1s (deadline) while in page.waitForSelector("#never")');
|
|
const r2 = await cli.run([...H, "-t", "1", "-e", "await new Promise(r => setTimeout(r, 3000))"]);
|
|
expect(r2.code).toBe(124);
|
|
expect(r2.stderr.split("\n")[0]).toBe("TimeoutError: Timed out after 1s (deadline)");
|
|
}, 30_000);
|
|
|
|
test("client disconnect ends the run: the script stops touching the page and status shows 0 active", async () => {
|
|
const ghost = `const p = await browser.getPage("ghost")
|
|
await p.goto(${q(srv.url("/"))})
|
|
for (;;) { await p.evaluate(() => { document.title = "ghost" }) }`;
|
|
// the helper kills the client after timeoutMs
|
|
const r = await cli.run([...H, "-t", "20"], { stdin: ghost, timeoutMs: 1500 });
|
|
expect(r.code).not.toBe(0);
|
|
await sleep(300);
|
|
const st = await cli.run(["status"]);
|
|
expect(st.stdout).toMatch(/0 active/);
|
|
const next = `const p = await browser.getPage("ghost")
|
|
await p.evaluate(() => { document.title = "mine" })
|
|
await new Promise(r => setTimeout(r, 700))
|
|
await p.title()`;
|
|
const r2 = await cli.run([...H, "-t", "10"], { stdin: next });
|
|
expect(r2.stdout).toBe("mine\n");
|
|
}, 30_000);
|
|
|
|
test("page listeners and timers from a finished script are removed", async () => {
|
|
const counts = `const p = await browser.getPage("lst");
|
|
[p.listenerCount("console"), p.listenerCount("request"), p.listenerCount("dialog")]`;
|
|
const base = await cli.run(H, { stdin: counts });
|
|
expect(base.code).toBe(0);
|
|
const baseline = JSON.parse(base.stdout) as number[];
|
|
const first = `const p = await browser.getPage("lst")
|
|
await p.goto(${q(srv.url("/"))})
|
|
p.on("console", () => {})
|
|
p.once("request", () => {})
|
|
p.on("dialog", d => d.accept())
|
|
setInterval(() => { fetch(${q(srv.url("/tick"))}).catch(() => {}) }, 30)
|
|
setTimeout(() => { fetch(${q(srv.url("/tick"))}).catch(() => {}) }, 100000)
|
|
await new Promise(r => setTimeout(r, 200))
|
|
"first done"`;
|
|
const r = await cli.run(H, { stdin: first });
|
|
expect(r.code).toBe(0);
|
|
expect(r.stdout).toBe("first done\n");
|
|
await sleep(300);
|
|
const ticks = srv.hits.filter((h) => h.endsWith("/tick")).length;
|
|
expect(ticks).toBeGreaterThan(0);
|
|
await sleep(400);
|
|
expect(srv.hits.filter((h) => h.endsWith("/tick")).length).toBe(ticks);
|
|
const r2 = await cli.run(H, { stdin: counts });
|
|
expect(r2.code).toBe(0);
|
|
// only dev-browser's own listeners remain (console collector, load tracker, default dialog handler)
|
|
expect(JSON.parse(r2.stdout)).toEqual(baseline);
|
|
expect(baseline[2]).toBe(1);
|
|
}, 30_000);
|
|
});
|
|
|
|
describe("dialogs", () => {
|
|
test("unhandled alert is auto-dismissed and reported on stderr; click does not hang", async () => {
|
|
const script = `const p = await browser.getPage("dlg")
|
|
await p.goto(${q(srv.url("/dialogs"))})
|
|
await p.click("#alert")
|
|
await p.click("#confirm")
|
|
await new Promise(r => setTimeout(r, 50));
|
|
[await p.evaluate(() => window.afterAlert), await p.evaluate(() => window.answer)]`;
|
|
const r = await cli.run([...H, "-t", "10"], { stdin: script });
|
|
expect(r.code).toBe(0);
|
|
expect(JSON.parse(r.stdout)).toEqual([true, false]);
|
|
expect(r.stderr).toContain("[page:dlg] dialog alert: hello (auto-dismissed)");
|
|
expect(r.stderr).toContain("[page:dlg] dialog confirm: sure? (auto-dismissed)");
|
|
}, 20_000);
|
|
|
|
test("a script's own dialog handler keeps control", async () => {
|
|
const script = `const p = await browser.getPage("dlg")
|
|
await p.goto(${q(srv.url("/dialogs"))})
|
|
p.on("dialog", d => d.accept())
|
|
await p.click("#confirm")
|
|
await new Promise(r => setTimeout(r, 50))
|
|
await p.evaluate(() => window.answer)`;
|
|
const r = await cli.run([...H, "-t", "10"], { stdin: script });
|
|
expect(r).toEqual({ code: 0, stdout: "true\n", stderr: "" });
|
|
}, 20_000);
|
|
});
|
|
|
|
describe("page console hygiene", () => {
|
|
test("favicon 404 is dropped; failed resources name their URL; overflow gets a marker", async () => {
|
|
const script = `const p = await browser.getPage("res")
|
|
await p.goto(${q(srv.url("/res"))})
|
|
await new Promise(r => setTimeout(r, 300))
|
|
"ok"`;
|
|
const r = await cli.run(H, { stdin: script });
|
|
expect(r.code).toBe(0);
|
|
expect(r.stderr).toContain(`[page:res] error: Failed to load resource: the server responded with a status of 404 (Not Found) (${srv.url("/missing.js")})`);
|
|
expect(r.stderr).not.toContain("favicon");
|
|
const spam = `const p = await browser.getPage("spam2")
|
|
await p.goto(${q(srv.url("/spam"))})
|
|
await new Promise(r => setTimeout(r, 200))
|
|
"ok"`;
|
|
const r2 = await cli.run(H, { stdin: spam });
|
|
const lines = r2.stderr.split("\n").filter((l) => l.startsWith("[page"));
|
|
expect(lines.length).toBe(21);
|
|
expect(lines[20]).toBe("[page] ... 20 more lines");
|
|
}, 20_000);
|
|
});
|
|
|
|
describe("result formatting", () => {
|
|
test("vm-realm Error/Map/Set print usefully; console.log(err) shows the message", async () => {
|
|
const r = await cli.run(H, { stdin: "try { null.x } catch (e) { console.log(e) }\nconst m = new Map([['a', new Set([1])]])\nm" });
|
|
expect(r.code).toBe(0);
|
|
expect(r.stdout.split("\n")[0]).toMatch(/^TypeError: /);
|
|
expect(r.stdout).toContain('{\n "a": [\n 1\n ]\n}');
|
|
const r2 = await cli.run([...H, "-e", "new RangeError('bad range')"]);
|
|
expect(r2.stdout).toBe("RangeError: bad range\n");
|
|
});
|
|
|
|
test("--json result frame carries structured data", async () => {
|
|
const r = await cli.run([...H, "--json", "-e", "({ a: [1, 'x'], s: 'str' })"]);
|
|
const frames = r.stdout.trim().split("\n").map((l) => JSON.parse(l));
|
|
const res = frames.find((f) => f.type === "result");
|
|
expect(res.data).toEqual({ a: [1, "x"], s: "str" });
|
|
expect(typeof res.value).toBe("string");
|
|
const r2 = await cli.run([...H, "--json", "-e", "'plain'"]);
|
|
const res2 = r2.stdout.trim().split("\n").map((l) => JSON.parse(l)).find((f) => f.type === "result");
|
|
expect(res2).toEqual({ type: "result", value: "plain", data: "plain" });
|
|
});
|
|
|
|
test("trailing object literal without parens is the result", async () => {
|
|
const r = await cli.run([...H, "-e", "const t = 'x'\n{ t, n: 1 }"]);
|
|
expect(r).toEqual({ code: 0, stdout: '{\n "t": "x",\n "n": 1\n}\n', stderr: "" });
|
|
});
|
|
|
|
test("page.shot() as the last expression prints one [image] line and a compact object", async () => {
|
|
const script = `const p = await browser.getPage("shot2")
|
|
await p.goto(${q(srv.url("/"))})
|
|
await p.shot()`;
|
|
const r = await cli.run(H, { stdin: script });
|
|
expect(r.code).toBe(0);
|
|
expect(r.stdout.split("\n").filter((l) => l.startsWith("[image]")).length).toBe(1);
|
|
const obj = JSON.parse(r.stdout.split("\n").filter((l) => !l.startsWith("[image]")).join("\n"));
|
|
expect(Object.keys(obj).sort()).toEqual(["height", "path", "scale", "width"]);
|
|
});
|
|
});
|
|
|
|
describe("errors", () => {
|
|
test("per-action timeout shows the cause; stale ref via selector shows the documented message and a script frame", async () => {
|
|
const script = `const p = await browser.getPage("err2")
|
|
await p.goto(${q(srv.url("/"))})
|
|
await p.waitForSelector("#never", { timeout: 300 })`;
|
|
const r = await cli.run(H, { stdin: script });
|
|
expect(r.code).toBe(1);
|
|
expect(r.stderr.split("\n")[0]).toMatch(/Waiting for selector `#never` failed.*300ms exceeded/);
|
|
const stale = `const p = await browser.getPage("err2")
|
|
await p.snapshot()
|
|
await p.click("ref/e999")`;
|
|
const r2 = await cli.run(H, { stdin: stale });
|
|
expect(r2.code).toBe(1);
|
|
const lines = r2.stderr.split("\n");
|
|
expect(lines[0]).toStartWith('Error: Ref "e999" is stale or unknown. Take a new page.snapshot() and use a fresh ref.');
|
|
expect(lines[0]).toContain("(cause: No element found for selector: ref/e999)");
|
|
expect(lines[1]).toMatch(/^ at <stdin>:3:\d+$/);
|
|
// frame-ref errors reject (catchable), never throw synchronously
|
|
const caught = `const p = await browser.getPage("err2")
|
|
await p.click("ref/f9e1").catch(e => "caught: " + e.message)`;
|
|
const r3 = await cli.run(H, { stdin: caught });
|
|
expect(r3.code).toBe(0);
|
|
expect(r3.stdout).toMatch(/^caught: Frame f9/);
|
|
}, 20_000);
|
|
|
|
test("stack columns before the inserted return( on the last line are not over-shifted", async () => {
|
|
const r = await cli.run([...H, "-e", "const f = () => { throw new Error('x') }; f()"]);
|
|
expect(r.code).toBe(1);
|
|
const m = / at f \(<eval>:1:(\d+)\)/.exec(r.stderr) ?? / at <eval>:1:(\d+)/.exec(r.stderr);
|
|
expect(m).not.toBeNull();
|
|
// `throw` is at column 19 (1-based), `new Error` at 25, its `(` at 34; Bun reports the paren. The old
|
|
// bookkeeping subtracted the 8-char `return (` shift from every frame on the line (-> 26/17/11).
|
|
const col = Number(m![1]);
|
|
expect([19, 25, 34]).toContain(col);
|
|
});
|
|
});
|