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>
156 lines
6.5 KiB
TypeScript
156 lines
6.5 KiB
TypeScript
import { test, expect, describe } from "bun:test";
|
|
import * as vm from "node:vm";
|
|
import { transformScript, ScriptSyntaxError } from "../../src/daemon/transform.ts";
|
|
|
|
async function run(src: string, sandbox: Record<string, unknown> = {}): Promise<unknown> {
|
|
const t = transformScript(src);
|
|
const ctx = vm.createContext({ ...sandbox });
|
|
const fn = vm.runInContext(t.code, ctx, { filename: "<stdin>", lineOffset: t.lineOffset }) as () => Promise<unknown>;
|
|
return fn();
|
|
}
|
|
|
|
describe("transformScript", () => {
|
|
test("last expression becomes the return value", async () => {
|
|
expect(await run("1+1")).toBe(2);
|
|
});
|
|
|
|
test("trailing semicolon", async () => {
|
|
expect(await run("const a = 2;\na * 3;")).toBe(6);
|
|
});
|
|
|
|
test("trailing comment after last expression", async () => {
|
|
expect(await run("const a = 2\na * 3 // comment\n")).toBe(6);
|
|
expect(await run("const a = 2\na * 3\n/* trailing block */")).toBe(6);
|
|
});
|
|
|
|
test("multi-line last expression", async () => {
|
|
const v = await run("const x = 1;\n({\n a: x,\n b: [1,\n 2]\n})");
|
|
expect(v).toEqual({ a: 1, b: [1, 2] });
|
|
});
|
|
|
|
test("multi-line call expression as last statement", async () => {
|
|
const v = await run("function f(a, b) { return a + b }\nf(\n 1,\n 2\n)");
|
|
expect(v).toBe(3);
|
|
});
|
|
|
|
test("explicit return works", async () => {
|
|
expect(await run("return 42")).toBe(42);
|
|
expect(await run("if (true) { return 'early' }\n'late'")).toBe("early");
|
|
});
|
|
|
|
test("const-only script returns undefined", async () => {
|
|
expect(await run("const a = 1; const b = 2;")).toBeUndefined();
|
|
expect(await run("")).toBeUndefined();
|
|
expect(await run("// only a comment")).toBeUndefined();
|
|
});
|
|
|
|
test("lone string literal (a 'directive' to the parser) is still returned", async () => {
|
|
expect(await run("'plain string'")).toBe("plain string");
|
|
expect(await run('"a"\n"b"')).toBe("b");
|
|
// but a real 'use strict' prologue is not the result
|
|
expect(await run("'use strict'")).toBeUndefined();
|
|
expect(await run("'use strict'\n5")).toBe(5);
|
|
});
|
|
|
|
test("top-level await", async () => {
|
|
const v = await run("const x = await Promise.resolve(5)\nawait new Promise(r => setTimeout(r, 1))\nx * 2", {
|
|
setTimeout,
|
|
});
|
|
expect(v).toBe(10);
|
|
});
|
|
|
|
test("await expression as last statement returns the resolved value", async () => {
|
|
expect(await run("await Promise.resolve('done')")).toBe("done");
|
|
});
|
|
|
|
test("never executes twice", async () => {
|
|
const calls: number[] = [];
|
|
await run("tick(1)\ntick(2)", { tick: (n: number) => calls.push(n) });
|
|
expect(calls).toEqual([1, 2]);
|
|
});
|
|
|
|
test("syntax error message includes line and column", () => {
|
|
let err: unknown;
|
|
try {
|
|
transformScript("const a = 1\nconst b = ;\n");
|
|
} catch (e) {
|
|
err = e;
|
|
}
|
|
expect(err).toBeInstanceOf(ScriptSyntaxError);
|
|
const e = err as ScriptSyntaxError;
|
|
expect(e.name).toBe("SyntaxError");
|
|
expect(e.line).toBe(2);
|
|
expect(e.message).toMatch(/line 2, column \d+/);
|
|
// acorn's trailing "(2:10)" is stripped in favour of the readable form
|
|
expect(e.message).not.toMatch(/\(\d+:\d+\)$/);
|
|
});
|
|
|
|
test("import statement gives a friendly error", () => {
|
|
expect(() => transformScript("import fs from 'fs'\n1")).toThrow(/import\/export are not available/);
|
|
expect(() => transformScript("export const a = 1")).toThrow(/import\/export are not available/);
|
|
});
|
|
|
|
test("stack line numbers match the user's script (Bun ignores negative lineOffset)", async () => {
|
|
const t = transformScript("const a = 1\n\nthrow new TypeError('boom')");
|
|
const ctx = vm.createContext({});
|
|
const fn = vm.runInContext(t.code, ctx, { filename: "<stdin>", lineOffset: t.lineOffset }) as () => Promise<unknown>;
|
|
let stack = "";
|
|
try {
|
|
await fn();
|
|
} catch (e) {
|
|
stack = (e as Error).stack ?? "";
|
|
}
|
|
expect(stack).toMatch(/<stdin>:3:\d+/);
|
|
});
|
|
|
|
test("hashbang is allowed", async () => {
|
|
expect(await run("#!/usr/bin/env dev-browser\n7")).toBe(7);
|
|
});
|
|
|
|
test("code is an async arrow expression sharing line 1 with the script", () => {
|
|
const t = transformScript("1");
|
|
expect(t.code.startsWith("(async () => {")).toBe(true);
|
|
expect(t.lineOffset).toBe(0);
|
|
expect(t.line1ColumnShift).toBe("(async () => {".length);
|
|
// line count of the wrapped code = user lines + 1 (closing line only)
|
|
const user = "a\nb\nconst c = 1";
|
|
expect(transformScript(user).code.split("\n").length).toBe(user.split("\n").length + 1);
|
|
});
|
|
});
|
|
|
|
describe("transformScript column shifts", () => {
|
|
test("records the wrapper shift on line 1 and the return( shift on the last statement line", () => {
|
|
expect(transformScript("1+1").columnShifts).toEqual({ 1: "(async () => {".length + "return (".length });
|
|
expect(transformScript("const a = 1\na").columnShifts).toEqual({ 1: "(async () => {".length, 2: "return (".length });
|
|
expect(transformScript("const a = 1\nconst b = 2").columnShifts).toEqual({ 1: "(async () => {".length });
|
|
});
|
|
});
|
|
|
|
describe("transformScript: trailing object literal", () => {
|
|
test("`{ a: 1 }` as the last statement is returned like an object literal (not a block)", async () => {
|
|
expect(await run("{ a: 1 }")).toEqual({ a: 1 });
|
|
expect(await run("const x = 2\n{ a: x }")).toEqual({ a: 2 });
|
|
expect(await run("{}")).toEqual({});
|
|
});
|
|
|
|
test("an object literal that does not even parse as a block (await/commas) is retried with parens", async () => {
|
|
expect(await run("const x = 2\n{ a: x, b: await Promise.resolve(3) }")).toEqual({ a: 2, b: 3 });
|
|
expect(await run("const x = 2\n{ a: x, b: 3 } // trailing comment\n")).toEqual({ a: 2, b: 3 });
|
|
expect(await run("const x = 2\n{ a: x, b: 3 }; /* block */")).toEqual({ a: 2, b: 3 });
|
|
});
|
|
|
|
test("real blocks and labels are left alone; other syntax errors still throw", async () => {
|
|
expect(await run("if (true) { 1 }")).toBeUndefined();
|
|
expect(await run("foo: { a: 1 }\n")).toBeUndefined();
|
|
expect(() => transformScript("const b = ;\n{ a: 1 }")).toThrow(ScriptSyntaxError);
|
|
expect(() => transformScript("{ a: 1, b: await, }")).toThrow(ScriptSyntaxError);
|
|
});
|
|
|
|
test("returnInsert records where `return (` went", () => {
|
|
expect(transformScript("1+1").returnInsert).toEqual({ line: 1, column: 0, length: 8 });
|
|
expect(transformScript("const f = () => 1; f()").returnInsert).toEqual({ line: 1, column: 19, length: 8 });
|
|
expect(transformScript("const a = 1\n a").returnInsert).toEqual({ line: 2, column: 2, length: 8 });
|
|
expect(transformScript("const a = 1").returnInsert).toBeNull();
|
|
});
|
|
});
|