mirror of
https://github.com/morluto/rea.git
synced 2026-11-05 14:26:07 +00:00
* fix(browser): name web-bundle budgets and recover web deadlines Name the existing heap, per-script, and total web-bundle phases, and reject a per-script deadline above the total. Promote capture, CDP command, source-map decode, and source-map fetch deadlines at today's defaults. Name DevTools timeoutMs, and state that discovery, cleanup grace, byte budgets, and Electron hook retention have no runtime override. Refs #5910 Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(browser): apply selected web deadlines to the owning phase Keep HTTP and browser-socket discovery on the fixed discovery deadline so a short command_timeout_ms starts at the handshake. Apply scenario command_timeout_ms to launch startup and the primary connect session, and keep service-worker restoration on the fixed 5000 ms grace. Omit command_timeout_ms from recovery text when the operation does not accept it. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(browser): retain scenario commands that finish after their deadline A timed-out scenario command stays owned so cleanup can close a late browser or context and restore a mutation that lands after the deadline. Child storage replies use that deadline, scenario recovery names command_timeout_ms in the capture JSON, and the session test connection implements beginCleanupGrace. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(browser): keep child command timeouts as the public failure A nested command such as Page.enable that is delivered but never answered was stored as protocol_error, hiding the command name, selected deadline, and recovery. Store the typed timeout and assert that public error. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(browser): keep runtime cleanup on the fixed grace Switch runtime restoration to the cleanup grace before the first send so a short command_timeout_ms cannot fail an otherwise successful observation. Stop advertising command_timeout_ms on list_browser_targets, which never bounds a post-discovery command. Co-authored-by: morluto <morluto@users.noreply.github.com> * test(browser): prove a short command deadline cannot fail runtime cleanup Drive restoration through the runtime fixture. A cleanup command that outlives command_timeout_ms still succeeds, and a stalled restoration names the fixed 5000 ms grace. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(browser): name the effective source-map decode deadline codecBudgetLimitation reported a fixed 20-second deadline after a caller selected decode_timeout_ms. Result and Evidence limitations now name that effective millisecond deadline. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(browser): separate handshake and target-attach deadlines A browser-level Target.attachToTarget no longer shares the WebSocket handshake budget. Blocked service-worker attachment keeps discovery on its fixed deadline and applies command_timeout_ms to the later handshake and attach. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(browser): name the web-bundle resource each runtime exhausts Bun ignores the V8 old-space flag. A Windows Job reports the process commit limit, and other Bun hosts no longer suggest raising max_heap_mb. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(browser): cite the browser-socket discovery message budget WebSocket discovery enforces the 64 MiB protocol budget. Timeout recovery no longer advertises the 8 MiB HTTP document budget. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(browser): bound the blocked service-worker check before discovery The first registration check has its own fixed 10s deadline. Discovery, handshake, and Target.attachToTarget stay on their separate deadlines. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(browser): name the operand that stopped web-bundle admission Host heap pressure no longer claims the fixed result budget is exhausted. A Windows Bun allocation before the commit limit still names that limit. Co-authored-by: morluto <morluto@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: morluto <morluto@users.noreply.github.com>
495 lines
15 KiB
TypeScript
495 lines
15 KiB
TypeScript
import { EventEmitter } from "node:events";
|
|
|
|
import { expect, it } from "vitest";
|
|
|
|
import { browserScenarioSchema } from "../domain/browserScenario.js";
|
|
import { BrowserScenarioSecrets } from "./BrowserScenarioSecrets.js";
|
|
import { PlaywrightScenarioStorage } from "./PlaywrightScenarioStorage.js";
|
|
|
|
class FakeCdp extends EventEmitter {
|
|
readonly calls: { readonly method: string; readonly params?: unknown }[] = [];
|
|
storageKey = "https://storage.example.test";
|
|
failDetachCount = 0;
|
|
holdDetach = false;
|
|
resolveDetach: (() => void) | undefined;
|
|
holdMethod: string | undefined;
|
|
heldReply: (() => void) | undefined;
|
|
autoReplyNested = false;
|
|
readonly pendingNested: {
|
|
readonly sessionId: string;
|
|
readonly id: number;
|
|
}[] = [];
|
|
detachCalls = 0;
|
|
|
|
async send(method: string, params?: unknown): Promise<unknown> {
|
|
this.calls.push({ method, params });
|
|
if (method === this.holdMethod) {
|
|
const response =
|
|
method === "Target.getTargetInfo"
|
|
? { targetInfo: { targetId: "root-target" } }
|
|
: method === "Page.addScriptToEvaluateOnNewDocument"
|
|
? { identifier: "late-script" }
|
|
: method === "Debugger.setBreakpointByUrl"
|
|
? { breakpointId: "late-breakpoint" }
|
|
: method === "Storage.getStorageKey"
|
|
? { storageKey: this.storageKey }
|
|
: {};
|
|
return new Promise((resolve) => {
|
|
this.heldReply = () => resolve(response);
|
|
});
|
|
}
|
|
if (method === "Target.getTargetInfo")
|
|
return { targetInfo: { targetId: "root-target" } };
|
|
if (method === "Target.sendMessageToTarget") {
|
|
const request = params as {
|
|
readonly sessionId: string;
|
|
readonly message: string;
|
|
};
|
|
const { id } = JSON.parse(request.message) as { readonly id: number };
|
|
this.pendingNested.push({ sessionId: request.sessionId, id });
|
|
if (this.autoReplyNested) this.replyNested(request.sessionId, id);
|
|
}
|
|
if (method === "Debugger.setBreakpointByUrl")
|
|
return { breakpointId: "breakpoint" };
|
|
if (method === "Page.addScriptToEvaluateOnNewDocument")
|
|
return { identifier: "script" };
|
|
if (method === "Storage.getStorageKey")
|
|
return { storageKey: this.storageKey };
|
|
if (method === "Debugger.evaluateOnCallFrame") return {};
|
|
return {};
|
|
}
|
|
|
|
async detach(): Promise<void> {
|
|
this.detachCalls += 1;
|
|
if (this.failDetachCount > 0) {
|
|
this.failDetachCount -= 1;
|
|
throw new Error("one-shot CDP detach failure");
|
|
}
|
|
if (this.holdDetach)
|
|
await new Promise<void>((resolve) => {
|
|
this.resolveDetach = resolve;
|
|
});
|
|
}
|
|
|
|
releaseHeldReply(): void {
|
|
this.heldReply?.();
|
|
this.heldReply = undefined;
|
|
this.holdMethod = undefined;
|
|
}
|
|
|
|
releaseDetach(): void {
|
|
this.holdDetach = false;
|
|
this.resolveDetach?.();
|
|
this.resolveDetach = undefined;
|
|
}
|
|
|
|
replyNested(sessionId: string, id: number): void {
|
|
queueMicrotask(() =>
|
|
this.emit("Target.receivedMessageFromTarget", {
|
|
sessionId,
|
|
message: JSON.stringify({ id, result: {} }),
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
const scenario = () =>
|
|
browserScenarioSchema.parse({
|
|
browser: { mode: "launch", executable_path: process.execPath },
|
|
start_url: { url: "https://storage.example.test/" },
|
|
actions: [{ step_id: "wait", action: "wait_for_timeout", duration_ms: 1 }],
|
|
storage: {
|
|
local_storage: [
|
|
{
|
|
origin: "https://storage.example.test",
|
|
entries: [
|
|
{
|
|
name: "local-seed",
|
|
value: { source: "literal", value: "local-value" },
|
|
},
|
|
],
|
|
},
|
|
],
|
|
session_storage: [
|
|
{
|
|
origin: "https://storage.example.test",
|
|
entries: [
|
|
{
|
|
name: "session-seed",
|
|
value: { source: "literal", value: "session-value" },
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
const fixture = (cdp: FakeCdp) => {
|
|
const page = new EventEmitter();
|
|
const context = new EventEmitter();
|
|
const browser = new EventEmitter();
|
|
return {
|
|
page,
|
|
context,
|
|
browser,
|
|
contextApi: Object.assign(context, {
|
|
browser: () => browser,
|
|
newCDPSession: async () => cdp,
|
|
}),
|
|
};
|
|
};
|
|
|
|
const waitForCall = async (cdp: FakeCdp, method: string): Promise<void> => {
|
|
for (let attempt = 0; attempt < 50; attempt += 1) {
|
|
if (cdp.calls.some((call) => call.method === method)) return;
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
}
|
|
throw new Error(`CDP method was not called: ${method}`);
|
|
};
|
|
|
|
const createInitializedStorage = async (
|
|
context: ReturnType<typeof fixture>["contextApi"],
|
|
page: ReturnType<typeof fixture>["page"],
|
|
input = scenario(),
|
|
options: { readonly signal?: AbortSignal } = {},
|
|
) => {
|
|
const owner = await PlaywrightScenarioStorage.create(
|
|
{ context: context as never, page: page as never },
|
|
input,
|
|
BrowserScenarioSecrets.resolve(input, {})!,
|
|
options,
|
|
);
|
|
await owner?.initialize(page as never, context as never);
|
|
return owner;
|
|
};
|
|
|
|
const emitSeedPause = (cdp: FakeCdp): void => {
|
|
const addScript = cdp.calls.find(
|
|
({ method }) => method === "Page.addScriptToEvaluateOnNewDocument",
|
|
);
|
|
if (addScript === undefined)
|
|
throw new Error("Storage seed script is missing");
|
|
const sourceUrl = (addScript.params as { readonly worldName: string })
|
|
.worldName;
|
|
cdp.emit("Runtime.executionContextCreated", {
|
|
context: {
|
|
id: 4,
|
|
origin: "https://storage.example.test",
|
|
name: sourceUrl,
|
|
auxData: { frameId: "root-frame" },
|
|
},
|
|
});
|
|
cdp.emit("Debugger.scriptParsed", {
|
|
scriptId: "seed-script",
|
|
executionContextId: 4,
|
|
url: sourceUrl,
|
|
});
|
|
cdp.emit("Debugger.paused", {
|
|
callFrames: [
|
|
{
|
|
callFrameId: "seed-frame",
|
|
location: { scriptId: "seed-script" },
|
|
},
|
|
],
|
|
});
|
|
};
|
|
|
|
it("seeds a browser storage area once before the document script resumes", async () => {
|
|
const cdp = new FakeCdp();
|
|
const { page, contextApi } = fixture(cdp);
|
|
const owner = await createInitializedStorage(contextApi, page);
|
|
expect(owner).toBeDefined();
|
|
|
|
cdp.emit("Debugger.paused", {
|
|
callFrames: [
|
|
{
|
|
callFrameId: "caller-debugger-frame",
|
|
location: { scriptId: "caller-owned-script" },
|
|
},
|
|
],
|
|
});
|
|
await owner?.settle();
|
|
expect(
|
|
cdp.calls.some(({ method }) => method === "Debugger.evaluateOnCallFrame"),
|
|
).toBe(false);
|
|
expect(cdp.calls.some(({ method }) => method === "Debugger.resume")).toBe(
|
|
false,
|
|
);
|
|
|
|
emitSeedPause(cdp);
|
|
await owner?.settle();
|
|
emitSeedPause(cdp);
|
|
await owner?.settle();
|
|
|
|
const evaluations = cdp.calls.filter(
|
|
({ method }) => method === "Debugger.evaluateOnCallFrame",
|
|
);
|
|
expect(evaluations).toHaveLength(1);
|
|
expect(evaluations[0]?.params).toMatchObject({
|
|
callFrameId: "seed-frame",
|
|
expression: expect.stringContaining('"local-seed","local-value"'),
|
|
});
|
|
expect(evaluations[0]?.params).toMatchObject({
|
|
expression: expect.stringContaining('"session-seed","session-value"'),
|
|
});
|
|
expect(
|
|
cdp.calls.findIndex(
|
|
({ method }) => method === "Debugger.evaluateOnCallFrame",
|
|
),
|
|
).toBeLessThan(
|
|
cdp.calls.findIndex(({ method }) => method === "Debugger.resume"),
|
|
);
|
|
await owner?.close();
|
|
});
|
|
|
|
it("seeds session storage once per namespace and StorageKey", async () => {
|
|
const cdp = new FakeCdp();
|
|
const { page, contextApi } = fixture(cdp);
|
|
const base = scenario();
|
|
const input = browserScenarioSchema.parse({
|
|
...base,
|
|
storage: {
|
|
local_storage: [],
|
|
session_storage: base.storage.session_storage,
|
|
},
|
|
});
|
|
const owner = await createInitializedStorage(contextApi, page, input);
|
|
|
|
emitSeedPause(cdp);
|
|
await owner?.settle();
|
|
emitSeedPause(cdp);
|
|
await owner?.settle();
|
|
cdp.storageKey = "https://storage.example.test/^0https://top-level.example";
|
|
emitSeedPause(cdp);
|
|
await owner?.settle();
|
|
|
|
expect(
|
|
cdp.calls.filter(({ method }) => method === "Debugger.evaluateOnCallFrame"),
|
|
).toHaveLength(2);
|
|
await owner?.close();
|
|
});
|
|
|
|
it("retries the owning CDP detach after a failed attempt", async () => {
|
|
const cdp = new FakeCdp();
|
|
const { page, contextApi } = fixture(cdp);
|
|
const owner = await createInitializedStorage(contextApi, page);
|
|
cdp.failDetachCount = 1;
|
|
|
|
await expect(owner?.close(true)).rejects.toMatchObject({
|
|
_tag: "BrowserObservationError",
|
|
reason: "cleanup_failed",
|
|
});
|
|
expect(cdp.detachCalls).toBe(1);
|
|
expect(cdp.listenerCount("Debugger.paused")).toBe(1);
|
|
|
|
await expect(owner?.close(true)).resolves.toEqual({
|
|
loadingStopped: true,
|
|
});
|
|
expect(cdp.detachCalls).toBe(2);
|
|
expect(cdp.listenerCount("Debugger.paused")).toBe(0);
|
|
expect(
|
|
cdp.calls.filter(({ method }) => method === "Page.stopLoading"),
|
|
).toHaveLength(1);
|
|
});
|
|
|
|
it("reports an unconfirmed stop when root initialization is still pending", async () => {
|
|
const cdp = new FakeCdp();
|
|
cdp.holdMethod = "Target.getTargetInfo";
|
|
const { page, contextApi } = fixture(cdp);
|
|
const input = scenario();
|
|
const owner = await PlaywrightScenarioStorage.create(
|
|
{ context: contextApi as never, page: page as never },
|
|
input,
|
|
BrowserScenarioSecrets.resolve(input, {})!,
|
|
);
|
|
expect(owner).toBeDefined();
|
|
const initialization = owner?.initialize(page as never, contextApi as never);
|
|
await waitForCall(cdp, "Target.getTargetInfo");
|
|
|
|
const closing = owner?.close(true);
|
|
cdp.releaseHeldReply();
|
|
await expect(initialization).resolves.toBeUndefined();
|
|
await expect(closing).resolves.toEqual({ loadingStopped: false });
|
|
expect(
|
|
cdp.calls.filter(({ method }) => method === "Page.stopLoading"),
|
|
).toHaveLength(0);
|
|
expect(cdp.detachCalls).toBe(1);
|
|
});
|
|
|
|
it("reports and retains a confirmed navigation stop", async () => {
|
|
const cdp = new FakeCdp();
|
|
const { page, contextApi } = fixture(cdp);
|
|
const owner = await createInitializedStorage(contextApi, page);
|
|
|
|
await expect(owner?.close(true)).resolves.toEqual({
|
|
loadingStopped: true,
|
|
});
|
|
await expect(owner?.close(true)).resolves.toEqual({
|
|
loadingStopped: true,
|
|
});
|
|
expect(
|
|
cdp.calls.filter(({ method }) => method === "Page.stopLoading"),
|
|
).toHaveLength(1);
|
|
});
|
|
|
|
it("does not create storage instrumentation when there are no storage seeds", async () => {
|
|
const cdp = new FakeCdp();
|
|
const { page, contextApi } = fixture(cdp);
|
|
const empty = browserScenarioSchema.parse({
|
|
browser: { mode: "launch", executable_path: process.execPath },
|
|
start_url: { url: "https://storage.example.test/" },
|
|
actions: [{ step_id: "wait", action: "wait_for_timeout", duration_ms: 1 }],
|
|
});
|
|
expect(
|
|
await PlaywrightScenarioStorage.create(
|
|
{ context: contextApi as never, page: page as never },
|
|
empty,
|
|
BrowserScenarioSecrets.resolve(empty, {})!,
|
|
),
|
|
).toBeUndefined();
|
|
expect(cdp.calls).toEqual([]);
|
|
});
|
|
|
|
it("detaches after setup cancellation even while script installation is pending", async () => {
|
|
const cdp = new FakeCdp();
|
|
cdp.holdMethod = "Page.addScriptToEvaluateOnNewDocument";
|
|
const { page, contextApi } = fixture(cdp);
|
|
const input = scenario();
|
|
const controller = new AbortController();
|
|
const owner = await PlaywrightScenarioStorage.create(
|
|
{ context: contextApi as never, page: page as never },
|
|
input,
|
|
BrowserScenarioSecrets.resolve(input, {})!,
|
|
{ signal: controller.signal },
|
|
);
|
|
expect(owner).toBeDefined();
|
|
const initialization = owner?.initialize(page as never, contextApi as never);
|
|
await waitForCall(cdp, "Page.addScriptToEvaluateOnNewDocument");
|
|
controller.abort();
|
|
await expect(initialization).rejects.toMatchObject({
|
|
_tag: "AnalysisCancelledError",
|
|
});
|
|
|
|
await owner?.close();
|
|
expect(cdp.detachCalls).toBe(1);
|
|
expect(
|
|
cdp.calls.some(
|
|
({ method }) => method === "Page.removeScriptToEvaluateOnNewDocument",
|
|
),
|
|
).toBe(false);
|
|
cdp.releaseHeldReply();
|
|
});
|
|
|
|
it("cancels a pending storage-key reply and still releases its CDP owner", async () => {
|
|
const cdp = new FakeCdp();
|
|
const { page, contextApi } = fixture(cdp);
|
|
const input = scenario();
|
|
const controller = new AbortController();
|
|
const owner = await createInitializedStorage(contextApi, page, input, {
|
|
signal: controller.signal,
|
|
});
|
|
expect(owner).toBeDefined();
|
|
cdp.holdMethod = "Storage.getStorageKey";
|
|
emitSeedPause(cdp);
|
|
const settling = owner?.settle();
|
|
await waitForCall(cdp, "Storage.getStorageKey");
|
|
controller.abort();
|
|
await expect(settling).rejects.toMatchObject({
|
|
_tag: "AnalysisCancelledError",
|
|
});
|
|
|
|
cdp.releaseHeldReply();
|
|
await owner?.close();
|
|
expect(cdp.detachCalls).toBe(1);
|
|
});
|
|
|
|
it("bounds a stalled child CDP reply by command_timeout_ms", async () => {
|
|
const cdp = new FakeCdp();
|
|
const { page, contextApi } = fixture(cdp);
|
|
const input = browserScenarioSchema.parse({
|
|
...scenario(),
|
|
command_timeout_ms: 40,
|
|
});
|
|
const started = performance.now();
|
|
const owner = await createInitializedStorage(contextApi, page, input);
|
|
expect(owner).toBeDefined();
|
|
cdp.emit("Target.attachedToTarget", {
|
|
sessionId: "iframe-session",
|
|
targetInfo: { type: "iframe", targetId: "iframe-target" },
|
|
});
|
|
await expect(owner?.settle()).rejects.toMatchObject({
|
|
_tag: "BrowserObservationError",
|
|
operation: "capture_browser_scenario",
|
|
reason: "timeout",
|
|
userMessage: expect.stringContaining(
|
|
"CDP command Page.enable exceeded its 40 ms command_timeout_ms",
|
|
),
|
|
remediationAction: expect.stringContaining(
|
|
"Set command_timeout_ms in the capture-browser-scenario JSON above 40",
|
|
),
|
|
});
|
|
expect(performance.now() - started).toBeLessThan(4_000);
|
|
await owner?.close();
|
|
});
|
|
|
|
it("releases the CDP owner when an attached target reply remains pending", async () => {
|
|
const cdp = new FakeCdp();
|
|
const { page, contextApi } = fixture(cdp);
|
|
const input = scenario();
|
|
const owner = await createInitializedStorage(contextApi, page, input);
|
|
expect(owner).toBeDefined();
|
|
cdp.emit("Target.attachedToTarget", {
|
|
sessionId: "iframe-session",
|
|
targetInfo: { type: "iframe", targetId: "iframe-target" },
|
|
});
|
|
await waitForCall(cdp, "Target.sendMessageToTarget");
|
|
await owner?.close();
|
|
expect(cdp.detachCalls).toBe(1);
|
|
});
|
|
|
|
it("does not fail root storage capture when an OOPIF detaches during setup", async () => {
|
|
const cdp = new FakeCdp();
|
|
const { page, contextApi } = fixture(cdp);
|
|
const owner = await createInitializedStorage(contextApi, page);
|
|
expect(owner).toBeDefined();
|
|
cdp.emit("Target.attachedToTarget", {
|
|
sessionId: "navigated-away-iframe",
|
|
targetInfo: { type: "iframe", targetId: "iframe-target" },
|
|
});
|
|
await waitForCall(cdp, "Target.sendMessageToTarget");
|
|
|
|
cdp.emit("Target.detachedFromTarget", {
|
|
sessionId: "navigated-away-iframe",
|
|
});
|
|
await owner?.settle();
|
|
|
|
cdp.autoReplyNested = true;
|
|
emitSeedPause(cdp);
|
|
await owner?.settle();
|
|
expect(
|
|
cdp.calls.some(({ method }) => method === "Debugger.evaluateOnCallFrame"),
|
|
).toBe(true);
|
|
await owner?.close();
|
|
expect(cdp.detachCalls).toBe(1);
|
|
});
|
|
|
|
it("retains the exact in-flight detach after the bounded wait expires", async () => {
|
|
const cdp = new FakeCdp();
|
|
const { page, contextApi } = fixture(cdp);
|
|
const owner = await createInitializedStorage(contextApi, page);
|
|
expect(owner).toBeDefined();
|
|
cdp.holdDetach = true;
|
|
|
|
await expect(owner?.close()).rejects.toMatchObject({
|
|
_tag: "BrowserObservationError",
|
|
reason: "cleanup_failed",
|
|
});
|
|
expect(cdp.detachCalls).toBe(1);
|
|
const retry = owner?.close();
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
expect(cdp.detachCalls).toBe(1);
|
|
cdp.releaseDetach();
|
|
await retry;
|
|
expect(cdp.detachCalls).toBe(1);
|
|
});
|