import assert from "node:assert/strict"; import { mkdtemp, readdir, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "vitest"; import { assemblePayload, interpolate, ledgerUpdate, MAX_SPOOL, resolveScreencastDestinations, runBatch, trimById, } from "../lib.ts"; const res = (id: number, result: unknown) => ({ id, type: "success", result }); test("interpolate resolves primitive refs from earlier responses", () => { const byId = new Map([[2, res(2, { context: "abc-1" })]]); assert.equal(interpolate("{{2.result.context}}", byId), "abc-1"); }); test("interpolate resolves deep paths inside nested params", () => { const byId = new Map([[4, res(4, { screencast: "sc-9" })]]); const params = { context: "{{2.result.context}}", nested: { a: ["{{4.result.screencast}}", "plain"] }, }; const out = interpolate( params, new Map([ [2, res(2, { context: "ctx-7" })], [4, byId.get(4)], ]), ); assert.deepEqual(out, { context: "ctx-7", nested: { a: ["sc-9", "plain"] } }); }); test("interpolate leaves strings without refs untouched", () => { assert.equal( interpolate("data:text/html,

hi

", new Map()), "data:text/html,

hi

", ); }); test("interpolate throws on unknown frame id", () => { assert.throws( () => interpolate("{{9.result.x}}", new Map()), /no successful response/, ); }); test("interpolate keeps whole-value primitive types and escapes literal refs", () => { const byId = new Map([[3, res(3, { n: 7, yes: false, text: "ok" })]]); assert.equal(interpolate("{{3.result.n}}", byId), 7); assert.equal(interpolate("{{3.result.yes}}", byId), false); assert.equal(interpolate("n={{3.result.n}}", byId), "n=7"); assert.equal(interpolate("{{{{3.result.text}}}}", byId), "{{3.result.text}}"); assert.equal( interpolate("text={{{{3.result.text}}}}", byId), "text={{3.result.text}}", ); assert.equal( interpolate("\u00000\u0000 {{{{3.result.text}}}}", byId), "\u00000\u0000 {{3.result.text}}", ); assert.throws( () => interpolate("{{3.result.constructor.name}}", byId), /path ends/, ); }); test("whole references preserve objects, arrays and null; embedded ones reject them", () => { const byId = new Map([[1, res(1, { tree: { a: [1, null] }, empty: null })]]); assert.deepEqual(interpolate("{{1.result.tree}}", byId), { a: [1, null] }); assert.deepEqual(interpolate("{{1.result.tree.a}}", byId), [1, null]); assert.equal(interpolate("{{1.result.empty}}", byId), null); assert.throws( () => interpolate("prefix {{1.result.tree}}", byId), /primitive/, ); assert.throws( () => interpolate("prefix {{1.result.empty}}", byId), /primitive/, ); }); test("resolveScreencastDestinations creates missing dir and injects explicit param", async () => { const dir = join(await mkdtemp(join(tmpdir(), "ffx-test-")), "videos"); const frames = [ { method: "browsingContext.startScreencast", params: { context: "c", destinationFolder: dir }, }, ]; await resolveScreencastDestinations(frames); assert.equal(frames[0].params?.destinationFolder, dir); assert.equal((await readdir(dir)).length, 0); }); test("resolveScreencastDestinations falls back to configured callback when param absent", async () => { const dir = join(await mkdtemp(join(tmpdir(), "ffx-test-")), "from-settings"); const frames = [ { method: "browsingContext.startScreencast", params: { context: "c" } }, ]; await resolveScreencastDestinations(frames, () => dir); assert.equal(frames[0].params?.destinationFolder, dir); assert.ok(await readdir(dir)); }); test("resolveScreencastDestinations explicit param beats configured", async () => { const dir = join(await mkdtemp(join(tmpdir(), "ffx-test-")), "explicit"); const frames = [ { method: "browsingContext.startScreencast", params: { context: "c", destinationFolder: dir }, }, ]; await resolveScreencastDestinations(frames, () => "/from-settings"); assert.equal(frames[0].params?.destinationFolder, dir); }); test("resolveScreencastDestinations leaves params untouched when nothing configured", async () => { const frames = [ { method: "browsingContext.startScreencast", params: { context: "c" } }, ]; await resolveScreencastDestinations(frames); assert.equal(frames[0].params?.destinationFolder, undefined); }); test("resolveScreencastDestinations ignores other methods", async () => { const frames = [{ method: "browsingContext.navigate", params: {} }]; await resolveScreencastDestinations(frames); assert.deepEqual(frames[0].params, {}); }); interface Ctx { pending: Map< number, (r: { id: number; type?: string; result?: unknown }) => void >; ledger: { subscriptions: string[]; intercepts: string[]; preloadScripts: string[]; }; spoolDir: string; spoolBudget?: { bytes: number }; } async function ctx(): Promise { return { pending: new Map(), ledger: { subscriptions: [], intercepts: [], preloadScripts: [] }, spoolDir: await mkdtemp(join(tmpdir(), "ffx-rb-")), }; } test("runBatch resolves frames in order and tracks ledger + byId", async () => { const c = await ctx(); const replies = new Map([ [1, {}], [2, { intercept: "int-1" }], ]); const pending = new Map void>(); const ws = { send: (s: string) => { const f = JSON.parse(s); queueMicrotask(() => pending.get(f.id)?.({ id: f.id, type: "success", result: replies.get(f.id), }), ); }, }; const b = { pending, ledger: c.ledger, spoolDir: c.spoolDir }; const byId = new Map(); const frames = [ { id: 1, method: "session.subscribe", params: { events: ["network"] } }, { id: 2, method: "network.addIntercept", params: { phases: ["request"] } }, ]; const { responses } = await runBatch( ws as unknown as WebSocket, b as never, frames as never, byId, 5000, ); assert.equal(responses.length, 2); assert.deepEqual( responses.map((r) => r.id), [1, 2], ); assert.ok(responses.every((r) => r.type === "success")); assert.deepEqual(c.ledger.subscriptions, ["network"]); assert.deepEqual(c.ledger.intercepts, ["int-1"]); assert.ok(byId.get(2)); }); test("runBatch reports every completed frame including failed and skipped frames", async () => { const b = await ctx(); const updates: string[] = []; const ws = { send: (s: string) => { const frame = JSON.parse(s); queueMicrotask(() => b.pending.get(frame.id)?.({ id: frame.id, type: "success", result: {}, }), ); }, }; const { responses } = await runBatch( ws, b, [ { id: 1, method: "session.status" }, { id: 2, method: "script.evaluate", params: { expression: "{{9.result.x}}" }, }, { id: 3, method: "browsingContext.getTree" }, ], new Map(), 5000, (response, completed) => updates.push( `${completed}:${response.method}:${response.skipped ? "skipped" : response.type}`, ), ); assert.equal(responses.length, 3); assert.deepEqual(updates, [ "1:session.status:success", "2:script.evaluate:error", "3:browsingContext.getTree:skipped", ]); }); test("runBatch cleans pending on timeout, abort, and send error; ignores late replies", async () => { const b = await ctx(); const sent: number[] = []; const ws = { send: (text: string) => { sent.push(JSON.parse(text).id); }, }; const byId = new Map(); const frames = [ { id: 1, method: "session.status" }, { id: 2, method: "session.status" }, ]; const { responses } = await runBatch(ws, b, frames, byId, 5); assert.deepEqual(sent, [1]); assert.equal(responses[0].type, "timeout"); assert.equal(responses[1].skipped, true); assert.equal(b.pending.size, 0); assert.equal(b.pending.get(1), undefined); const controller = new AbortController(); const pendingRun = runBatch( ws, b, [{ id: 3, method: "session.status" }], byId, 5000, undefined, controller.signal, ); controller.abort(); assert.equal((await pendingRun).responses[0].type, "aborted"); assert.equal(b.pending.size, 0); const fail = await runBatch( { send: () => { throw Error("socket closed"); }, }, b, [{ id: 4, method: "session.status" }], byId, 5000, ); assert.equal(fail.responses[0].type, "send-error"); assert.equal(b.pending.size, 0); }); test("runBatch spooled result remains available to later frame references", async () => { const b = await ctx(); const byId = new Map(); const sent: unknown[] = []; const ws = { send: (text: string) => { const frame = JSON.parse(text); sent.push(frame); queueMicrotask(() => b.pending.get(frame.id)?.({ id: frame.id, type: "success", result: frame.id === 1 ? { big: "x".repeat(300 * 1024), value: 13 } : {}, }), ); }, }; const first = await runBatch( ws, b, [{ id: 1, method: "script.evaluate" }], byId, 1000, ); assert.ok((first.responses[0].result as { spooled: string }).spooled); await runBatch( ws, b, [ { id: 2, method: "script.evaluate", params: { value: "{{1.result.value}}" }, }, ], byId, 1000, ); assert.equal((sent[1] as { params: { value: number } }).params.value, 13); }); test("runBatch rejects multibyte params and expanded references by UTF-8 bytes", async () => { const b = await ctx(); const sent: unknown[] = []; const byId = new Map([[1, res(1, { data: "🚀".repeat(280_000) })]]); const result = await runBatch( { send: (s: string) => sent.push(JSON.parse(s)) }, b, [ { id: 2, method: "script.evaluate", params: { data: "🚀".repeat(280_000) }, }, ], byId, 100, ); assert.equal(result.responses[0].type, "error"); assert.equal(sent.length, 0); const expanded = await runBatch( { send: (s: string) => sent.push(JSON.parse(s)) }, b, [ { id: 3, method: "script.evaluate", params: { data: "{{1.result.data}}" }, }, ], byId, 100, ); assert.equal(expanded.responses[0].type, "send-error"); assert.equal(sent.length, 0); }); test("runBatch bounds oversized requests and error payloads", async () => { const b = await ctx(); let sent = 0; const huge = await runBatch( { send: () => { sent++; }, }, b, [ { id: 1, method: "session.status", params: { data: "x".repeat(1024 * 1024 + 1) }, }, { id: 2, method: "session.status" }, ], new Map(), 1000, ); assert.equal(sent, 0); assert.equal(huge.responses[0].type, "error"); assert.equal(huge.responses[1].skipped, true); const ws = { send: (text: string) => { const frame = JSON.parse(text); queueMicrotask(() => b.pending.get(frame.id)?.({ id: frame.id, type: "error", message: "x".repeat(300 * 1024), }), ); }, }; const response = await runBatch( ws, b, [{ id: 3, method: "session.status" }], new Map(), 1000, ); assert.match(response.texts[0], /payload budget exceeded/); assert.ok(response.texts[0].length < 1024); }); test("runBatch + assemblePayload preserve 64 outcomes within the response budget", async () => { const b = await ctx(); const frames = Array.from({ length: 64 }, (_, index) => ({ id: index + 1, method: "session.status", })); const responseText = (id: number, data: string) => JSON.stringify({ id, type: "success", result: { data }, method: "session.status", }); const fixed = frames.map((frame) => frame.id === 1 ? JSON.stringify({ id: frame.id, type: "error", error: "synthetic failure", method: frame.method, }) : responseText(frame.id, frame.id === 64 ? "" : "x".repeat(14_000)), ); const finalDataBytes = 896 * 1024 - fixed.reduce((sum, text) => sum + Buffer.byteLength(text), 0); assert.ok(finalDataBytes > 0); const sent: number[] = []; const ws = { send: (text: string) => { const frame = JSON.parse(text); sent.push(frame.id); queueMicrotask(() => b.pending.get(frame.id)?.( frame.id === 1 ? { id: 1, type: "error", error: "synthetic failure" } : { id: frame.id, type: "success", result: { data: frame.id === 64 ? "x".repeat(finalDataBytes) : "x".repeat(14_000), }, }, ), ); }, }; const result = await runBatch(ws, b, frames, new Map(), 1000); assert.equal(result.responses.length, 64); assert.deepEqual( sent, frames.map((frame) => frame.id), ); assert.deepEqual( result.responses.map((response) => response.id), frames.map((frame) => frame.id), ); assert.equal(result.responses[0].error, "synthetic failure"); assert.equal(result.responses.at(-1)?.error, "pi payload budget exceeded"); assert.ok( result.texts.reduce( (sum, text, index) => sum + Buffer.byteLength(text) + (index ? 1 : 0), 0, ) <= 896 * 1024, ); const payload = assemblePayload( { sessionId: "s", port: 9222, capabilities: {} }, result.texts, [], 0, ); assert.ok(Buffer.byteLength(payload) <= 1024 * 1024); assert.deepEqual(JSON.parse(payload).responses, result.responses); }); test("runBatch aborts remaining frames after interpolation error", async () => { const pending = new Map void>(); const ws = { send: (s: string) => { const f = JSON.parse(s); queueMicrotask(() => pending.get(f.id)?.({ id: f.id, type: "success", result: {} }), ); }, }; const b = { pending, ledger: { subscriptions: [], intercepts: [], preloadScripts: [] }, spoolDir: await mkdtemp(join(tmpdir(), "ffx-rb-")), }; const byId = new Map(); const frames = [ { id: 1, method: "browsingContext.create", params: {} }, { id: 2, method: "browsingContext.navigate", params: { context: "{{9.result.context}}" }, }, { id: 3, method: "script.evaluate", params: {} }, ]; const { responses } = await runBatch( ws as unknown as WebSocket, b as never, frames as never, byId, 5000, ); assert.equal(responses[1].type, "error"); assert.equal(responses[2].skipped, true); }); test("runBatch spools by UTF-8 byte size, with byte-accurate marker", async () => { const b = await ctx(); const byId = new Map(); const data = "🚀".repeat(80_000); const result = await runBatch( { send: (s: string) => { const frame = JSON.parse(s); queueMicrotask(() => b.pending.get(frame.id)?.({ id: frame.id, type: "success", result: { data }, }), ); }, }, b, [{ id: 1, method: "script.evaluate" }], byId, 1000, ); const marker = result.responses[0].result as { spooled: string; size: number; }; assert.ok(marker.spooled); assert.equal( marker.size, Buffer.byteLength( JSON.stringify({ id: 1, type: "success", result: { data }, method: "script.evaluate", }), ), ); assert.equal(JSON.parse(await readFile(marker.spooled, "utf8")).data, data); }); test("runBatch spools oversized results and replaces them inline", async () => { const dir = await mkdtemp(join(tmpdir(), "ffx-rb-")); const pending = new Map void>(); const ws = { send: (s: string) => { const f = JSON.parse(s); queueMicrotask(() => pending.get(f.id)?.({ id: f.id, type: "success", result: { data: "A".repeat(300 * 1024) }, }), ); }, }; const b = { pending, ledger: { subscriptions: [], intercepts: [], preloadScripts: [] }, spoolDir: dir, }; const { responses } = await runBatch( ws as unknown as WebSocket, b as never, [ { id: 1, method: "browsingContext.captureScreenshot", params: {} }, ] as never, new Map(), 5000, ); const spooled = responses[0].result as { spooled: string; size: number }; assert.ok(spooled.spooled.startsWith(dir)); assert.equal((await readdir(dir)).length, 1); const file = await readFile(spooled.spooled, "utf8"); assert.equal(JSON.parse(file).data.length, 300 * 1024); assert.equal( spooled.size, JSON.stringify({ id: 1, type: "success", result: { data: "A".repeat(300 * 1024) }, method: "browsingContext.captureScreenshot", }).length, "marker.size equals the unspooled full-frame length", ); }); test("runBatch caps spool across retained browser lifetimes", async () => { const budget = { bytes: 0 }; const first = await ctx(); const second = await ctx(); first.spoolBudget = budget; second.spoolBudget = budget; const runLarge = (b: Ctx, id: number, bytes: number) => runBatch( { send: (text: string) => { const frame = JSON.parse(text); queueMicrotask(() => b.pending.get(frame.id)?.({ id: frame.id, type: "success", result: { data: "x".repeat(bytes) }, }), ); }, }, b, [{ id, method: "script.evaluate" }], new Map(), 1000, ); try { const retained = await runLarge(first, 1, 20 * 1024 * 1024); assert.ok((retained.responses[0].result as { spooled: string }).spooled); assert.equal((await readdir(first.spoolDir)).length, 1); assert.ok(budget.bytes < MAX_SPOOL); const exhausted = await runLarge(second, 2, 13 * 1024 * 1024); assert.equal(exhausted.responses[0].error, "pi spool budget exhausted"); assert.equal((await readdir(second.spoolDir)).length, 0); assert.equal(budget.bytes, first.spoolBytes); } finally { await rm(first.spoolDir, { recursive: true, force: true }); await rm(second.spoolDir, { recursive: true, force: true }); } }); test("runBatch drains early spool failure and never exposes broken references", async () => { const b = await ctx(); b.spoolDir = join(b.spoolDir, "missing"); const byId = new Map(); const ws = { send: (s: string) => { const frame = JSON.parse(s); setTimeout( () => b.pending.get(frame.id)?.({ id: frame.id, type: "success", result: { data: "x".repeat(300 * 1024) }, }), 30, ); }, }; const result = await runBatch( ws, b, [ { id: 1, method: "script.evaluate" }, { id: 2, method: "script.evaluate", params: { value: "{{1.result.data}}" }, }, ], byId, 1000, ); assert.equal(result.responses[0].error, "pi spool write failed"); assert.equal(result.responses[1].type, "error"); assert.match(result.responses[1].message ?? "", /spool write failed/); assert.equal(byId.get(1)?.error, "pi spool write failed"); assert.equal(b.spoolBytes, 0); const payload = assemblePayload( { sessionId: "s", port: 9222, capabilities: {} }, result.texts, [], 0, ); assert.equal(JSON.parse(payload).responses[0].error, "pi spool write failed"); }); test("runBatch flushes deferred spool writes before returning", async () => { const dir = await mkdtemp(join(tmpdir(), "ffx-rb-")); const pending = new Map void>(); const ws = { send: (s: string) => { const f = JSON.parse(s); queueMicrotask(() => pending.get(f.id)?.({ id: f.id, type: "success", result: { data: "A".repeat(300 * 1024) }, }), ); }, }; const b = { pending, ledger: { subscriptions: [], intercepts: [], preloadScripts: [] }, spoolDir: dir, }; const { responses } = await runBatch( ws as unknown as WebSocket, b as never, [ { id: 1, method: "browsingContext.captureScreenshot", params: {} }, { id: 2, method: "browsingContext.captureScreenshot", params: {} }, ] as never, new Map(), 5000, ); assert.equal(responses.length, 2); assert.equal( (await readdir(dir)).length, 2, "both spool files flushed before return", ); }); test("assemblePayload caps bytes across events, session and wait", () => { const events = Array.from({ length: 200 }, (_, i) => ({ type: "event", method: "e", params: { i, data: "a".repeat(7000) }, })); const payload = assemblePayload( { capabilities: "🦊".repeat(40_000) }, [], events, 200, { data: "🚀".repeat(100_000) }, ); assert.ok(Buffer.byteLength(payload) <= 1024 * 1024); const result = JSON.parse(payload); assert.ok(result.events.length < 200); assert.equal(result.note, "event buffer capped or payload budget reached"); assert.deepEqual(result.session.capabilities, { truncated: true }); assert.deepEqual(result.wait, { truncated: true }); assert.equal(result.events.at(-1).params.i, 199); }); test("assemblePayload produces JSON identical to object stringify", () => { const session = { sessionId: "s", port: 9222, capabilities: { browserName: "firefox" }, }; const responses = [ { id: 1, method: "session.new", type: "success", result: { sessionId: "s" }, }, { id: 2, method: "browsingContext.create", type: "error", error: "x", message: "y", }, ]; const events = [ { type: "event", method: "log.entryAdded", params: { a: 1 } }, ]; const wait = { timedOut: true, method: "x" }; const got = assemblePayload( session, responses.map((r) => JSON.stringify(r)), events, 200, wait, ); const want = JSON.stringify({ session, responses, events, wait }); assert.equal(JSON.parse(got).responses[0].result.sessionId, "s"); assert.deepEqual(JSON.parse(got), JSON.parse(want)); }); test("ledgerUpdate removes intercepts and preload scripts", () => { const ledger = { subscriptions: [], intercepts: ["i1", "i2"], preloadScripts: ["p1"], }; ledgerUpdate( { pending: new Map(), ledger, spoolDir: "" } as never, "network.removeIntercept", { intercepts: ["i1"] }, { id: 1, type: "success" }, ); ledgerUpdate( { pending: new Map(), ledger, spoolDir: "" } as never, "script.removePreloadScript", { id: "p1" }, { id: 2, type: "success" }, ); assert.deepEqual(ledger.intercepts, ["i2"]); assert.deepEqual(ledger.preloadScripts, []); }); test("trimById passes small maps through unchanged", () => { const byId = new Map([ [1, res(1, { a: 1 })], [2, res(2, { b: 2 })], ]); assert.equal(trimById(byId, 500), byId); }); test("trimById preserves insertion chronology across repeated byte-limited trims", () => { const large = "x".repeat(1100 * 1024); let entries = new Map>([ [100, res(100, {})], [101, res(101, {})], [1, res(1, { large })], [2, res(2, { large })], ]); entries = trimById(entries, 3); assert.deepEqual([...entries.keys()], [100, 101, 2]); entries.set(3, res(3, {})); entries = trimById(entries, 3); assert.deepEqual([...entries.keys()], [101, 2, 3]); }); test("trimById keeps the most recent keep ids by frame id", () => { const byId = new Map>(); for (let id = 1; id <= 600; id++) byId.set(id, res(id, { n: id })); const trimmed = trimById(byId, 500); assert.equal(trimmed.size, 500); assert.equal(trimmed.has(100), false); assert.equal(trimmed.get(101)?.id, 101); assert.equal(trimmed.get(600)?.id, 600); });