repositories / pi-ext
pi-ext
bugabingas pi extensions
owned by admin
extensions/firefox-bidi/__tests__/lib.test.ts
Rawimport 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,<h1>hi</h1>", new Map()),
"data:text/html,<h1>hi</h1>",
);
});
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<Ctx> {
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<number, unknown>([
[1, {}],
[2, { intercept: "int-1" }],
]);
const pending = new Map<number, (r: any) => 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<number, (r: any) => 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<number, (r: any) => 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<number, (r: any) => 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<number, ReturnType<typeof res>>([
[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<number, ReturnType<typeof res>>();
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);
});