import assert from "node:assert/strict";
import { existsSync } from "node:fs";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { test } from "vitest";
import { Browser, executeOperations } from "../browser.ts";
import { type Frame, TMP_PREFIX } from "../lib.ts";
test("wait before its labeled command fails before sending anything", async () => {
const b = new Browser();
const result = await executeOperations(b, [
{
kind: "wait",
after: "nav",
event: "Page.loadEventFired",
sessionId: "session",
},
{
kind: "cdp",
method: "Page.navigate",
label: "nav",
sessionId: "session",
params: { url: "about:blank" },
},
]);
assert.match(result.failure || "", /wait must follow a labeled cdp command/);
assert.deepEqual(result.output, []);
});
test("batch failure identifies failed operation and skipped operations", async () => {
const b = new Browser();
const sent: string[] = [];
b.send = async (method): Promise => {
sent.push(method);
return method === "Browser.fail"
? { id: sent.length, error: { code: -1, message: "expected failure" } }
: { id: sent.length, result: {} };
};
const result = await executeOperations(b, [
{ kind: "cdp", method: "Browser.getVersion" },
{ kind: "cdp", method: "Browser.fail" },
{ kind: "status" },
]);
assert.equal(result.failure, "expected failure");
assert.deepEqual(result.failedOperation, { operation: 1, kind: "cdp" });
assert.deepEqual(result.skipped, [{ operation: 2, kind: "status" }]);
assert.deepEqual(sent, ["Browser.getVersion", "Browser.fail"]);
await b.close();
});
test("multiple waits sharing an after label fail before sending", async () => {
const b = new Browser();
let sent = 0;
b.send = async (): Promise => {
sent++;
return { id: sent, result: {} };
};
const result = await executeOperations(b, [
{ kind: "cdp", method: "Browser.getVersion", label: "ready" },
{
kind: "wait",
after: "ready",
event: "Page.loadEventFired",
sessionId: "s",
},
{
kind: "wait",
after: "ready",
event: "Page.frameStoppedLoading",
sessionId: "s",
},
]);
assert.match(result.failure || "", /multiple waits use after label: ready/);
assert.deepEqual(result.failedOperation, { operation: 2, kind: "wait" });
assert.deepEqual(result.skipped, []);
assert.equal(sent, 0);
await b.close();
});
test("multiple batch waits match their events and release their observers", async () => {
const b = new Browser();
let id = 0;
b.send = async (): Promise => {
b.onMessage(
JSON.stringify({
method: "Page.loadEventFired",
sessionId: "s",
params: { number: ++id },
}),
);
return { id, result: {} };
};
const result = await executeOperations(b, [
{ kind: "cdp", method: "Page.navigate", sessionId: "s", label: "first" },
{
kind: "wait",
after: "first",
event: "Page.loadEventFired",
sessionId: "s",
},
{ kind: "cdp", method: "Page.navigate", sessionId: "s", label: "second" },
{
kind: "wait",
after: "second",
event: "Page.loadEventFired",
sessionId: "s",
},
]);
assert.equal(result.failure, undefined);
assert.deepEqual(
[result.output[1], result.output[3]].map(
(item) => (item as { wait: { seq: number } }).wait.seq,
),
[1, 2],
);
assert.equal(b.watchers.length, 0);
});
test("event overflow spools exact messages and reports path", async () => {
const b = new Browser();
b.spoolDir = await mkdtemp(TMP_PREFIX);
try {
for (let i = 0; i < 230; i++)
b.onMessage(
JSON.stringify({
method: "Log.entryAdded",
sessionId: "s",
params: { text: `entry-${i}` },
}),
);
const res = await executeOperations(b, []);
assert.ok(res.overflow.length > 0);
assert.ok(res.events.length < 230);
assert.match(await readFile(res.overflow[0], "utf8"), /entry-0/);
b.onMessage(
JSON.stringify({
method: "Log.entryAdded",
sessionId: "s",
params: { text: "x".repeat(18000) },
}),
);
const big = await executeOperations(b, []);
assert.equal(big.events.at(-1)?.frame, undefined);
assert.match(await readFile(big.overflow[0], "utf8"), /"text":"xxx/);
const path = big.overflow[0];
await b.close();
assert.equal(existsSync(path), false);
} finally {
await b.close();
await rm(b.spoolDir, { recursive: true, force: true });
}
});
test("labeled small replies remain referencable; large replies spool unchanged", async () => {
const b = new Browser();
b.spoolDir = await mkdtemp(TMP_PREFIX);
const small: Frame = { id: 1, result: { value: 42 } };
const large: Frame = { id: 2, result: { data: "x".repeat(512 * 1024) } };
b.send = async (method) => (method === "Browser.getVersion" ? small : large);
try {
const result = await executeOperations(b, [
{ kind: "cdp", method: "Browser.getVersion", label: "small" },
{ kind: "cdp", method: "Browser.getHistograms", label: "large" },
]);
assert.equal(result.failure, undefined);
assert.deepEqual((result.output[0] as { raw: Frame }).raw, small);
assert.deepEqual(b.results.get("small"), small);
assert.equal(b.results.has("large"), false);
const output = (
result.output[1] as {
raw: { path: string; bytes: number; preview: string };
}
).raw;
assert.equal(await readFile(output.path, "utf8"), JSON.stringify(large));
assert.equal(output.bytes, Buffer.byteLength(JSON.stringify(large)));
assert.equal(output.preview, JSON.stringify(large).slice(0, 512));
} finally {
await b.close();
}
});
test("payload spooling expires old event files absent from this response", async () => {
const b = new Browser();
b.spoolDir = await mkdtemp(TMP_PREFIX);
const response: Frame = {
id: 1,
result: { data: "r".repeat(40 * 1024) },
};
b.send = async (): Promise => response;
try {
b.onMessage(
JSON.stringify({
method: "Log.entryAdded",
sessionId: "s",
params: { text: "e".repeat(16 * 1024 * 1024 - 128 * 1024) },
}),
);
await b.spoolWrites;
const eventPath = b.events[0]?.spooled;
assert.ok(eventPath);
b.cursor = b.seq;
const result = await executeOperations(b, [
{ kind: "cdp", method: "Browser.getVersion" },
{ kind: "cdp", method: "Browser.getVersion" },
{ kind: "cdp", method: "Browser.getVersion" },
{ kind: "cdp", method: "Browser.getVersion" },
]);
assert.deepEqual(result.overflow, []);
assert.equal(JSON.stringify(result).includes(eventPath), false);
const { images: _images, ...text } = result;
const resultSpool = await b.spoolPayload(JSON.stringify(text));
assert.equal(existsSync(eventPath), false);
assert.equal(existsSync(resultSpool), true);
} finally {
await b.close();
await rm(b.spoolDir, { recursive: true, force: true });
}
});
test("replay reports event files evicted between batches as lost", async () => {
const b = new Browser();
b.spoolDir = await mkdtemp(TMP_PREFIX);
try {
b.onMessage(
JSON.stringify({
method: "Log.entryAdded",
sessionId: "s",
params: { text: "e".repeat(9 * 1024 * 1024) },
}),
);
await b.spoolWrites;
const eventPath = b.events[0]?.spooled;
assert.ok(eventPath);
b.cursor = b.seq;
await executeOperations(b, []);
await b.spoolPayload("p".repeat(9 * 1024 * 1024));
assert.equal(existsSync(eventPath), false);
const replay = await executeOperations(b, [], undefined, 0);
assert.deepEqual(replay.overflow, []);
assert.equal(replay.events[0]?.lost, true);
assert.equal(replay.events[0]?.spooled, undefined);
} finally {
await b.close();
await rm(b.spoolDir, { recursive: true, force: true });
}
});
test("replayed event spool paths survive response quota exhaustion", async () => {
const b = new Browser();
b.spoolDir = await mkdtemp(TMP_PREFIX);
const eventData = "e".repeat(2 * 1024 * 1024);
const response: Frame = {
id: 1,
result: { data: "r".repeat(15 * 1024 * 1024) },
};
b.send = async (): Promise => response;
try {
b.onMessage(
JSON.stringify({
method: "Log.entryAdded",
sessionId: "s",
params: { text: eventData },
}),
);
await b.spoolWrites;
const eventPath = b.events[0]?.spooled;
assert.ok(eventPath);
b.cursor = b.seq;
const result = await executeOperations(
b,
[{ kind: "cdp", method: "Browser.getVersion" }],
undefined,
0,
);
assert.match(result.failure || "", /available spool quota/);
assert.equal(result.events[0]?.spooled, eventPath);
assert.equal(existsSync(eventPath), true);
} finally {
await b.close();
await rm(b.spoolDir, { recursive: true, force: true });
}
});
test("queued event spooling remains protected when a batch starts", async () => {
const b = new Browser();
b.spoolDir = await mkdtemp(TMP_PREFIX);
const response: Frame = {
id: 1,
result: { data: "r".repeat(15 * 1024 * 1024) },
};
b.send = async (): Promise => response;
try {
b.onMessage(
JSON.stringify({
method: "Log.entryAdded",
sessionId: "s",
params: { text: "e".repeat(2 * 1024 * 1024) },
}),
);
const result = await executeOperations(b, [
{ kind: "cdp", method: "Browser.getVersion" },
]);
assert.match(result.failure || "", /available spool quota/);
const eventPath = result.events[0]?.spooled;
assert.ok(eventPath);
assert.equal(existsSync(eventPath), true);
} finally {
await b.close();
await rm(b.spoolDir, { recursive: true, force: true });
}
});
test("current batch spool paths survive quota exhaustion", async () => {
const b = new Browser();
b.spoolDir = await mkdtemp(TMP_PREFIX);
const frame: Frame = { id: 1, result: { data: "x".repeat(9 * 1024 * 1024) } };
let calls = 0;
b.send = async (): Promise => ({ ...frame, id: ++calls });
try {
const result = await executeOperations(b, [
{ kind: "cdp", method: "Browser.getVersion", label: "first" },
{ kind: "cdp", method: "Browser.getVersion", label: "second" },
{ kind: "status" },
]);
assert.match(result.failure || "", /available spool quota/);
assert.deepEqual(result.failedOperation, { operation: 1, kind: "cdp" });
assert.deepEqual(result.skipped, [{ operation: 2, kind: "status" }]);
const first = (result.output[0] as { raw: { path: string } }).raw.path;
assert.equal(existsSync(first), true);
assert.equal(
(await readFile(first, "utf8")).length > 9 * 1024 * 1024,
true,
);
} finally {
await b.close();
await rm(b.spoolDir, { recursive: true, force: true });
}
});
test("video size cap does not throw in event handler", async () => {
const b = new Browser();
b.spoolDir = await mkdtemp(TMP_PREFIX);
try {
let stopped = false;
b.video = {
sessionId: "s",
size: 256 * 1024 * 1024,
droppedFrames: 0,
file: "ignored",
process: { stdin: { writable: false } } as never,
done: Promise.resolve(0),
timer: setTimeout(() => {}, 100),
};
b.stopVideo = async () => {
stopped = true;
b.video = undefined;
return "ignored";
};
assert.doesNotThrow(() =>
b.onMessage(
JSON.stringify({
method: "Page.screencastFrame",
sessionId: "s",
params: { data: Buffer.from("abc").toString("base64"), sessionId: 1 },
}),
),
);
await new Promise((resolve) => setImmediate(resolve));
assert.equal(stopped, true);
} finally {
await b.close();
await rm(b.spoolDir, { recursive: true, force: true });
}
});
test("concurrent video finalization and close share encoder completion", async () => {
const b = new Browser();
b.spoolDir = await mkdtemp(TMP_PREFIX);
const file = `${b.spoolDir}/capture.webm`;
await writeFile(file, "partial");
let finish!: (code: number) => void;
const done = new Promise((resolve) => {
finish = resolve;
});
let ends = 0;
b.video = {
file,
sessionId: "s",
size: 5,
droppedFrames: 0,
timer: setTimeout(() => {}, 5000),
process: {
stdin: {
end: () => {
ends++;
},
},
} as never,
done,
};
try {
const a = b.stopVideo();
const second = b.stopVideo();
const closing = b.close();
assert.equal(ends, 1);
finish(0);
assert.equal(await a, file);
assert.equal(await second, file);
await closing;
assert.equal(ends, 1);
assert.equal(b.closed, true);
} finally {
await b.close();
await rm(b.spoolDir, { recursive: true, force: true });
}
});
test("concurrent trace stops share one Tracing.end and one stream", async () => {
const b = new Browser();
b.spoolDir = await mkdtemp(TMP_PREFIX);
const file = `${b.spoolDir}/trace.json`;
let complete!: (v: Record) => void;
let ends = 0,
reads = 0,
closes = 0;
b.trace = {
file,
timer: setTimeout(() => {}, 5000),
done: new Promise((resolve) => {
complete = resolve as typeof complete;
}),
finish: (v) => complete(v),
};
b.send = async (method, params): Promise => {
if (method === "Tracing.end") ends++;
if (method === "IO.close") closes++;
if (method === "IO.read") {
assert.deepEqual(params, { handle: "trace-stream", size: 1024 * 1024 });
return {
id: 1,
result: reads++
? {
data: Buffer.from("[]}").toString("base64"),
base64Encoded: true,
eof: true,
}
: { data: '{"traceEvents":' },
};
}
return { id: 1, result: {} };
};
try {
const a = b.stopTrace();
const second = b.stopTrace();
await new Promise((resolve) => setImmediate(resolve));
complete({ stream: "trace-stream" });
assert.equal(await a, file);
assert.equal(await second, file);
assert.equal(ends, 1);
assert.equal(reads, 2);
assert.equal(closes, 1);
assert.deepEqual(JSON.parse(await readFile(file, "utf8")), {
traceEvents: [],
});
} finally {
await b.close();
await rm(b.spoolDir, { recursive: true, force: true });
}
});
test("failed trace export closes the CDP stream and removes incomplete file", async () => {
const b = new Browser();
b.spoolDir = await mkdtemp(TMP_PREFIX);
const file = `${b.spoolDir}/incomplete.json`;
let closed = 0,
reads = 0;
b.trace = {
file,
timer: setTimeout(() => {}, 5000),
done: Promise.resolve({ stream: "trace-stream" }),
finish: () => {},
};
b.send = async (method): Promise => {
if (method === "IO.close") closed++;
if (method === "IO.read")
return reads++
? { id: 1, error: { code: -1, message: "stream lost" } }
: { id: 1, result: { data: '{"traceEvents":' } };
return { id: 1, result: {} };
};
try {
await assert.rejects(b.stopTrace(), /stream lost/);
assert.equal(closed, 1);
assert.equal(existsSync(file), false);
} finally {
await b.close();
await rm(b.spoolDir, { recursive: true, force: true });
}
});