repositories / pi-ext
pi-ext
bugabingas pi extensions
owned by admin
extensions/firefox-bidi/__bench__/e2e.mts
Raw// End-to-end benchmark against a real headless Firefox: measures the batch
// loop where spool writes can overlap real WebSocket round-trips.
// Baseline = inline spool awaits (__bench__/baseline.ts), candidate = deferred
// spool drain (lib.ts). One sessionless /session connection carries both
// variants; browsing contexts are created up front. Interleaved pairs,
// Wilcoxon signed-rank.
// Usage: node --experimental-strip-types __bench__/e2e.mts [pairs]
import { spawn } from "node:child_process";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { performance } from "node:perf_hooks";
import { runBatchBaseline } from "./baseline.ts";
import { runBatch, type BidiResponse, type BatchCtx, type Frame } from "../lib.ts";
const ITER = Number(process.argv[2] ?? 12);
function stats(s: number[]) {
const sorted = [...s].sort((a, b) => a - b);
const mean = s.reduce((a, b) => a + b, 0) / s.length;
return { n: s.length, median: sorted[Math.floor(sorted.length / 2)], mean, sd: Math.sqrt(s.reduce((a, b) => a + (b - mean) ** 2, 0) / s.length) };
}
function erf(x: number): number {
const t = 1 / (1 + 0.3275911 * Math.abs(x));
const y = 1 - ((((1.061405429 * t - 1.453152027) * t + 1.421413741) * t - 0.284496736) * t + 0.254829592) * t * Math.exp(-x * x);
return x >= 0 ? y : -y;
}
function wilcoxon(d: number[]): number {
const nz = d.filter((x) => x !== 0).map((x) => ({ abs: Math.abs(x), sign: Math.sign(x), rank: 0 }));
nz.sort((a, b) => a.abs - b.abs);
nz.forEach((o, i) => (o.rank = i + 1));
const w = nz.filter((o) => o.sign > 0).reduce((a, o) => a + o.rank, 0);
const n = nz.length;
const z = (w - (n * (n + 1)) / 4) / Math.sqrt((n * (n + 1) * (2 * n + 1)) / 24);
return 2 * (1 - 0.5 * (1 + erf(Math.abs(z) / Math.SQRT2)));
}
// Launch Firefox
const profileDir = await mkdtemp(join(tmpdir(), "ffx-e2e-"));
const child = spawn(process.env.FIREFOX_BIN ?? "firefox", ["--headless", "--no-remote", "--profile", profileDir, "--remote-debugging-port", "0"], { stdio: ["ignore", "pipe", "pipe"] });
const baseUrl = await new Promise<string>((resolve, reject) => {
const t = setTimeout(() => reject(new Error("no listener")), 30_000);
const grab = (buf: Buffer) => {
const m = String(buf).match(/WebDriver BiDi listening on (ws:\/\/\S+)/);
if (m) {
clearTimeout(t);
resolve(m[1]);
}
};
child.stdout?.on("data", grab);
child.stderr?.on("data", grab);
});
// One sessionless connection; session.new attaches it. All commands and both
// variants run through this socket.
const ws = await connect(`${baseUrl}/session`);
const pending = new Map<number, (r: BidiResponse) => void>();
ws.onmessage = (m) => {
const f = JSON.parse(String(m.data));
if (f.type === "event") return;
pending.get(f.id)?.(f);
};
function connect(url: string): Promise<WebSocket> {
return new Promise((resolve, reject) => {
const w = new WebSocket(url);
const t = setTimeout(() => reject(new Error("connect timeout")), 10_000);
w.onopen = () => {
clearTimeout(t);
resolve(w);
};
w.onerror = () => {
clearTimeout(t);
reject(new Error("connect failed"));
};
});
}
let nextId = 1;
function call(method: string, params: Record<string, unknown>): Promise<BidiResponse> {
const id = nextId++;
return new Promise((resolve) => {
pending.set(id, resolve);
ws.send(JSON.stringify({ id, method, params }));
});
}
const sn = await call("session.new", { capabilities: { alwaysMatch: { acceptInsecureCerts: true } } });
if (sn.type !== "success") throw new Error(`session.new failed: ${JSON.stringify(sn)}`);
async function makeSlot(spoolParent: string): Promise<{ ctx: BatchCtx; ctxId: string; nextId: () => number; n: { v: number } }> {
const cr = await call("browsingContext.create", { type: "tab" });
const ctxId = (cr.result as { context: string }).context;
const spoolDir = await mkdtemp(join(tmpdir(), spoolParent));
const n = { v: 500 };
return {
ctx: { pending, ledger: { subscriptions: [], intercepts: [], preloadScripts: [] }, spoolDir },
ctxId,
nextId: () => n.v++,
n,
};
}
const framesFor = (ctxId: string, ids: () => number): Frame[] => [
{ id: ids(), method: "browsingContext.navigate", params: { context: ctxId, url: "data:text/html,<b>x</b>", wait: "complete" } },
{ id: ids(), method: "script.evaluate", params: { expression: `"A".repeat(${300 * 1024})`, awaitPromise: false, target: { context: ctxId } } },
{ id: ids(), method: "script.evaluate", params: { expression: `"A".repeat(${300 * 1024})`, awaitPromise: false, target: { context: ctxId } } },
{ id: ids(), method: "script.evaluate", params: { expression: `"A".repeat(${300 * 1024})`, awaitPromise: false, target: { context: ctxId } } },
{ id: ids(), method: "browsingContext.navigate", params: { context: ctxId, url: "data:text/html,<i>y</i>", wait: "complete" } },
];
const slotA = await makeSlot("ffx-e2e-a-");
const slotB = await makeSlot("ffx-e2e-b-");
// Warmup
await runBatchBaseline(ws, slotA.ctx, framesFor(slotA.ctxId, slotA.nextId), new Map(), 30000);
await runBatch(ws, slotB.ctx, framesFor(slotB.ctxId, slotB.nextId), new Map(), 30000);
const pairs: { base: number; cand: number }[] = [];
for (let i = 0; i < ITER; i++) {
{
const t0 = performance.now();
await runBatchBaseline(ws, slotA.ctx, framesFor(slotA.ctxId, slotA.nextId), new Map(), 30000);
pairs.push({ base: performance.now() - t0, cand: 0 });
}
{
const t1 = performance.now();
await runBatch(ws, slotB.ctx, framesFor(slotB.ctxId, slotB.nextId), new Map(), 30000);
pairs[i].cand = performance.now() - t1;
}
}
const baseSamples = pairs.map((p) => p.base);
const candSamples = pairs.map((p) => p.cand);
const diffs = pairs.map((p) => p.base - p.cand);
console.log(JSON.stringify({
pairs: ITER,
samples_ms: { baseline_inlineSpool: baseSamples, candidate_deferredSpool: candSamples },
stats: { baseline: stats(baseSamples), candidate: stats(candSamples) },
paired_diff_ms: { mean: diffs.reduce((a, b) => a + b, 0) / diffs.length },
wilcoxon_p: wilcoxon(diffs),
}));
ws.close();
child.kill("SIGTERM");
await rm(profileDir, { recursive: true, force: true }).catch(() => {});