From 57a23f0a2da0fdc3835e203718ded8acea5a34e6 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Thu, 15 Oct 2026 03:06:19 +0800 Subject: [PATCH] fix(browser)!: expose capture budgets and queue admitted content reads BREAKING CHANGE: Browser capture results require resolved limits, event families, and omission facts. Caller-selected budgets replace hidden retention ceilings; admitted content reads queue behind bounded concurrency. --- src/browser/BrowserScenario.test.ts | 6 +- .../BrowserScenarioPartialObservation.ts | 1 + src/browser/BrowserScenarioSessionPort.ts | 2 + src/browser/CdpBrowserProvider.ts | 8 +- .../PlaywrightBrowserScenarioProvider.test.ts | 18 +- src/browser/PlaywrightScenarioEvents.test.ts | 119 +++++- src/browser/PlaywrightScenarioEvents.ts | 260 ++++++++--- .../PlaywrightScenarioServiceWorkers.ts | 20 +- src/browser/PlaywrightScenarioSession.test.ts | 21 +- src/browser/PlaywrightScenarioSession.ts | 1 + src/browser/WebSourceMapFetcher.test.ts | 36 ++ src/browser/WebSourceMapFetcher.ts | 12 + .../network/PlaywrightNetworkContent.ts | 85 +++- .../network/PlaywrightScenarioNetwork.test.ts | 252 ++++++++++- .../network/PlaywrightScenarioNetwork.ts | 404 ++++++++++++++---- src/contracts/browserScenarioToolContracts.ts | 2 +- src/contracts/browserToolContracts.ts | 13 +- src/domain/analysisErrorPresentation.ts | 10 +- src/domain/analysisErrorProjection.test.ts | 43 +- src/domain/analysisErrorProjection.ts | 1 + src/domain/browserNetworkEvidence.ts | 24 +- src/domain/browserObservationError.ts | 2 + src/domain/browserObservationErrors.ts | 1 + src/domain/browserScenario.test.ts | 28 ++ src/domain/browserScenarioCapture.test.ts | 120 +++++- src/domain/browserScenarioCapture.ts | 17 +- src/domain/browserScenarioCaptureLimits.ts | 46 ++ src/domain/browserScenarioCaptureValues.ts | 68 +++ src/domain/browserScenarioDiff.test.ts | 47 +- src/domain/browserScenarioDiffEvidence.ts | 4 +- src/domain/browserScenarioValues.ts | 8 + src/domain/webBundleAnalyzer.test.ts | 14 + src/native/LldbCallTracer.ts | 2 +- ...cdpBrowserProvider.document_script.test.ts | 7 + tests/boundary/cli/browserCli.test.ts | 18 +- tests/boundary/mcp/browserScenarioMcp.test.ts | 12 +- tests/fixtures/webScriptCapture.ts | 13 +- .../browserScenarioCleanup.test.ts | 11 +- 38 files changed, 1535 insertions(+), 221 deletions(-) create mode 100644 src/browser/WebSourceMapFetcher.test.ts create mode 100644 src/domain/browserScenarioCaptureLimits.ts diff --git a/src/browser/BrowserScenario.test.ts b/src/browser/BrowserScenario.test.ts index ee736ee5ec..d1108b999a 100644 --- a/src/browser/BrowserScenario.test.ts +++ b/src/browser/BrowserScenario.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { BrowserScenarioSecrets } from "./BrowserScenarioSecrets.js"; import { browserScenarioSchema } from "../domain/browserScenario.js"; +import { DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS } from "../domain/browserScenarioCaptureLimits.js"; const secret = (secret_id: string) => ({ source: "secret", secret_id }); const literal = (value: string) => ({ @@ -136,6 +137,7 @@ describe("browserScenarioSchema", () => { service_workers: "block", }); expect(parsed.capture).toEqual({ + limits: DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS, network: { request_body: false, response_body: false, @@ -145,7 +147,9 @@ describe("browserScenarioSchema", () => { at_end: ["dom", "storage"], events: ["console", "page-errors", "network", "websockets"], }); - expect(parsed).not.toHaveProperty("limits"); + expect(parsed.capture.limits).toEqual( + DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS, + ); }); it("allows callers to choose visible browsers and service workers", () => { diff --git a/src/browser/BrowserScenarioPartialObservation.ts b/src/browser/BrowserScenarioPartialObservation.ts index a573f600fd..9914d0a326 100644 --- a/src/browser/BrowserScenarioPartialObservation.ts +++ b/src/browser/BrowserScenarioPartialObservation.ts @@ -90,6 +90,7 @@ export const browserScenarioCaptureData = (input: { .sort(), network_content: input.scenario.capture.network, event_families: [...new Set(input.scenario.capture.events)].sort(), + limits: input.scenario.capture.limits, }, duration_ms: Math.max(0, Math.round(performance.now() - input.startedAt)), steps: [...input.steps], diff --git a/src/browser/BrowserScenarioSessionPort.ts b/src/browser/BrowserScenarioSessionPort.ts index de54ee33d2..d0cf7f3a78 100644 --- a/src/browser/BrowserScenarioSessionPort.ts +++ b/src/browser/BrowserScenarioSessionPort.ts @@ -2,6 +2,7 @@ import type { BrowserScenario } from "../domain/browserScenario.js"; import type { BrowserScenarioAction } from "../domain/browserScenarioValues.js"; import type { BrowserScenarioEvent, + BrowserScenarioEventOmissions, BrowserStepArtifacts, } from "../domain/browserScenarioCaptureValues.js"; import type { SanitizedBrowserUrl } from "../domain/browserObservation.js"; @@ -23,6 +24,7 @@ export interface BrowserScenarioSessionPort { readonly retained: number; readonly dropped: number; readonly items: readonly BrowserScenarioEvent[]; + readonly omissions: BrowserScenarioEventOmissions; }; /** Gaps in selected event families that prevent complete capture claims. */ eventLimitations?(): readonly string[]; diff --git a/src/browser/CdpBrowserProvider.ts b/src/browser/CdpBrowserProvider.ts index 65cb6eb044..64c4815f1f 100644 --- a/src/browser/CdpBrowserProvider.ts +++ b/src/browser/CdpBrowserProvider.ts @@ -73,7 +73,13 @@ import { comparePngScreenshots } from "./PngVisualDiff.js"; import { CDP_BROWSER_PROVIDER_IDENTITY } from "./providerIdentities.js"; import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; -const CLEANUP_DOMAINS = ["Network", "Debugger", "Runtime", "Page"] as const; +const CLEANUP_DOMAINS = [ + "Network", + "Debugger", + "Runtime", + "Page", + "WebMCP", +] as const; /** Passive browser observation through a selected user-owned CDP endpoint. */ export class CdpBrowserProvider implements BrowserObservationPort { diff --git a/src/browser/PlaywrightBrowserScenarioProvider.test.ts b/src/browser/PlaywrightBrowserScenarioProvider.test.ts index 734e32426b..d652cc7136 100644 --- a/src/browser/PlaywrightBrowserScenarioProvider.test.ts +++ b/src/browser/PlaywrightBrowserScenarioProvider.test.ts @@ -145,15 +145,16 @@ class FakeSession implements BrowserScenarioSessionPort { return 0; } - events(): { - readonly retained: number; - readonly dropped: number; - readonly items: readonly BrowserScenarioEvent[]; - } { + events(): ReturnType { return { retained: this.eventItems.length, dropped: 0, items: this.eventItems, + omissions: { + by_kind: [], + network_transactions: [], + network_transaction_range: null, + }, }; } @@ -559,9 +560,16 @@ describe("PlaywrightBrowserScenarioProvider operation failures", () => { }, steps: [{ step_id: "scenario_start" }], events: { + retained: 1, + dropped: 0, items: [ expect.objectContaining({ text: "navigation started" }), ], + omissions: { + by_kind: [], + network_transactions: [], + network_transaction_range: null, + }, }, }, }, diff --git a/src/browser/PlaywrightScenarioEvents.test.ts b/src/browser/PlaywrightScenarioEvents.test.ts index 4c0e21d30b..c813d3f290 100644 --- a/src/browser/PlaywrightScenarioEvents.test.ts +++ b/src/browser/PlaywrightScenarioEvents.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import type { Page } from "playwright-core"; +import type { BrowserContext, Page, Request, Response } from "playwright-core"; import { browserScenarioSchema } from "../domain/browserScenario.js"; import { BrowserScenarioSecrets } from "./BrowserScenarioSecrets.js"; @@ -76,7 +76,124 @@ describe("declared secrets in scenario events", () => { }); }); +const captureNetworkBudgetFixture = async ( + maxTotalEventAllocationBytes?: number, +) => { + const pageListeners = new Map void>(); + const contextListeners = new Map void>(); + const context = { + on: (name: string, listener: (value: unknown) => void) => + contextListeners.set(name, listener), + off: vi.fn(), + } as unknown as BrowserContext; + const page = { + url: () => "http://127.0.0.1/fixture", + context: () => context, + on: (name: string, listener: (value: unknown) => void) => + pageListeners.set(name, listener), + off: vi.fn(), + } as unknown as Page; + const scenario = browserScenarioSchema.parse({ + browser: { mode: "launch", executable_path: "/fixture/chrome" }, + start_url: { url: "http://127.0.0.1/fixture" }, + actions: [{ step_id: "ready", action: "wait_for_timeout", duration_ms: 1 }], + capture: { + events: ["network"], + network: { response_body: true }, + ...(maxTotalEventAllocationBytes === undefined + ? {} + : { + limits: { + max_total_event_allocation_bytes: maxTotalEventAllocationBytes, + }, + }), + }, + }); + const secrets = BrowserScenarioSecrets.resolve(scenario, {}); + if (secrets === undefined) throw new Error("Expected resolved secrets"); + const redactBytes = vi.spyOn(secrets, "redactBytes"); + const capture = new PlaywrightScenarioEvents({ + page, + context, + ownsContext: true, + enabled: new Set(["network"]), + network: scenario.capture.network, + limits: scenario.capture.limits, + secrets, + }); + for (let index = 0; index < 43; index += 1) { + const post = index >= 40; + const body = post + ? Buffer.from('{"ok":true}') + : Buffer.from("x".repeat(60_000)); + const request = { + method: () => (post ? "POST" : "GET"), + url: () => `http://127.0.0.1/${post ? "final" : "data"}/${String(index)}`, + resourceType: () => "fetch", + postDataBuffer: () => (post ? Buffer.from("{}") : null), + redirectedFrom: () => null, + failure: () => null, + headers: () => ({ "content-type": "application/json" }), + headersArray: async () => [ + { name: "content-type", value: "application/json" }, + ], + } as unknown as Request; + const response = { + request: () => request, + url: () => request.url(), + status: () => 200, + headers: () => ({ "content-type": "application/json" }), + headersArray: async () => [ + { name: "content-type", value: "application/json" }, + ], + body: async () => body, + } as unknown as Response; + contextListeners.get("request")?.(request); + contextListeners.get("response")?.(response); + contextListeners.get("requestfinished")?.(request); + await new Promise((resolve) => setTimeout(resolve, 0)); + } + await capture.finish(); + return { + result: capture.result(), + redactedByteValues: redactBytes.mock.calls.length, + }; +}; + describe("PlaywrightScenarioEvents", () => { + it("identifies omitted network transaction content and honors a raised budget", async () => { + const baseline = await captureNetworkBudgetFixture(); + const raised = await captureNetworkBudgetFixture(32 * 1024 * 1024); + expect(baseline.result.dropped).toBeGreaterThan(0); + expect( + baseline.result.omissions.by_kind.find( + ({ kind }) => kind === "network-content", + ), + ).toMatchObject({ + count: expect.any(Number), + reasons: expect.arrayContaining(["max-total-event-allocation-bytes"]), + }); + expect( + baseline.result.omissions.network_transactions.length, + ).toBeGreaterThan(0); + expect(baseline.redactedByteValues).toBe( + 2 * + baseline.result.items.filter( + (event) => event.kind === "network-content", + ).length, + ); + expect(raised.result).toMatchObject({ + dropped: 0, + omissions: { network_transactions: [] }, + }); + expect( + raised.result.items.filter( + (event) => + event.kind === "network-content" && event.phase === "response", + ), + ).toHaveLength(43); + }); + it("observes requested popup page errors independently of popup lifecycle events", () => { const listeners = new Map void>(); const popupListeners = new Map void>(); diff --git a/src/browser/PlaywrightScenarioEvents.ts b/src/browser/PlaywrightScenarioEvents.ts index 807d1ae70d..ef1cb14d16 100644 --- a/src/browser/PlaywrightScenarioEvents.ts +++ b/src/browser/PlaywrightScenarioEvents.ts @@ -17,6 +17,14 @@ import { } from "../domain/browserScenarioCaptureValues.js"; import type { BrowserScenarioSecrets } from "./BrowserScenarioSecrets.js"; import type { BrowserNetworkContentSelection } from "../domain/browserNetworkEvidence.js"; +import type { BrowserNetworkHeaders } from "../domain/browserNetworkEvidence.js"; +import type { BrowserScenarioCaptureLimits } from "../domain/browserScenarioCaptureLimits.js"; +import type { + BrowserScenarioEventAdmission, + BrowserScenarioEventBudgetReason, + BrowserScenarioEventOmissions, +} from "../domain/browserScenarioCaptureValues.js"; +import { DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS } from "../domain/browserScenarioCaptureLimits.js"; import { PlaywrightScenarioNetwork } from "./network/PlaywrightScenarioNetwork.js"; type EventName = @@ -43,14 +51,12 @@ interface EventCaptureOptions { readonly enabled: ReadonlySet; readonly secrets: BrowserScenarioSecrets; readonly network?: BrowserNetworkContentSelection; + readonly limits?: BrowserScenarioCaptureLimits; readonly signal?: AbortSignal; } // Conservative allocation accounting includes UTF-16 storage and worst-case -// JSON escaping. Reserve before schema cloning; never serialize to measure. -const MAX_EVENT_COST = 4 * 1024 * 1024; -const MAX_CAPTURE_COST = 16 * 1024 * 1024; -const MAX_EVENTS = 10_000; +// JSON escaping. Check before schema cloning; never serialize to measure. const MAX_LISTENER_OWNERS = 2048; const allocationCost = (value: unknown, remaining: number): number => { if (typeof value === "string") return value.length * 8 + 2; @@ -65,15 +71,28 @@ const allocationCost = (value: unknown, remaining: number): number => { return cost; }; +type EventKind = BrowserScenarioEvent["kind"]; /** Arrival-ordered Playwright event capture with current-step attribution. */ export class PlaywrightScenarioEvents { private readonly items: BrowserScenarioEvent[] = []; private retainedCost = 0; private dropped = 0; + private readonly omittedKinds = new Map< + EventKind, + { count: number; reasons: Set } + >(); + private readonly omittedNetwork = new Map< + string, + { + readonly kinds: Set; + readonly reasons: Set; + } + >(); private stepIndex = 0; private sequence = 0; private readonly enabled: ReadonlySet; private readonly secrets: BrowserScenarioSecrets; + private readonly limits: BrowserScenarioCaptureLimits; private readonly ownsContext: boolean; private pages = new WeakSet(); private readonly incompleteFamilies = new Set(); @@ -92,6 +111,7 @@ export class PlaywrightScenarioEvents { if (network.request_body || network.response_body || network.header_values) this.enabled = new Set([...this.enabled, "network"]); this.secrets = options.secrets; + this.limits = options.limits ?? DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS; this.ownsContext = options.ownsContext === true; this.pages.add(options.page); if (this.ownsContext && this.enabled.size > 0) { @@ -104,9 +124,13 @@ export class PlaywrightScenarioEvents { const context = options.context ?? options.page.context(); const collector = new PlaywrightScenarioNetwork({ selection: network, + limits: this.limits, secrets: options.secrets, emit: (event) => this.push(event), - admitText: (values) => this.admitProducerText(values), + admitText: (kind, values) => this.admitProducerText(kind, values, true), + admitContentBody: (input) => this.admitContentBody(input), + omitNetwork: (id, kind, reason) => this.omitNetwork(id, kind, reason), + omitEvent: (kind, reason) => this.omitEvent(kind, reason), ...(options.signal === undefined ? {} : { signal: options.signal }), }); this.network = collector; @@ -181,7 +205,7 @@ export class PlaywrightScenarioEvents { const gaps = [...popupGaps, ...(this.network?.limitations() ?? [])]; if (this.dropped > 0) gaps.push( - "Browser event collection reached its resource budget; omitted events and descendant observations are unknown.", + "Browser event collection reached a selected resource budget; per-kind omissions and affected network transaction IDs are listed in events.omissions, and event coverage is incomplete.", ); return this.enabled.has("network") && !this.ownsContext ? [ @@ -195,25 +219,55 @@ export class PlaywrightScenarioEvents { readonly retained: number; readonly dropped: number; readonly items: readonly BrowserScenarioEvent[]; + readonly omissions: BrowserScenarioEventOmissions; } { + const omittedKinds = new Map( + [...this.omittedKinds].map( + ([kind, omission]) => + [ + kind, + { count: omission.count, reasons: new Set(omission.reasons) }, + ] as const, + ), + ); return { retained: this.items.length, dropped: this.dropped, items: this.items, + omissions: { + by_kind: [...omittedKinds] + .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) + .map(([kind, omission]) => ({ + kind, + count: omission.count, + reasons: [...omission.reasons].sort(), + })), + network_transactions: [...this.omittedNetwork] + .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) + .map(([transaction_id, omission]) => ({ + transaction_id, + omitted_kinds: [...omission.kinds].sort(), + reasons: [...omission.reasons].sort(), + })), + network_transaction_range: + this.network?.omittedTransactionRange() ?? null, + }, }; } - private push(event: UnindexedEvent): number { - if (this.sealed) return 0; + private push(event: UnindexedEvent): BrowserScenarioEventAdmission { this.sequence += 1; - if (this.items.length >= MAX_EVENTS) { - this.dropped += 1; - return 0; - } - const cost = allocationCost(event, MAX_EVENT_COST) + 128; - if (cost > MAX_EVENT_COST || this.retainedCost + cost > MAX_CAPTURE_COST) { - this.dropped += 1; - return 0; + const cost = + allocationCost(event, this.limits.max_event_allocation_bytes) + 128; + const reason = this.admissionReason(cost); + if (this.sealed || reason !== undefined) { + const finalReason = reason ?? "max-events"; + this.omitEvent(event.kind, finalReason); + return { + status: "omitted", + sequence: this.sequence, + reason: finalReason, + }; } this.retainedCost += cost; const parsed = browserScenarioEventSchema.parse({ @@ -222,44 +276,134 @@ export class PlaywrightScenarioEvents { step_index: this.stepIndex, }); this.items.push(parsed); - return this.sequence; + return { status: "retained", sequence: this.sequence }; } - private admitText(...values: readonly string[]): boolean { - return this.admitProducerText(values); + private admitText(kind: EventKind, ...values: readonly string[]): boolean { + return this.admitProducerText(kind, values, false).status === "retained"; } /** Admit conservative output cost before running redaction or cloning. */ - private admitProducerText(values: Iterable): boolean { - if (this.sealed) return false; + private admitProducerText( + kind: EventKind, + values: Iterable, + networkMetadata: boolean, + ): BrowserScenarioEventAdmission { let cost = 4096; for (const value of values) { cost += this.secrets.redactionAllocationCost(value.length); - if ( - cost > MAX_EVENT_COST || - cost > MAX_CAPTURE_COST - this.retainedCost - ) { - this.sequence += 1; - this.dropped += 1; - return false; - } } - if ( - this.items.length >= MAX_EVENTS || - cost > MAX_EVENT_COST || - cost > MAX_CAPTURE_COST - this.retainedCost - ) { + const reason = + networkMetadata && + cost > this.limits.max_network_metadata_allocation_bytes + ? "max-network-metadata-allocation-bytes" + : this.admissionReason(cost); + if (reason !== undefined) { this.sequence += 1; - this.dropped += 1; - return false; + this.omitEvent(kind, reason); + return { status: "omitted", sequence: this.sequence, reason }; } - return true; + return { status: "retained", sequence: this.sequence + 1 }; } - private admitOwner(): boolean { - if (this.sealed) return false; - if (this.stopListeners.size < MAX_LISTENER_OWNERS) return true; + private admissionReason( + cost: number, + ): BrowserScenarioEventBudgetReason | undefined { + if (this.sealed || this.items.length >= this.limits.max_events) + return "max-events"; + if (cost > this.limits.max_event_allocation_bytes) + return "max-event-allocation-bytes"; + if (cost > this.limits.max_total_event_allocation_bytes - this.retainedCost) + return "max-total-event-allocation-bytes"; + return undefined; + } + + private admitContentBody(input: { + readonly transactionId: string; + readonly sourceEventSequence: number; + readonly phase: "request" | "response"; + readonly headers: BrowserNetworkHeaders; + readonly observedBytes: number; + readonly allocationBytes: number; + readonly mediaType: string | undefined; + }): BrowserScenarioEventAdmission { + const kind = "network-content"; + const eventCost = + allocationCost( + { + kind, + sequence: this.sequence + 1, + step_index: this.stepIndex, + transaction_id: input.transactionId, + source_event_sequence: input.sourceEventSequence, + phase: input.phase, + headers: input.headers, + body: { + state: "captured", + representation: + input.phase === "request" + ? "browser-exposed-request-bytes" + : "playwright-response-body-bytes", + encoding: "base64", + content: null, + bytes: input.observedBytes, + sha256: "0".repeat(64), + media_type: null, + redacted: false, + }, + }, + this.limits.max_event_allocation_bytes, + ) - + 64 + + input.allocationBytes + + (input.mediaType === undefined + ? 0 + : Math.max( + input.mediaType.length * 8 + 2, + this.secrets.redactionAllocationCost(input.mediaType.length), + )) + + 128; + const reason = this.admissionReason(eventCost); + if (reason !== undefined) { + this.sequence += 1; + this.omitEvent(kind, reason); + return { status: "omitted", sequence: this.sequence, reason }; + } + return { status: "retained", sequence: this.sequence + 1 }; + } + + private omitEvent( + kind: EventKind, + reason: BrowserScenarioEventBudgetReason, + ): void { this.dropped += 1; + let omission = this.omittedKinds.get(kind); + if (omission === undefined) { + omission = { count: 0, reasons: new Set() }; + this.omittedKinds.set(kind, omission); + } + omission.count += 1; + omission.reasons.add(reason); + } + + private omitNetwork( + transactionId: string, + kind: EventKind, + reason: BrowserScenarioEventBudgetReason, + ): void { + let omission = this.omittedNetwork.get(transactionId); + if (omission === undefined) { + omission = { kinds: new Set(), reasons: new Set() }; + this.omittedNetwork.set(transactionId, omission); + } + omission.kinds.add(kind); + omission.reasons.add(reason); + } + + private admitOwner(kind: EventKind): boolean { + if (this.stopListeners.size < MAX_LISTENER_OWNERS) return true; + this.sequence += 1; + this.omitEvent(kind, "max-listener-owners"); return false; } @@ -294,7 +438,8 @@ export class PlaywrightScenarioEvents { } if (this.enabled.has("page-errors")) { const onError = (error: Error) => { - if (!this.admitText(error.message, error.stack ?? "")) return; + if (!this.admitText("page-error", error.message, error.stack ?? "")) + return; this.push({ kind: "page-error", message: this.secrets.redact(error.message), @@ -348,7 +493,8 @@ export class PlaywrightScenarioEvents { private console(message: ConsoleMessage): void { const rawText = message.text(); const location = message.location(); - if (!this.admitText(rawText, location.url, message.type())) return; + if (!this.admitText("console", rawText, location.url, message.type())) + return; this.push({ kind: "console", level: this.secrets.redact(message.type()), @@ -358,8 +504,8 @@ export class PlaywrightScenarioEvents { } private webSocket(socket: WebSocket): void { - if (!this.admitOwner()) return; - if (!this.admitText(socket.url())) return; + if (!this.admitOwner("websocket-opened")) return; + if (!this.admitText("websocket-opened", socket.url())) return; const url = this.safeUrl(socket.url()); this.push({ kind: "websocket-opened", url }); const onSent = ({ payload }: { payload: string | Buffer }) => @@ -396,7 +542,7 @@ export class PlaywrightScenarioEvents { }); return; } - if (!this.admitText(payload)) return; + if (!this.admitText(kind, payload)) return; const text = this.secrets.redact(payload); this.push({ kind, @@ -412,7 +558,7 @@ export class PlaywrightScenarioEvents { kind: "frame-attached" | "frame-detached" | "frame-navigated", frame: Frame, ): void { - if (!this.admitText(frame.url(), frame.name())) return; + if (!this.admitText(kind, frame.url(), frame.name())) return; this.push({ kind, url: frame.url() === "" ? null : this.safeUrl(frame.url()), @@ -421,7 +567,11 @@ export class PlaywrightScenarioEvents { } private worker(worker: Worker): void { - if (!this.admitOwner() || !this.admitText(worker.url())) return; + if ( + !this.admitOwner("worker-created") || + !this.admitText("worker-created", worker.url()) + ) + return; const details = { url: this.safeUrl(worker.url()), name: null, @@ -437,7 +587,11 @@ export class PlaywrightScenarioEvents { private popup(page: Page): void { if (this.pages.has(page)) return; - if (!this.admitOwner() || !this.admitText(page.url())) return; + if ( + !this.admitOwner("popup-opened") || + !this.admitText("popup-opened", page.url()) + ) + return; this.pages.add(page); for (const family of [ "frames", @@ -453,7 +607,7 @@ export class PlaywrightScenarioEvents { if (this.enabled.has("popups")) { this.push({ kind: "popup-opened", ...opened }); const onClose = () => { - if (!this.admitText(page.url())) return; + if (!this.admitText("popup-closed", page.url())) return; this.push({ kind: "popup-closed", url: page.url() === "" ? null : this.safeUrl(page.url()), @@ -467,7 +621,13 @@ export class PlaywrightScenarioEvents { } private download(download: Download): void { - if (this.admitText(download.suggestedFilename(), download.url())) + if ( + this.admitText( + "download-cancelled", + download.suggestedFilename(), + download.url(), + ) + ) this.push({ kind: "download-cancelled", suggested_filename: this.secrets.redact(download.suggestedFilename()), diff --git a/src/browser/PlaywrightScenarioServiceWorkers.ts b/src/browser/PlaywrightScenarioServiceWorkers.ts index 4963fbf5df..35f1460a3f 100644 --- a/src/browser/PlaywrightScenarioServiceWorkers.ts +++ b/src/browser/PlaywrightScenarioServiceWorkers.ts @@ -207,23 +207,29 @@ export class PlaywrightScenarioServiceWorkers { async assertBlocked(): Promise { if (this.#session !== undefined) await this.settle(); for (const frame of this.page.frames()) { - let violatesPolicy: unknown; + let failure: unknown; try { - violatesPolicy = await frame.evaluate(`(async () => { - if (!("serviceWorker" in navigator)) return false; + failure = await frame.evaluate(`(async () => { + if (!("serviceWorker" in navigator)) return null; const serviceWorker = navigator.serviceWorker; if (${this.#session !== undefined} && (!Object.hasOwn(serviceWorker.register, Symbol.for(${this.marker})) || - typeof globalThis[${this.restoreKey}] !== "function")) return true; - if (serviceWorker.controller !== null) return true; - return (await serviceWorker.getRegistrations()).length > 0; + typeof globalThis[${this.restoreKey}] !== "function")) return "policy_unprotected"; + if (serviceWorker.controller !== null) return "service_worker_blocked"; + return (await serviceWorker.getRegistrations()).length > 0 + ? "service_worker_blocked" + : null; })()`); } catch (cause: unknown) { if (frame.isDetached()) continue; throw cause; } - if (violatesPolicy) + if (failure === "policy_unprotected") throw new BrowserObservationError(OPERATION, "target_not_allowed"); + if (failure === "service_worker_blocked") + throw new BrowserObservationError(OPERATION, "service_worker_blocked"); + if (failure !== null) + throw new BrowserObservationError(OPERATION, "protocol_error"); } } diff --git a/src/browser/PlaywrightScenarioSession.test.ts b/src/browser/PlaywrightScenarioSession.test.ts index d279207731..366e4a78a8 100644 --- a/src/browser/PlaywrightScenarioSession.test.ts +++ b/src/browser/PlaywrightScenarioSession.test.ts @@ -65,7 +65,16 @@ vi.mock("./PlaywrightScenarioEvents.js", () => ({ } result() { - return { events: [] }; + return { + retained: 0, + dropped: 0, + items: [], + omissions: { + by_kind: [], + network_transactions: [], + network_transaction_range: null, + }, + }; } limitations() { @@ -105,7 +114,7 @@ const fixture = () => { const page = Object.assign(new EventEmitter(), { url: () => "about:blank", goto: vi.fn(async () => undefined), - evaluate: vi.fn(async (_script: unknown) => false), + evaluate: vi.fn(async (_script: unknown): Promise => null), addInitScript: vi.fn(async (_script: unknown) => undefined), context: () => context, frames: () => [page], @@ -190,7 +199,9 @@ it("stops attached navigation without storage and retries rejected policy cleanu it("rejects an init script accepted by CDP when the live realm is unprotected", async () => { const { page, cleanup } = fixture(); - page.evaluate.mockResolvedValueOnce(false).mockResolvedValueOnce(true); + page.evaluate + .mockResolvedValueOnce(null) + .mockResolvedValueOnce("policy_unprotected"); const attached = browserScenarioSchema.parse({ ...scenario, browser: { @@ -484,7 +495,7 @@ it("waits for a pending registration installation before cancelled cleanup", asy it("rejects attached block when caller-owned registrations already exist", async () => { const { cleanup, page } = fixture(); - page.evaluate.mockResolvedValueOnce(true); + page.evaluate.mockResolvedValueOnce("service_worker_blocked"); const attached = browserScenarioSchema.parse({ ...scenario, browser: { @@ -499,7 +510,7 @@ it("rejects attached block when caller-owned registrations already exist", async PlaywrightScenarioSession.open(attached, {}, {}), ).rejects.toMatchObject({ _tag: "BrowserObservationError", - reason: "target_not_allowed", + reason: "service_worker_blocked", }); expect(page.addInitScript).not.toHaveBeenCalled(); expect(cleanup.close).toHaveBeenCalledTimes(1); diff --git a/src/browser/PlaywrightScenarioSession.ts b/src/browser/PlaywrightScenarioSession.ts index 88e6a438d2..80537cbcdc 100644 --- a/src/browser/PlaywrightScenarioSession.ts +++ b/src/browser/PlaywrightScenarioSession.ts @@ -247,6 +247,7 @@ export class PlaywrightScenarioSession implements BrowserScenarioSessionPort { enabled: new Set(scenario.capture.events), secrets, network: scenario.capture.network, + limits: scenario.capture.limits, ...(options.signal === undefined ? {} : { signal: options.signal }), }); return new PlaywrightScenarioSession(opened, { diff --git a/src/browser/WebSourceMapFetcher.test.ts b/src/browser/WebSourceMapFetcher.test.ts new file mode 100644 index 0000000000..dd5071f70e --- /dev/null +++ b/src/browser/WebSourceMapFetcher.test.ts @@ -0,0 +1,36 @@ +import { expect, it } from "vitest"; + +import { fetchWebSourceMaps } from "./WebSourceMapFetcher.js"; +import { analyzeWebBundleInputSchema } from "../domain/webBundleAnalysis.js"; + +const input = analyzeWebBundleInputSchema.parse({ + cdp_endpoint: "http://127.0.0.1:9222", + allowed_origins: ["http://127.0.0.1:9222"], + target_id: "page-1", + fetch_source_maps: true, +}); + +it("reports an empty selected request set without starting fetch work", async () => { + const result = await fetchWebSourceMaps([], input, undefined, { + fetch: async () => { + throw new Error("No source-map request should be made"); + }, + }); + + expect(result).toEqual({ + status: "not_requested", + requested: 0, + processed: 0, + items: [], + }); +}); + +it("honors cancellation before returning an empty request set", async () => { + const controller = new AbortController(); + const reason = new Error("cancelled before fetching"); + controller.abort(reason); + + await expect(fetchWebSourceMaps([], input, controller.signal)).rejects.toBe( + reason, + ); +}); diff --git a/src/browser/WebSourceMapFetcher.ts b/src/browser/WebSourceMapFetcher.ts index 0148cb9a29..567ed4aa97 100644 --- a/src/browser/WebSourceMapFetcher.ts +++ b/src/browser/WebSourceMapFetcher.ts @@ -81,6 +81,14 @@ export const fetchWebSourceMaps = async ( signal?: AbortSignal, host: SourceMapFetchHost = { fetch: globalThis.fetch }, ): Promise => { + throwIfAborted(signal); + if (requests.length === 0) + return webSourceMapsSchema.parse({ + status: "not_requested", + requested: 0, + processed: 0, + items: [], + }); const items: SourceMapItem[] = []; const operationController = new AbortController(); const timeout = setTimeout( @@ -170,6 +178,10 @@ export const fetchWebSourceMaps = async ( }); }; +const throwIfAborted = (signal?: AbortSignal): void => { + if (signal?.aborted === true) throw signal.reason; +}; + const sourceMapOutputLimitation = (unprocessed: number): string => `source-map output budget could not retain results for ${String(unprocessed)} requested map${unprocessed === 1 ? "" : "s"}. their results are unknown.`; diff --git a/src/browser/network/PlaywrightNetworkContent.ts b/src/browser/network/PlaywrightNetworkContent.ts index 390831030f..cff6df47ec 100644 --- a/src/browser/network/PlaywrightNetworkContent.ts +++ b/src/browser/network/PlaywrightNetworkContent.ts @@ -8,6 +8,10 @@ import type { BrowserNetworkBody, BrowserNetworkHeaders, } from "../../domain/browserNetworkEvidence.js"; +import type { + BrowserScenarioEventAdmission, + BrowserScenarioEventBudgetReason, +} from "../../domain/browserScenarioCaptureValues.js"; import type { BrowserScenarioSecrets } from "../BrowserScenarioSecrets.js"; import { withPlaywrightExecutionBoundary } from "../PlaywrightExecutionBoundary.js"; @@ -20,9 +24,26 @@ export interface NetworkHeadersSource { /** A bounded read cannot delay browser cleanup indefinitely. */ export interface NetworkReadOptions { readonly timeoutMs: number; + readonly maxBodyBytes: number; + readonly maxBodyAllocationBytes: number; + readonly maxHeaderAllocationBytes: number; + readonly maxMetadataAllocationBytes: number; readonly signal?: AbortSignal; } +export type NetworkBodyCapture = + | BrowserNetworkBody + | { + readonly state: "omitted"; + readonly reason: BrowserScenarioEventBudgetReason; + }; + +export type NetworkBodyPreflight = (input: { + readonly observedBytes: number; + readonly allocationBytes: number; + readonly mediaType: string | undefined; +}) => BrowserScenarioEventAdmission; + const credentialHeaders = new Set([ "authorization", "proxy-authorization", @@ -33,12 +54,14 @@ const credentialHeaders = new Set([ const errorMessage = ( cause: unknown, secrets: BrowserScenarioSecrets, + maxMetadataAllocationBytes: number, ): string => { const message = cause instanceof Error && cause.message !== "" ? cause.message : "Network read failed"; - return secrets.redactionAllocationCost(message.length) <= 1024 * 1024 + return secrets.redactionAllocationCost(message.length) <= + maxMetadataAllocationBytes ? secrets.redact(message) : "Network read failed; diagnostic exceeds the allocation budget."; }; @@ -58,10 +81,10 @@ export const captureNetworkHeaders = async ( let cost = 0; for (const { name, value } of headers) { cost += secrets.redactionAllocationCost(name.length + value.length) + 128; - if (cost > 1024 * 1024) + if (cost > options.maxHeaderAllocationBytes) return { state: "unavailable", - message: "Headers exceed the 1 MiB allocation budget.", + message: `Headers exceed the ${String(options.maxHeaderAllocationBytes)}-byte allocation budget.`, }; } return { @@ -80,7 +103,10 @@ export const captureNetworkHeaders = async ( }), }; } catch (cause: unknown) { - return { state: "unavailable", message: errorMessage(cause, secrets) }; + return { + state: "unavailable", + message: errorMessage(cause, secrets, options.maxMetadataAllocationBytes), + }; } }; @@ -91,7 +117,8 @@ export const captureNetworkBody = async ( source: NetworkHeadersSource, secrets: BrowserScenarioSecrets, options: NetworkReadOptions, -): Promise => { + preflight?: NetworkBodyPreflight, +): Promise => { try { const observed = await withPlaywrightExecutionBoundary( read, @@ -101,29 +128,55 @@ export const captureNetworkBody = async ( if (observed === null) return { state: "not_exposed" }; // This bounds subsequent redaction, base64 and validation copies, but the // Playwright API itself has already materialized the body (see PR limits). - if (observed.length > 64 * 1024) + const maxBodyBytes = options.maxBodyBytes; + if (observed.length > maxBodyBytes) return { state: "unavailable", - reason: "body-read-failed", - message: "Body exceeds the 64 KiB retained-content budget.", + reason: "body-size-limit", + message: `Playwright materialized the body before its ${String(maxBodyBytes)}-byte retained-body budget was checked.`, + limit_bytes: maxBodyBytes, + observed_bytes: observed.length, }; - if (secrets.redactionAllocationCost(observed.length) > 1024 * 1024) + const bodyAllocationCost = secrets.redactionAllocationCost(observed.length); + const base64AllocationCost = Math.ceil(observed.length / 3) * 4 * 8 + 2; + const maxBodyAllocationBytes = options.maxBodyAllocationBytes; + const observedBodyAllocationBytes = Math.max( + bodyAllocationCost, + base64AllocationCost, + ); + if (observedBodyAllocationBytes > maxBodyAllocationBytes) return { state: "unavailable", - reason: "body-read-failed", - message: "Body redaction exceeds the allocation budget.", + reason: "body-allocation-limit", + message: + "Body redaction exceeds the selected network body allocation budget.", + limit_allocation_bytes: maxBodyAllocationBytes, + observed_allocation_bytes: observedBodyAllocationBytes, }; - const retained = secrets.redactBytes(observed); const contentType = source.headers()["content-type"]; if ( contentType !== undefined && - secrets.redactionAllocationCost(contentType.length) > 64 * 1024 + secrets.redactionAllocationCost(contentType.length) > + options.maxHeaderAllocationBytes ) return { state: "unavailable", - reason: "body-read-failed", - message: "Content type exceeds the retained metadata budget.", + reason: "body-allocation-limit", + message: + "Content type exceeds the selected network header allocation budget.", + limit_allocation_bytes: options.maxHeaderAllocationBytes, + observed_allocation_bytes: secrets.redactionAllocationCost( + contentType.length, + ), }; + const admission = preflight?.({ + observedBytes: observed.length, + allocationBytes: observedBodyAllocationBytes, + mediaType: contentType, + }); + if (admission?.status === "omitted") + return { state: "omitted", reason: admission.reason }; + const retained = secrets.redactBytes(observed); return { state: "captured", representation: @@ -149,7 +202,7 @@ export const captureNetworkBody = async ( : cause instanceof AnalysisTimeoutError ? "body-read-timeout" : "body-read-failed", - message: errorMessage(cause, secrets), + message: errorMessage(cause, secrets, options.maxMetadataAllocationBytes), }; } }; diff --git a/src/browser/network/PlaywrightScenarioNetwork.test.ts b/src/browser/network/PlaywrightScenarioNetwork.test.ts index 97e95a2968..3a0705f22d 100644 --- a/src/browser/network/PlaywrightScenarioNetwork.test.ts +++ b/src/browser/network/PlaywrightScenarioNetwork.test.ts @@ -2,6 +2,7 @@ import { afterEach, expect, it, vi } from "vitest"; import { browserScenarioSchema } from "../../domain/browserScenario.js"; import { + type BrowserScenarioEventAdmission, browserScenarioEventSchema, type BrowserScenarioEvent, } from "../../domain/browserScenarioCaptureValues.js"; @@ -100,6 +101,7 @@ const harness = ( selected: BrowserNetworkContentSelection = selection, signal?: AbortSignal, secretValue = "私密", + limitsInput: unknown = {}, ) => { const scenario = browserScenarioSchema.parse({ browser: { mode: "launch", executable_path: "/fixture/chrome" }, @@ -108,14 +110,17 @@ const harness = ( secrets: [ { secret_id: "declared", environment_variable: "FIXTURE_SECRET" }, ], + capture: { limits: limitsInput }, }); const secrets = BrowserScenarioSecrets.resolve(scenario, { FIXTURE_SECRET: secretValue, }); if (secrets === undefined) throw new Error("Fixture secret was not resolved"); const events: BrowserScenarioEvent[] = []; + const omittedEventCounts = new Map(); const collector = new PlaywrightScenarioNetwork({ selection: selected, + limits: scenario.capture.limits, secrets, readTimeoutMs: 50, ...(signal === undefined ? {} : { signal }), @@ -124,10 +129,38 @@ const harness = ( events.push( browserScenarioEventSchema.parse({ ...event, sequence, step_index: 0 }), ); - return sequence; + return { status: "retained", sequence }; + }, + admitText: () => ({ status: "retained", sequence: events.length + 1 }), + admitContentBody: () => ({ + status: "retained", + sequence: events.length + 1, + }), + omitNetwork: (transactionId, kind, reason) => { + let omission = networkOmissions.get(transactionId); + if (omission === undefined) { + omission = { kinds: new Set(), reasons: new Set() }; + networkOmissions.set(transactionId, omission); + } + omission.kinds.add(kind); + omission.reasons.add(reason); + }, + omitEvent: (kind) => { + omittedEventCounts.set(kind, (omittedEventCounts.get(kind) ?? 0) + 1); }, }); - return { collector, events, secrets }; + const networkOmissions = new Map< + string, + { kinds: Set; reasons: Set } + >(); + return { + collector, + events, + limits: scenario.capture.limits, + secrets, + networkOmissions, + omittedEventCounts, + }; }; const bodyText = (event: BrowserScenarioEvent) => { @@ -350,6 +383,144 @@ it("does not read unfinished streaming responses and reports the cutoff", async expect(collector.limitations().length).toBeGreaterThan(0); }); +it("queues completed body reads behind the active-read limit", async () => { + const { collector, events } = harness({ + request_body: false, + response_body: true, + header_values: false, + }); + const requests = Array.from( + { length: 20 }, + (_, index) => new RequestFixture(`request-${String(index)}`), + ); + const releases: Array<(bytes: Buffer) => void> = []; + let activeReads = 0; + let maximumActiveReads = 0; + for (const request of requests) { + const response = new ResponseFixture(request, "unused"); + response.read = () => + new Promise((resolve) => { + activeReads += 1; + maximumActiveReads = Math.max(maximumActiveReads, activeReads); + releases.push((bytes) => { + activeReads -= 1; + resolve(bytes); + }); + }); + collector.request(request); + collector.response(response); + collector.finished(request); + } + + let releasedReads = 0; + while (releasedReads < requests.length) { + await new Promise((resolve) => setTimeout(resolve, 0)); + const batch = releases.splice(0); + expect(batch.length).toBeGreaterThan(0); + batch.forEach((release) => release(Buffer.from("captured"))); + releasedReads += batch.length; + } + await collector.finish(); + + const content = events.filter( + (event) => event.kind === "network-content" && event.phase === "response", + ); + expect(maximumActiveReads).toBe(16); + expect(content).toHaveLength(20); + expect( + content.every( + (event) => + event.kind === "network-content" && event.body.state === "captured", + ), + ).toBe(true); + expect(events.filter(({ kind }) => kind === "network-content")).toHaveLength( + 20, + ); +}); + +it("starts each queued body's timeout when its read begins", async () => { + vi.useFakeTimers(); + const { collector, events } = harness({ + request_body: false, + response_body: true, + header_values: false, + }); + const requests = Array.from( + { length: 20 }, + (_, index) => new RequestFixture(`request-${String(index)}`), + ); + for (const request of requests) { + const response = new ResponseFixture(request, "unused"); + response.read = () => + new Promise((resolve) => { + setTimeout(() => resolve(Buffer.from("captured")), 40); + }); + collector.request(request); + collector.response(response); + collector.finished(request); + } + + const finishing = collector.finish(); + await vi.advanceTimersByTimeAsync(40); + await vi.advanceTimersByTimeAsync(40); + await finishing; + + const content = events.filter( + (event) => event.kind === "network-content" && event.phase === "response", + ); + expect(content).toHaveLength(20); + expect( + content.every( + (event) => + event.kind === "network-content" && event.body.state === "captured", + ), + ).toBe(true); +}); + +it("settles queued reads as cancelled without starting their body APIs", async () => { + const controller = new AbortController(); + const { collector, events } = harness( + { + request_body: false, + response_body: true, + header_values: false, + }, + controller.signal, + ); + const requests = Array.from( + { length: 20 }, + (_, index) => new RequestFixture(`request-${String(index)}`), + ); + const responses = requests.map((request) => { + const response = new ResponseFixture(request, "unused"); + response.read = () => new Promise(() => undefined); + return response; + }); + requests.forEach((request, index) => { + collector.request(request); + collector.response(responses[index]!); + collector.finished(request); + }); + + expect(responses.filter(({ bodyReads }) => bodyReads > 0)).toHaveLength(16); + controller.abort(); + await collector.finish(); + + expect(responses.filter(({ bodyReads }) => bodyReads > 0)).toHaveLength(16); + const content = events.filter( + (event) => event.kind === "network-content" && event.phase === "response", + ); + expect(content).toHaveLength(20); + expect( + content.every( + (event) => + event.kind === "network-content" && + event.body.state === "unavailable" && + event.body.reason === "cancelled", + ), + ).toBe(true); +}); + it("bounds hung completed-body reads and rejects late mutation after finalization", async () => { vi.useFakeTimers(); const { collector, events } = harness(); @@ -554,9 +725,9 @@ it("bounds admitted transactions before invoking content producers", async () => await collector.finish(); expect(admitted[0]?.bodyReads).toBe(1); expect( - admitted - .slice(16) - .every((request) => request.bodyReads === 0 && request.headerReads === 0), + admitted.every( + (request) => request.bodyReads === 1 && request.headerReads === 1, + ), ).toBe(true); expect(rejected.bodyReads).toBe(0); expect(rejected.headerReads).toBe(0); @@ -568,6 +739,55 @@ it("bounds admitted transactions before invoking content producers", async () => expect(collector.limitations().join(" ")).toContain("resource budget"); }); +it("uses the caller's transaction limit and reports the exact omitted ID range", async () => { + const { collector, events, omittedEventCounts } = harness( + selection, + undefined, + "fixture", + { max_network_transactions: 2 }, + ); + const requests = Array.from( + { length: 4 }, + (_, index) => new RequestFixture(`body-${String(index)}`), + ); + for (const request of requests) collector.request(request); + collector.response(new ResponseFixture(requests[2]!, "body-2")); + collector.finished(requests[2]!); + await collector.finish(); + expect(collector.omittedTransactionRange()).toEqual({ + first_transaction_id: "request-3", + last_transaction_id: "request-4", + count: 2, + reason: "max-network-transactions", + }); + expect(events.filter(({ kind }) => kind === "request")).toHaveLength(2); + expect(Object.fromEntries(omittedEventCounts)).toEqual({ + request: 2, + "network-content": 3, + response: 1, + "request-finished": 1, + }); + expect(requests[2]?.bodyReads).toBe(0); +}); + +it("uses the caller's body-byte limit and returns typed size evidence", async () => { + const { collector, events } = harness(selection, undefined, "fixture", { + max_network_body_bytes: 4, + }); + const request = new RequestFixture("12345"); + collector.request(request); + await collector.finish(); + expect(events.find(({ kind }) => kind === "network-content")).toMatchObject({ + transaction_id: "request-1", + body: { + state: "unavailable", + reason: "body-size-limit", + limit_bytes: 4, + observed_bytes: 5, + }, + }); +}); + it("keeps header names and selected body when unselected values are oversized", async () => { const { collector, events } = harness({ request_body: true, @@ -605,6 +825,9 @@ it("omits oversized body copies and retains ordinary content", async () => { const content = events.filter((event) => event.kind === "network-content"); expect(content[0]?.body).toMatchObject({ state: "unavailable", + reason: "body-size-limit", + limit_bytes: 64 * 1024, + observed_bytes: 64 * 1024 + 1, message: expect.stringContaining("budget"), }); expect(bodyText(content[1]!)).toBe("ordinary"); @@ -628,13 +851,24 @@ it("reserves redaction expansion before creating replacement copies", async () = }); it("does not read content when phase metadata was omitted", async () => { - const { secrets } = harness(); + const { secrets, limits } = harness(); const redact = vi.spyOn(secrets, "redact"); const collector = new PlaywrightScenarioNetwork({ selection, + limits, secrets, - admitText: () => false, - emit: () => 0, + admitText: () => ({ + status: "omitted", + sequence: 1, + reason: "max-total-event-allocation-bytes", + }), + emit: (): BrowserScenarioEventAdmission => ({ + status: "retained", + sequence: 1, + }), + admitContentBody: () => ({ status: "retained", sequence: 1 }), + omitNetwork: () => undefined, + omitEvent: () => undefined, }); const request = new RequestFixture("ordinary"); collector.request(request); @@ -642,7 +876,7 @@ it("does not read content when phase metadata was omitted", async () => { expect(redact).not.toHaveBeenCalled(); expect(request.bodyReads + request.headerReads).toBe(0); expect(collector.limitations().join(" ")).toContain( - "aggregate browser event budget", + "max-total-event-allocation-bytes", ); expect(collector.limitations().join(" ")).toContain("incomplete"); }); diff --git a/src/browser/network/PlaywrightScenarioNetwork.ts b/src/browser/network/PlaywrightScenarioNetwork.ts index 24479ef378..b27702eedf 100644 --- a/src/browser/network/PlaywrightScenarioNetwork.ts +++ b/src/browser/network/PlaywrightScenarioNetwork.ts @@ -1,12 +1,19 @@ import type { BrowserNetworkBody, + BrowserNetworkHeaders, BrowserNetworkContentSelection, } from "../../domain/browserNetworkEvidence.js"; +import type { BrowserScenarioCaptureLimits } from "../../domain/browserScenarioCaptureLimits.js"; +import type { + BrowserScenarioEventAdmission, + BrowserScenarioEventBudgetReason, +} from "../../domain/browserScenarioCaptureValues.js"; import type { BrowserScenarioEvent } from "../../domain/browserScenarioCaptureValues.js"; import type { BrowserScenarioSecrets } from "../BrowserScenarioSecrets.js"; import { captureNetworkBody, captureNetworkHeaders, + type NetworkBodyCapture, type NetworkHeadersSource, type NetworkReadOptions, } from "./PlaywrightNetworkContent.js"; @@ -56,12 +63,42 @@ interface Transaction { requestObserved: boolean; } +interface QueuedContentRead { + readonly run: () => Promise; + readonly resolve: () => void; + readonly reject: (cause: unknown) => void; +} + interface NetworkCaptureOptions { readonly selection: BrowserNetworkContentSelection; + readonly limits: BrowserScenarioCaptureLimits; readonly secrets: BrowserScenarioSecrets; - readonly emit: (event: UnindexedNetworkEvent) => number; + readonly emit: ( + event: UnindexedNetworkEvent, + ) => BrowserScenarioEventAdmission; /** Check aggregate event capacity before redacting producer-owned metadata. */ - readonly admitText?: (values: Iterable) => boolean; + readonly admitText: ( + kind: UnindexedNetworkEvent["kind"], + values: Iterable, + ) => BrowserScenarioEventAdmission; + readonly admitContentBody: (input: { + readonly transactionId: string; + readonly sourceEventSequence: number; + readonly phase: "request" | "response"; + readonly headers: BrowserNetworkHeaders; + readonly observedBytes: number; + readonly allocationBytes: number; + readonly mediaType: string | undefined; + }) => BrowserScenarioEventAdmission; + readonly omitNetwork: ( + transactionId: string, + kind: UnindexedNetworkEvent["kind"], + reason: BrowserScenarioEventBudgetReason, + ) => void; + readonly omitEvent: ( + kind: UnindexedNetworkEvent["kind"], + reason: BrowserScenarioEventBudgetReason, + ) => void; readonly signal?: AbortSignal; /** Production uses 5s; a fixture can supply its own bounded-read clock. */ readonly readTimeoutMs?: number; @@ -73,15 +110,33 @@ export class PlaywrightScenarioNetwork { ScenarioNetworkRequest, Transaction >(); + private readonly omittedRequests = new WeakSet(); + private readonly omittedPhases = new WeakMap< + ScenarioNetworkRequest, + Set + >(); + private totalTransactions = 0; + private omittedTransactionCount = 0; + private firstOmittedTransaction = 0; + private lastOmittedTransaction = 0; + /** One request and one response-content job at most per admitted transaction. */ private readonly pending: Promise[] = []; + private readonly queuedReads: QueuedContentRead[] = []; private readonly gaps = new Set(); private accepting = true; private activeReads = 0; private readonly readOptions: NetworkReadOptions; + private readonly limits: BrowserScenarioCaptureLimits; constructor(private readonly options: NetworkCaptureOptions) { + this.limits = options.limits; this.readOptions = { timeoutMs: options.readTimeoutMs ?? 5_000, + maxBodyBytes: this.limits.max_network_body_bytes, + maxBodyAllocationBytes: this.limits.max_network_body_allocation_bytes, + maxHeaderAllocationBytes: this.limits.max_network_header_allocation_bytes, + maxMetadataAllocationBytes: + this.limits.max_network_metadata_allocation_bytes, ...(options.signal === undefined ? {} : { signal: options.signal }), }; } @@ -90,30 +145,59 @@ export class PlaywrightScenarioNetwork { request(request: ScenarioNetworkRequest): void { if (!this.accepting) return; const transaction = this.transaction(request); - if (transaction === undefined || transaction.requestObserved) return; - if (!this.admitMetadata(request)) return; + if (transaction === undefined) { + this.omitOmittedTransactionPhase(request, "request"); + if ( + this.options.selection.request_body || + this.options.selection.header_values + ) + this.omitOmittedTransactionContent(request, "request"); + return; + } + if (transaction.requestObserved) return; + if (!this.admitMetadata(transaction, "request", request)) return; transaction.requestObserved = true; - const sequence = this.options.emit({ + const admission = this.options.emit({ ...this.metadata(request, transaction), kind: "request", status: null, failure: null, }); - if (sequence === 0) return; + if (admission.status === "omitted") { + this.options.omitNetwork(transaction.id, "request", admission.reason); + return; + } + const sequence = admission.sequence; if ( this.options.selection.request_body || this.options.selection.header_values ) - this.collect(transaction, sequence, "request", request, () => - this.options.selection.request_body - ? captureNetworkBody( - async () => request.postDataBuffer(), - "request", - request, - this.options.secrets, - this.readOptions, - ) - : Promise.resolve({ state: "not_requested" }), + this.collect( + transaction, + sequence, + "request", + request, + (headers) => + this.options.selection.request_body + ? captureNetworkBody( + async () => request.postDataBuffer(), + "request", + request, + this.options.secrets, + this.readOptions, + (body) => + this.options.admitContentBody({ + transactionId: transaction.id, + sourceEventSequence: sequence, + phase: "request", + headers, + observedBytes: body.observedBytes, + allocationBytes: body.allocationBytes, + mediaType: body.mediaType, + }), + ) + : Promise.resolve({ state: "not_requested" }), + this.options.selection.header_values, ); } @@ -122,9 +206,15 @@ export class PlaywrightScenarioNetwork { if (!this.accepting) return; const request = response.request(); const transaction = this.transaction(request); + if (transaction === undefined) { + this.omitOmittedTransactionPhase(request, "response"); + this.observeOmittedTransactionPhase(request, "response"); + return; + } if ( - transaction === undefined || !this.admitMetadata( + transaction, + "response", request, undefined, [], @@ -132,7 +222,7 @@ export class PlaywrightScenarioNetwork { ) ) return; - const sequence = this.options.emit({ + const admission = this.options.emit({ ...this.metadata(request, transaction), kind: "response", url: this.options.secrets.sanitizeUrl(response.url()), @@ -140,7 +230,11 @@ export class PlaywrightScenarioNetwork { header_names: this.headerNames(response), failure: null, }); - if (sequence === 0) return; + if (admission.status === "omitted") { + this.options.omitNetwork(transaction.id, "response", admission.reason); + return; + } + const sequence = admission.sequence; transaction.response = { source: response, sequence }; } @@ -148,15 +242,31 @@ export class PlaywrightScenarioNetwork { finished(request: ScenarioNetworkRequest): void { if (!this.accepting) return; const transaction = this.transaction(request); - if (transaction === undefined || !this.admitMetadata(request)) return; + if (transaction === undefined) { + this.omitOmittedTransactionPhase(request, "request-finished"); + const observed = this.omittedPhases.get(request); + if ( + observed?.has("response") && + (this.options.selection.response_body || + this.options.selection.header_values) + ) + this.omitOmittedTransactionContent(request, "response"); + return; + } + if (!this.admitMetadata(transaction, "request-finished", request)) return; transaction.terminal = "finished"; - const sequence = this.options.emit({ + const admission = this.options.emit({ ...this.metadata(request, transaction), kind: "request-finished", status: null, failure: null, }); - if (sequence === 0) { + if (admission.status === "omitted") { + this.options.omitNetwork( + transaction.id, + "request-finished", + admission.reason, + ); transaction.responseScheduled = true; return; } @@ -167,18 +277,31 @@ export class PlaywrightScenarioNetwork { failed(request: ScenarioNetworkRequest): void { if (!this.accepting) return; const transaction = this.transaction(request); - if (transaction === undefined) return; + if (transaction === undefined) { + this.omitOmittedTransactionPhase(request, "request-failed"); + return; + } transaction.terminal = "failed"; const failure = request.failure()?.errorText || "Unknown request failure"; - if (!this.admitMetadata(request, undefined, [failure])) return; + if ( + !this.admitMetadata(transaction, "request-failed", request, undefined, [ + failure, + ]) + ) + return; const message = this.options.secrets.redact(failure); - const sequence = this.options.emit({ + const admission = this.options.emit({ ...this.metadata(request, transaction), kind: "request-failed", status: null, failure: message, }); - if (sequence === 0) { + if (admission.status === "omitted") { + this.options.omitNetwork( + transaction.id, + "request-failed", + admission.reason, + ); transaction.responseScheduled = true; return; } @@ -189,20 +312,21 @@ export class PlaywrightScenarioNetwork { }); } - /** Stop observing, mark unfinished transactions, and settle bounded content reads. */ + /** Stop observing, mark unfinished transactions, and drain admitted content reads. */ async finish(): Promise { if (!this.accepting) { - await Promise.all(this.pending); + await this.settlePendingReads(); return; } this.accepting = false; for (const [request, transaction] of this.transactions) { if (transaction.terminal === undefined) { - if (!this.admitMetadata(request)) continue; + if (!this.admitMetadata(transaction, "request-unfinished", request)) + continue; this.gaps.add( "Some network requests had not completed when capture ended; later activity is unknown.", ); - const sequence = this.options.emit({ + const admission = this.options.emit({ ...this.metadata(request, transaction), kind: "request-unfinished", status: null, @@ -212,7 +336,12 @@ export class PlaywrightScenarioNetwork { ? "cancelled" : "capture-ended", }); - if (sequence === 0) { + if (admission.status === "omitted") { + this.options.omitNetwork( + transaction.id, + "request-unfinished", + admission.reason, + ); transaction.responseScheduled = true; continue; } @@ -227,7 +356,15 @@ export class PlaywrightScenarioNetwork { }); } } - await Promise.all(this.pending); + await this.settlePendingReads(); + } + + private async settlePendingReads(): Promise { + const results = await Promise.allSettled(this.pending); + const failed = results.find( + (result): result is PromiseRejectedResult => result.status === "rejected", + ); + if (failed !== undefined) throw failed.reason; } /** Missing selected content contributes to aggregate completeness. */ @@ -235,14 +372,38 @@ export class PlaywrightScenarioNetwork { return [...this.gaps]; } + omittedTransactionRange(): { + readonly first_transaction_id: string; + readonly last_transaction_id: string; + readonly count: number; + readonly reason: "max-network-transactions"; + } | null { + return this.omittedTransactionCount === 0 + ? null + : { + first_transaction_id: `request-${String(this.firstOmittedTransaction)}`, + last_transaction_id: `request-${String(this.lastOmittedTransaction)}`, + count: this.omittedTransactionCount, + reason: "max-network-transactions", + }; + } + private transaction( request: ScenarioNetworkRequest, ): Transaction | undefined { const known = this.transactions.get(request); if (known !== undefined) return known; - if (this.transactions.size >= 1024) { + if (this.omittedRequests.has(request)) return undefined; + this.totalTransactions += 1; + const id = `request-${String(this.totalTransactions)}`; + if (this.transactions.size >= this.limits.max_network_transactions) { + this.omittedRequests.add(request); + if (this.omittedTransactionCount === 0) + this.firstOmittedTransaction = this.totalTransactions; + this.lastOmittedTransaction = this.totalTransactions; + this.omittedTransactionCount += 1; this.gaps.add( - "Network collection reached its 1024-transaction resource budget; further transactions were omitted.", + `Network collection reached its ${String(this.limits.max_network_transactions)}-transaction resource budget; omitted transaction IDs are listed in events.omissions.network_transaction_range.`, ); return undefined; } @@ -250,7 +411,7 @@ export class PlaywrightScenarioNetwork { const predecessorTransaction = predecessor === null ? undefined : this.transactions.get(predecessor); const transaction: Transaction = { - id: `request-${this.transactions.size + 1}`, + id, redirectedFrom: predecessorTransaction?.id ?? null, redirectedFromState: predecessor === null @@ -265,27 +426,58 @@ export class PlaywrightScenarioNetwork { return transaction; } - private admitProducerStrings(values: Iterable): boolean { - const collected: string[] = []; - let cost = 0; - for (const value of values) { - cost += this.options.secrets.redactionAllocationCost(value.length) + 128; - if (cost > 1024 * 1024) { - this.gaps.add( - "Some network metadata exceeded its allocation budget and was omitted.", - ); - return false; - } - collected.push(value); - } - if (this.options.admitText?.(collected) ?? true) return true; + private admitProducerStrings( + transaction: Transaction, + kind: UnindexedNetworkEvent["kind"], + values: Iterable, + ): boolean { + const admission = this.options.admitText(kind, values); + if (admission.status === "retained") return true; + this.options.omitNetwork(transaction.id, kind, admission.reason); this.gaps.add( - "Some network metadata was omitted because the aggregate browser event budget was reached; affected transactions are incomplete.", + `Network ${kind} metadata was omitted for ${transaction.id} because ${admission.reason} was reached; network coverage is incomplete.`, ); return false; } + private omitOmittedTransactionPhase( + request: ScenarioNetworkRequest, + kind: UnindexedNetworkEvent["kind"], + ): void { + if (!this.omittedRequests.has(request)) return; + if (!this.observeOmittedTransactionPhase(request, kind)) return; + this.options.omitEvent(kind, "max-network-transactions"); + } + + private observeOmittedTransactionPhase( + request: ScenarioNetworkRequest, + phase: string, + ): boolean { + let observed = this.omittedPhases.get(request); + if (observed === undefined) { + observed = new Set(); + this.omittedPhases.set(request, observed); + } + if (observed.has(phase)) return false; + observed.add(phase); + return true; + } + + private omitOmittedTransactionContent( + request: ScenarioNetworkRequest, + phase: "request" | "response", + ): void { + if (!this.omittedRequests.has(request)) return; + if ( + !this.observeOmittedTransactionPhase(request, `network-content:${phase}`) + ) + return; + this.options.omitEvent("network-content", "max-network-transactions"); + } + private admitMetadata( + transaction: Transaction, + kind: UnindexedNetworkEvent["kind"], source: NetworkHeadersSource, url?: string, additionalStrings: readonly string[] = [], @@ -295,6 +487,8 @@ export class PlaywrightScenarioNetwork { }[] = [], ): boolean { return this.admitProducerStrings( + transaction, + kind, this.metadataStrings(source, url, additionalStrings, additionalSources), ); } @@ -379,7 +573,7 @@ export class PlaywrightScenarioNetwork { response.sequence, "response", response.source, - () => + (headers) => this.options.selection.response_body ? unavailable === undefined ? captureNetworkBody( @@ -388,9 +582,20 @@ export class PlaywrightScenarioNetwork { response.source, this.options.secrets, this.readOptions, + (body) => + this.options.admitContentBody({ + transactionId: transaction.id, + sourceEventSequence: response.sequence, + phase: "response", + headers, + observedBytes: body.observedBytes, + allocationBytes: body.allocationBytes, + mediaType: body.mediaType, + }), ) : Promise.resolve(unavailable) : Promise.resolve({ state: "not_requested" }), + this.options.selection.header_values, ); } @@ -399,54 +604,69 @@ export class PlaywrightScenarioNetwork { sequence: number, phase: "request" | "response", source: NetworkHeadersSource, - body: () => Promise, + body: (headers: BrowserNetworkHeaders) => Promise, + includeHeaders: boolean, ): void { - if (this.activeReads >= 16) { - const message = "Network content exceeds the 16 concurrent-read budget."; - this.gaps.add(message); - this.options.emit({ + const collect = async (): Promise => { + const retainedHeaders = includeHeaders + ? await captureNetworkHeaders( + source, + this.options.secrets, + this.readOptions, + ) + : { state: "not_requested" as const }; + const retainedBody = await body(retainedHeaders); + if (retainedBody.state === "omitted") { + this.options.omitNetwork( + transaction.id, + "network-content", + retainedBody.reason, + ); + return; + } + if ( + retainedHeaders.state === "unavailable" || + retainedBody.state === "unavailable" || + retainedBody.state === "not_exposed" + ) + this.gaps.add( + "Some selected network content was unavailable; per-transaction content states retain the reason.", + ); + const admission = this.options.emit({ kind: "network-content", transaction_id: transaction.id, source_event_sequence: sequence, phase, - headers: this.options.selection.header_values - ? { state: "unavailable", message } - : { state: "not_requested" }, - body: this.options.selection[ - phase === "request" ? "request_body" : "response_body" - ] - ? { state: "unavailable", reason: "body-read-failed", message } - : { state: "not_requested" }, + headers: retainedHeaders, + body: retainedBody, }); - return; - } - this.activeReads += 1; - const headers = this.options.selection.header_values - ? captureNetworkHeaders(source, this.options.secrets, this.readOptions) - : Promise.resolve({ state: "not_requested" } as const); + if (admission.status === "omitted") + this.options.omitNetwork( + transaction.id, + "network-content", + admission.reason, + ); + }; this.pending.push( - Promise.all([headers, body()]) - .then(([retainedHeaders, retainedBody]) => { - if ( - retainedHeaders.state === "unavailable" || - retainedBody.state === "unavailable" || - retainedBody.state === "not_exposed" - ) - this.gaps.add( - "Some selected network content was unavailable; per-transaction content states retain the reason.", - ); - this.options.emit({ - kind: "network-content", - transaction_id: transaction.id, - source_event_sequence: sequence, - phase, - headers: retainedHeaders, - body: retainedBody, - }); - }) - .finally(() => { - this.activeReads -= 1; - }), + new Promise((resolve, reject) => { + this.queuedReads.push({ run: collect, resolve, reject }); + this.pumpContentReads(); + }), ); } + + private pumpContentReads(): void { + while (this.activeReads < 16 && this.queuedReads.length > 0) { + const job = this.queuedReads.shift(); + if (job === undefined) return; + this.activeReads += 1; + void job + .run() + .then(job.resolve, job.reject) + .finally(() => { + this.activeReads -= 1; + this.pumpContentReads(); + }); + } + } } diff --git a/src/contracts/browserScenarioToolContracts.ts b/src/contracts/browserScenarioToolContracts.ts index 81bc188fc4..54bc1bc156 100644 --- a/src/contracts/browserScenarioToolContracts.ts +++ b/src/contracts/browserScenarioToolContracts.ts @@ -26,7 +26,7 @@ export const BROWSER_SCENARIO_TOOL_CONTRACTS = [ name: "capture_browser_scenario", ...toolContractMetadata("capture_browser_scenario"), description: - "Run a controlled browser scenario when passive observation cannot exercise the application. Launch the selected executable or connect to a loopback CDP target, provide a start URL and actions, and select storage or artifacts when needed. Environment, empty storage, and a URL-only final capture are defaults. capture.network selects request_body, response_body, and header_values independently; selected content is returned inline with transaction and event references, byte digests, and explicit unavailable states. Credentials and declared secrets are redacted. Agents can run their own local fixture servers to control application responses. Actions run until completion or cancellation unless timeout_ms is set. Results are returned inline.", + "Run a controlled browser scenario when passive observation cannot exercise the application. Launch the selected executable or connect to a loopback CDP target, provide a start URL and actions, and select storage or artifacts when needed. Environment, empty storage, a URL-only final capture, and measured event-retention budgets are defaults. capture.network selects request_body, response_body, and header_values independently; capture.limits lets callers raise or lower the event count, retained-allocation estimates, network transaction count, and network content budgets. Selected content is returned inline with transaction and event references, byte digests, and explicit unavailable states. Budget omissions identify affected event kinds and network transactions. Credentials and declared secrets are redacted. Agents can run their own local fixture servers to control application responses. Actions run until completion or cancellation unless timeout_ms is set. Results are returned inline.", kind: "browser-provider", inputSchema: browserScenarioInputSchema, outputSchema: evidenceResultOf(browserScenarioCaptureSchema), diff --git a/src/contracts/browserToolContracts.ts b/src/contracts/browserToolContracts.ts index 70111640af..4f5a124eeb 100644 --- a/src/contracts/browserToolContracts.ts +++ b/src/contracts/browserToolContracts.ts @@ -23,6 +23,7 @@ import { browserCaptureComparisonInputSchema, browserCaptureComparisonSchema, } from "../domain/browserCaptureComparison.js"; +import { DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS } from "../domain/browserScenarioCaptureLimits.js"; import { captureWebScreenshotInputSchema, compareWebScreenshotsInputSchema, @@ -85,6 +86,7 @@ const exampleScenarioCapture = () => ({ header_values: false, }, event_families: [], + limits: DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS, }, duration_ms: 10, steps: [ @@ -117,7 +119,16 @@ const exampleScenarioCapture = () => ({ completeness: scenarioCompleteness, }, ], - events: { retained: 0, dropped: 0, items: [] }, + events: { + retained: 0, + dropped: 0, + items: [], + omissions: { + by_kind: [], + network_transactions: [], + network_transaction_range: null, + }, + }, completeness: scenarioCompleteness, limitations: [], }); diff --git a/src/domain/analysisErrorPresentation.ts b/src/domain/analysisErrorPresentation.ts index 54ad586c56..955b48538d 100644 --- a/src/domain/analysisErrorPresentation.ts +++ b/src/domain/analysisErrorPresentation.ts @@ -35,6 +35,11 @@ import type { AnalysisErrorProjection } from "./analysisErrorProjection.js"; export const analysisErrorRemediationAction = ( error: AnalysisError, ): string => { + if ( + error instanceof BrowserObservationError && + error.reason === "service_worker_blocked" + ) + return "Set environment.service_workers to allow to permit this capture, or remove the service worker registration through the browser. REA never removes existing registrations."; if (error instanceof AnalysisCapabilityUnavailableError && error.retryable) return "Review details.reason, then retry the same analysis after the condition clears."; if (error instanceof EvidenceFileError && error.published !== undefined) @@ -177,6 +182,7 @@ const browserErrorCategory = ( if ( reason === "target_not_found" || reason === "target_not_allowed" || + reason === "service_worker_blocked" || reason === "target_changed" || reason === "endpoint_unreachable" || reason === "disconnected" @@ -300,12 +306,14 @@ const hopperErrorUserMessage = (error: AnalysisError): string | undefined => { const RESTART_OWNED_PROVIDER = "Restart the owned provider by calling close_binary, then open the target again and retry."; +const RESTART_UNREACHABLE_HOPPER = `${RESTART_OWNED_PROVIDER} If Hopper may have exited, call export_evidence_bundle before close_binary; closing clears retained records.`; const hopperProcessRemediation = (error: HopperProcessError): string => { if (error.failureCode !== undefined && error.userMessage !== undefined) return error.userMessage; if (error.stage === "launch") return "Review the captured launcher diagnostics and Hopper setup, then retry opening the target."; + if (error.providerState === "unreachable") return RESTART_UNREACHABLE_HOPPER; if (providerRetryAction(error.providerState) === "restart_provider") return RESTART_OWNED_PROVIDER; return "Read binary_session.provider_operation_health for this request before retrying or restarting the owned provider."; @@ -335,7 +343,7 @@ const hopperProcessMessage = (error: HopperProcessError): string => { return `Hopper exited during ${where} (${exit}).${startup}${recovery}${request}`; } if (error.providerState === "unreachable") - return `Hopper became unreachable during ${where}; its process exit was not observed. ${RESTART_OWNED_PROVIDER}${startup}${request}`; + return `Hopper became unreachable during ${where}; its process exit was not observed. ${RESTART_UNREACHABLE_HOPPER}${startup}${request}`; return `Hopper failed during ${where} and REA could not determine whether the provider is busy, exited, or unreachable.${startup} Read binary_session.provider_operation_health for this request before retrying or restarting.${request}`; }; diff --git a/src/domain/analysisErrorProjection.test.ts b/src/domain/analysisErrorProjection.test.ts index c0008156bb..de8c326afa 100644 --- a/src/domain/analysisErrorProjection.test.ts +++ b/src/domain/analysisErrorProjection.test.ts @@ -392,7 +392,30 @@ it("retains native partial observations and cleanup uncertainty on provider fail expect(analysisErrorProjectionSchema.safeParse(projected).success).toBe(true); }); -describe("analysis error projection: provider failures", () => { +describe("analysis error projection: browser provider failures", () => { + it("explains the service-worker policy refusal and names its recovery", () => { + const projected = projectAnalysisError( + new BrowserObservationError( + "capture_browser_scenario", + "service_worker_blocked", + ), + ); + + expect(projected).toMatchObject({ + code: "target_unavailable", + category: "unavailable", + retryable: false, + message: expect.stringContaining("service-worker policy is block"), + remediation: { + action: expect.stringContaining("environment.service_workers to allow"), + }, + details: { + operation: "capture_browser_scenario", + reason: "service_worker_blocked", + }, + }); + }); + it("projects the primary browser failure alongside incomplete cleanup", () => { const projected = projectAnalysisError( new BrowserObservationError( @@ -421,7 +444,9 @@ describe("analysis error projection: provider failures", () => { }, }); }); +}); +describe("analysis error projection: Hopper provider failures", () => { it("reports a timed-out active Hopper request and actionable retry guidance", () => { const projected = projectAnalysisError( new HopperTimeoutError(30_000, "trace_feature", 42, "busy"), @@ -487,7 +512,9 @@ describe("analysis error projection: provider failures", () => { message: expect.stringContaining("Hopper analysis failed"), }); }); +}); +describe("analysis error projection: detached provider diagnostics", () => { it.each(["adapter", "cancelled", "timeout"])( "preserves detached, actionable diagnostics for %s errors", (kind) => { @@ -515,7 +542,9 @@ describe("analysis error projection: provider failures", () => { expect(error.diagnostics?.exit_code).toBe(1); }, ); +}); +describe("analysis error projection: Hopper startup failures", () => { it("maps the Linux startup compatibility exit codes without host internals", () => { const expected = [ [70, "private_display_unavailable"], @@ -765,6 +794,18 @@ describe("analysis error projection: operational diagnostics", () => { expect(unknown.message).toContain("could not determine"); expect(unknown.message).not.toContain("stopped"); expect(unknown.remediation.action).toContain("provider_operation_health"); + + const unreachable = projectAnalysisError( + new HopperProcessError(null, { + operation: "search_strings", + providerState: "unreachable", + }), + ); + expect(unreachable.message).toContain( + "If Hopper may have exited, call export_evidence_bundle before close_binary", + ); + expect(unreachable.message).toContain("closing clears retained records"); + expect(unreachable.remediation.action).toContain("export_evidence_bundle"); }); }); diff --git a/src/domain/analysisErrorProjection.ts b/src/domain/analysisErrorProjection.ts index 2884bcfcbb..e73de5e92a 100644 --- a/src/domain/analysisErrorProjection.ts +++ b/src/domain/analysisErrorProjection.ts @@ -135,6 +135,7 @@ const browserErrorCode = ( if ( reason === "target_not_found" || reason === "target_not_allowed" || + reason === "service_worker_blocked" || reason === "target_changed" ) return "target_unavailable"; diff --git a/src/domain/browserNetworkEvidence.ts b/src/domain/browserNetworkEvidence.ts index 08df3aaafa..34f90e84a2 100644 --- a/src/domain/browserNetworkEvidence.ts +++ b/src/domain/browserNetworkEvidence.ts @@ -26,9 +26,7 @@ export const browserNetworkContentSelectionSchema = }); /** Self-verifying retained bytes, after any declared-secret redaction. */ -export const browserNetworkBodySchema = z.union([ - z.strictObject({ state: z.literal("not_requested") }), - z.strictObject({ state: z.literal("not_exposed") }), +const unavailableBrowserNetworkBodySchema = z.discriminatedUnion("reason", [ z.strictObject({ state: z.literal("unavailable"), reason: z.enum([ @@ -41,6 +39,26 @@ export const browserNetworkBodySchema = z.union([ ]), message: z.string().min(1), }), + z.strictObject({ + state: z.literal("unavailable"), + reason: z.literal("body-size-limit"), + message: z.string().min(1), + limit_bytes: z.number().int().positive().safe(), + observed_bytes: z.number().int().positive().safe(), + }), + z.strictObject({ + state: z.literal("unavailable"), + reason: z.literal("body-allocation-limit"), + message: z.string().min(1), + limit_allocation_bytes: z.number().int().positive().safe(), + observed_allocation_bytes: z.number().int().positive().safe(), + }), +]); + +export const browserNetworkBodySchema = z.union([ + z.strictObject({ state: z.literal("not_requested") }), + z.strictObject({ state: z.literal("not_exposed") }), + unavailableBrowserNetworkBodySchema, z .strictObject({ state: z.literal("captured"), diff --git a/src/domain/browserObservationError.ts b/src/domain/browserObservationError.ts index 0ad6868871..7bb5da8c1b 100644 --- a/src/domain/browserObservationError.ts +++ b/src/domain/browserObservationError.ts @@ -51,6 +51,8 @@ const observationFailureMessage = ( return `${operation} could not reach the selected loopback debugging endpoint. Verify that the target process is running with debugging enabled and that the endpoint's port matches it, then retry.`; if (reason === "target_not_found") return `${operation} could not find the selected target at the debugging endpoint. Refresh target discovery for that endpoint and retry with a returned target ID.`; + if (reason === "service_worker_blocked") + return `${operation} refused the selected page because it is controlled by or has a registered service worker while the service-worker policy is block. Set environment.service_workers to allow to permit observation, or remove the service worker registration through the browser.`; if (reason === "disconnected") return `${operation} lost its debugging connection. Verify that the target process is still running, refresh target discovery, and retry the observation.`; return undefined; diff --git a/src/domain/browserObservationErrors.ts b/src/domain/browserObservationErrors.ts index d126fcd749..89b2b73e0b 100644 --- a/src/domain/browserObservationErrors.ts +++ b/src/domain/browserObservationErrors.ts @@ -24,6 +24,7 @@ export type BrowserObservationFailureReason = | "invalid_endpoint_response" | "target_not_found" | "target_not_allowed" + | "service_worker_blocked" | "target_changed" | "protocol_error" | "disconnected" diff --git a/src/domain/browserScenario.test.ts b/src/domain/browserScenario.test.ts index a5527c4a14..96cf35590c 100644 --- a/src/domain/browserScenario.test.ts +++ b/src/domain/browserScenario.test.ts @@ -87,6 +87,34 @@ it("does not require duplicate origin scope declarations", () => { ); }); +it("accepts caller-selected safe capture limits and rejects invalid bounds", () => { + const base = { + browser: { mode: "launch", executable_path: "/opt/chromium" }, + start_url: { url: "https://example.test/" }, + actions: [{ step_id: "wait", action: "wait_for_timeout", duration_ms: 1 }], + }; + expect( + browserScenarioSchema.parse({ + ...base, + capture: { + limits: { max_total_event_allocation_bytes: 32 * 1024 * 1024 }, + }, + }).capture.limits, + ).toMatchObject({ + max_events: 10_000, + max_event_allocation_bytes: 4 * 1024 * 1024, + max_total_event_allocation_bytes: 32 * 1024 * 1024, + max_network_body_bytes: 64 * 1024, + }); + for (const invalid of [0, Number.MAX_SAFE_INTEGER + 1, Number.NaN]) + expect( + browserScenarioSchema.safeParse({ + ...base, + capture: { limits: { max_events: invalid } }, + }).success, + ).toBe(false); +}); + it("accepts complete browser inputs beyond former string length caps", () => { const longValue = "value".repeat(20_000); const longToken = "a".repeat(70_000); diff --git a/src/domain/browserScenarioCapture.test.ts b/src/domain/browserScenarioCapture.test.ts index bc075967d7..8c84e978be 100644 --- a/src/domain/browserScenarioCapture.test.ts +++ b/src/domain/browserScenarioCapture.test.ts @@ -9,6 +9,7 @@ import { browserStepArtifactsSchema, } from "./browserScenarioCaptureValues.js"; import { browserScenarioCaptureSchema } from "./browserScenarioCapture.js"; +import { DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS } from "./browserScenarioCaptureLimits.js"; const networkEvent = { sequence: 1, @@ -217,10 +218,21 @@ it("accepts complete scenario output beyond former string caps", () => { response_body: false, header_values: false, }, + event_families: [], + limits: DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS, }, duration_ms: 0, steps: [step(0), step(1)], - events: { retained: 0, dropped: 0, items: [] }, + events: { + retained: 0, + dropped: 0, + items: [], + omissions: { + by_kind: [], + network_transactions: [], + network_transaction_range: null, + }, + }, completeness: { status: "complete", equality_eligible: true, @@ -234,6 +246,112 @@ it("accepts complete scenario output beyond former string caps", () => { expect(parsed.limitations).toHaveLength(65); }); +it("validates reported event limits and omission accounting", () => { + const outputStep = (index: number) => ({ + step_index: index, + step_id: `step-${index}`, + action: "wait_for_timeout", + elapsed_ms: 0, + before_url: sanitizeBrowserUrl("http://127.0.0.1/"), + after_url: sanitizeBrowserUrl("http://127.0.0.1/"), + event_sequence_start: 1, + event_sequence_end: 0, + artifacts: { + screenshot: { state: "not_requested" }, + dom: { state: "not_requested" }, + accessibility: { state: "not_requested" }, + url: { state: "not_requested" }, + history: { state: "not_requested" }, + storage: { state: "not_requested" }, + }, + completeness: { + status: "complete", + equality_eligible: true, + missing_sections: [], + truncated_sections: [], + }, + status: "completed", + error: null, + }); + const capture = browserScenarioCaptureSchema.safeParse({ + browser: { + mode: "launch", + process_ownership: "provider-owned", + cleanup: "terminated-owned-process", + product: "Chromium", + version: "fixture", + }, + scenario: { + start_origin: "http://127.0.0.1", + action_count: 1, + secret_references: [], + network_content: { + request_body: false, + response_body: true, + header_values: false, + }, + event_families: ["network"], + limits: { + max_total_event_allocation_bytes: 32 * 1024 * 1024, + }, + }, + duration_ms: 1, + steps: [outputStep(0), outputStep(1)], + events: { + retained: 0, + dropped: 2, + items: [], + omissions: { + by_kind: [ + { + kind: "network-content", + count: 1, + reasons: ["max-total-event-allocation-bytes"], + }, + { + kind: "request", + count: 1, + reasons: ["max-network-transactions"], + }, + ], + network_transactions: [ + { + transaction_id: "request-1", + omitted_kinds: ["network-content"], + reasons: ["max-total-event-allocation-bytes"], + }, + ], + network_transaction_range: { + first_transaction_id: "request-2", + last_transaction_id: "request-2", + count: 1, + reason: "max-network-transactions", + }, + }, + }, + completeness: { + status: "incomplete", + equality_eligible: false, + missing_sections: ["events"], + truncated_sections: [], + }, + limitations: ["Event coverage is incomplete."], + }); + if (!capture.success) throw new Error("Expected valid omission accounting"); + expect(capture.data.scenario.limits.max_total_event_allocation_bytes).toBe( + 32 * 1024 * 1024, + ); + expect( + browserScenarioCaptureSchema.safeParse({ + ...capture.data, + events: { + ...capture.data.events, + dropped: 3, + }, + }).success, + ).toBe(false); +}); + it("derives equality eligibility from exact completeness state", () => { expect( browserScenarioCompletenessSchema.safeParse({ diff --git a/src/domain/browserScenarioCapture.ts b/src/domain/browserScenarioCapture.ts index 61c86b4310..088817c5f8 100644 --- a/src/domain/browserScenarioCapture.ts +++ b/src/domain/browserScenarioCapture.ts @@ -1,9 +1,11 @@ import { z } from "zod"; import { browserNetworkContentSelectionValuesSchema } from "./browserNetworkEvidence.js"; +import { browserScenarioCaptureLimitsSchema } from "./browserScenarioCaptureLimits.js"; import { browserScenarioCompletenessSchema, browserScenarioEventFamilySchema, + browserScenarioEventOmissionsSchema, browserScenarioEventSchema, browserScenarioStepSchema, } from "./browserScenarioCaptureValues.js"; @@ -23,7 +25,8 @@ export const browserScenarioCaptureSchema = z action_count: z.number().int().min(1), secret_references: z.array(z.string().min(1)), network_content: browserNetworkContentSelectionValuesSchema, - event_families: z.array(browserScenarioEventFamilySchema).optional(), + event_families: z.array(browserScenarioEventFamilySchema), + limits: browserScenarioCaptureLimitsSchema, }), duration_ms: z.number().int().min(0), steps: z.array(browserScenarioStepSchema).min(1), @@ -31,6 +34,7 @@ export const browserScenarioCaptureSchema = z retained: z.number().int().min(0), dropped: z.number().int().min(0), items: z.array(browserScenarioEventSchema), + omissions: browserScenarioEventOmissionsSchema, }), completeness: browserScenarioCompletenessSchema, limitations: z.array(z.string().min(1)), @@ -66,6 +70,17 @@ export const browserScenarioCaptureSchema = z path: ["events", "retained"], message: "Retained event count must equal items length", }); + if ( + capture.events.omissions.by_kind.reduce( + (count, omission) => count + omission.count, + 0, + ) !== capture.events.dropped + ) + context.addIssue({ + code: "custom", + path: ["events", "omissions", "by_kind"], + message: "Per-kind omission counts must equal the dropped event count", + }); if (capture.steps.length !== capture.scenario.action_count + 1) context.addIssue({ code: "custom", diff --git a/src/domain/browserScenarioCaptureLimits.ts b/src/domain/browserScenarioCaptureLimits.ts new file mode 100644 index 0000000000..316c87ceb5 --- /dev/null +++ b/src/domain/browserScenarioCaptureLimits.ts @@ -0,0 +1,46 @@ +import { z } from "zod"; + +const positiveSafeInteger = z.number().int().safe().positive(); + +export const DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS = { + max_events: 10_000, + max_event_allocation_bytes: 4 * 1024 * 1024, + max_total_event_allocation_bytes: 16 * 1024 * 1024, + max_network_transactions: 1024, + max_network_body_bytes: 64 * 1024, + max_network_body_allocation_bytes: 1024 * 1024, + max_network_header_allocation_bytes: 1024 * 1024, + max_network_metadata_allocation_bytes: 1024 * 1024, +} as const; + +/** Caller-selected retained-output budgets; byte values are allocation-cost estimates. */ +export const browserScenarioCaptureLimitsSchema = z.strictObject({ + max_events: positiveSafeInteger.default( + DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS.max_events, + ), + max_event_allocation_bytes: positiveSafeInteger.default( + DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS.max_event_allocation_bytes, + ), + max_total_event_allocation_bytes: positiveSafeInteger.default( + DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS.max_total_event_allocation_bytes, + ), + max_network_transactions: positiveSafeInteger.default( + DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS.max_network_transactions, + ), + max_network_body_bytes: positiveSafeInteger.default( + DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS.max_network_body_bytes, + ), + max_network_body_allocation_bytes: positiveSafeInteger.default( + DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS.max_network_body_allocation_bytes, + ), + max_network_header_allocation_bytes: positiveSafeInteger.default( + DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS.max_network_header_allocation_bytes, + ), + max_network_metadata_allocation_bytes: positiveSafeInteger.default( + DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS.max_network_metadata_allocation_bytes, + ), +}); + +export type BrowserScenarioCaptureLimits = z.infer< + typeof browserScenarioCaptureLimitsSchema +>; diff --git a/src/domain/browserScenarioCaptureValues.ts b/src/domain/browserScenarioCaptureValues.ts index aa00f222ae..9eefa298ac 100644 --- a/src/domain/browserScenarioCaptureValues.ts +++ b/src/domain/browserScenarioCaptureValues.ts @@ -279,6 +279,74 @@ const completenessSectionSchema = z.enum([ "popups", "websockets", ]); + +export const browserScenarioEventKindSchema = z.enum([ + "console", + "page-error", + "request", + "response", + "request-failed", + "request-finished", + "request-unfinished", + "network-content", + "websocket-opened", + "websocket-closed", + "websocket-frame-sent", + "websocket-frame-received", + "frame-attached", + "frame-detached", + "frame-navigated", + "worker-created", + "worker-closed", + "popup-opened", + "popup-closed", + "download-cancelled", +]); +export const browserScenarioEventBudgetReasonSchema = z.enum([ + "max-events", + "max-event-allocation-bytes", + "max-total-event-allocation-bytes", + "max-network-transactions", + "max-network-metadata-allocation-bytes", + "max-listener-owners", +]); +export const browserScenarioEventOmissionsSchema = z.strictObject({ + by_kind: z.array( + z.strictObject({ + kind: browserScenarioEventKindSchema, + count: z.number().int().positive(), + reasons: z.array(browserScenarioEventBudgetReasonSchema).min(1), + }), + ), + network_transactions: z.array( + z.strictObject({ + transaction_id: z.string().min(1), + omitted_kinds: z.array(browserScenarioEventKindSchema).min(1), + reasons: z.array(browserScenarioEventBudgetReasonSchema).min(1), + }), + ), + network_transaction_range: z + .strictObject({ + first_transaction_id: z.string().min(1), + last_transaction_id: z.string().min(1), + count: z.number().int().positive(), + reason: z.literal("max-network-transactions"), + }) + .nullable(), +}); +export type BrowserScenarioEventOmissions = z.infer< + typeof browserScenarioEventOmissionsSchema +>; +export type BrowserScenarioEventBudgetReason = z.infer< + typeof browserScenarioEventBudgetReasonSchema +>; +export type BrowserScenarioEventAdmission = + | { readonly status: "retained"; readonly sequence: number } + | { + readonly status: "omitted"; + readonly sequence: number; + readonly reason: BrowserScenarioEventBudgetReason; + }; export type BrowserScenarioCompletenessSection = z.infer< typeof completenessSectionSchema >; diff --git a/src/domain/browserScenarioDiff.test.ts b/src/domain/browserScenarioDiff.test.ts index 0eb5e593dd..4102a508aa 100644 --- a/src/domain/browserScenarioDiff.test.ts +++ b/src/domain/browserScenarioDiff.test.ts @@ -11,6 +11,13 @@ import { browserScenarioCaptureSchema, type BrowserScenarioCapture, } from "./browserScenarioCapture.js"; +import { DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS } from "./browserScenarioCaptureLimits.js"; + +const noEventOmissions = { + by_kind: [], + network_transactions: [], + network_transaction_range: null, +} as const; const networkCapture = (sourceSequence: number, receiptStep: number) => { const base = scenarioCapture({}); @@ -65,7 +72,12 @@ const networkCapture = (sourceSequence: number, receiptStep: number) => { }, event_families: ["network"], }, - events: { items: events, retained: 2, dropped: 0 }, + events: { + items: events, + retained: 2, + dropped: 0, + omissions: noEventOmissions, + }, }); }; @@ -148,7 +160,7 @@ it("reports content-selection mismatch as unknown instead of a website change", ).toBe(false); }); -it("reports event-family mismatch and legacy selection as unknown", () => { +it("reports event-family selection mismatch as unknown", () => { const consoleOnly = scenarioCapture({}); const withFamilies = ( capture: BrowserScenarioCapture, @@ -193,18 +205,6 @@ it("reports event-family mismatch and legacy selection as unknown", () => { ).overall_status, ).toBe("unchanged"); - const legacy = withFamilies(consoleOnly, undefined); - expect(legacy.scenario.event_families).toBeUndefined(); - for (const [before, after] of [ - [legacy, consoleOnly], - [legacy, legacy], - ] as const) - expect(eventDiffs(compare(before, after))).toContainEqual( - expect.objectContaining({ - status: "unknown", - reason: expect.stringContaining("Event family selection is unknown"), - }), - ); expect( compareBrowserScenariosInputSchema.safeParse({ before_scenario: { @@ -217,7 +217,7 @@ it("reports event-family mismatch and legacy selection as unknown", () => { const quiet = browserScenarioCaptureSchema.parse({ ...withFamilies(consoleOnly, []), - events: { retained: 0, dropped: 0, items: [] }, + events: { retained: 0, dropped: 0, items: [], omissions: noEventOmissions }, }); const differentEvents = compare(quiet, widened); expect(differentEvents.overall_status).toBe("unknown"); @@ -283,7 +283,12 @@ const concurrentCapture = ( ).map((event, index) => ({ ...event, sequence: index + 1 })); return browserScenarioCaptureSchema.parse({ ...base, - events: { items: events, retained: events.length, dropped: 0 }, + events: { + items: events, + retained: events.length, + dropped: 0, + omissions: noEventOmissions, + }, }); }; @@ -338,7 +343,12 @@ const redirectChainCapture = (swapLeaves = false, reverseReceipt = false) => { .map((event, index) => ({ ...event, sequence: index + 1 })); return browserScenarioCaptureSchema.parse({ ...base, - events: { items: events, retained: events.length, dropped: 0 }, + events: { + items: events, + retained: events.length, + dropped: 0, + omissions: noEventOmissions, + }, }); }; @@ -465,6 +475,7 @@ describe("browser scenario network comparison stability", () => { events: { retained: 2, dropped: 0, + omissions: noEventOmissions, items: [ { ...consoleEvent, text }, { @@ -938,6 +949,7 @@ const scenarioCapture = ( header_values: false, }, event_families: ["console"], + limits: DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS, }, duration_ms: options.elapsedMs ?? 10, steps: [ @@ -983,6 +995,7 @@ const scenarioCapture = ( url: null, }, ], + omissions: noEventOmissions, }, completeness, limitations: [], diff --git a/src/domain/browserScenarioDiffEvidence.ts b/src/domain/browserScenarioDiffEvidence.ts index 380f90303a..1dc9fb95ce 100644 --- a/src/domain/browserScenarioDiffEvidence.ts +++ b/src/domain/browserScenarioDiffEvidence.ts @@ -158,15 +158,13 @@ export const networkContentCoverageReason = ( : undefined; }; -/** Different or unrecorded event-family selection is missing comparability, not an observed website change. */ +/** Different event-family selection is missing comparability, not an observed website change. */ export const eventFamilyCoverageReason = ( before: BrowserScenarioCapture, after: BrowserScenarioCapture, ): string | undefined => { const left = before.scenario.event_families; const right = after.scenario.event_families; - if (left === undefined || right === undefined) - return "Event family selection is unknown in a legacy capture; capture both sides with matching event families before comparing event evidence."; const leftFamilies = new Set(left); const rightFamilies = new Set(right); return leftFamilies.size === rightFamilies.size && diff --git a/src/domain/browserScenarioValues.ts b/src/domain/browserScenarioValues.ts index b0344ae0ec..585c269cc8 100644 --- a/src/domain/browserScenarioValues.ts +++ b/src/domain/browserScenarioValues.ts @@ -6,6 +6,10 @@ import { IDENTIFIER_PATTERN } from "./stringPatterns.js"; import { localPathStringSchema } from "./localPath.js"; import { browserNetworkContentSelectionSchema } from "./browserNetworkEvidence.js"; import { browserScenarioEventFamilySchema } from "./browserScenarioCaptureValues.js"; +import { + browserScenarioCaptureLimitsSchema, + DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS, +} from "./browserScenarioCaptureLimits.js"; import { browserEndpointSchema, @@ -292,6 +296,9 @@ export const browserScenarioCaptureSchema = z at_end: z.array(snapshotKindSchema).default(["url"]), events: z.array(browserScenarioEventFamilySchema).default([]), network: browserNetworkContentSelectionSchema, + limits: browserScenarioCaptureLimitsSchema.default( + DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS, + ), }) .default({ after_each_step: [], @@ -302,6 +309,7 @@ export const browserScenarioCaptureSchema = z response_body: false, header_values: false, }, + limits: DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS, }) .describe( "Optional retained artifacts and event families. Defaults to only a final sanitized URL; request screenshots, DOM, accessibility, history, storage, and event capture explicitly.", diff --git a/src/domain/webBundleAnalyzer.test.ts b/src/domain/webBundleAnalyzer.test.ts index bed9cb6f3d..ed0a151eb0 100644 --- a/src/domain/webBundleAnalyzer.test.ts +++ b/src/domain/webBundleAnalyzer.test.ts @@ -161,7 +161,9 @@ describe("web bundle analyzer", () => { trust: "page-declared-untrusted", }); }); +}); +describe("web bundle source-map coverage", () => { it("reports unavailable source maps as partial without dropping requested maps", () => { const analysis = analyzeCapturedWebBundle(inspection("export {};"), { status: "unavailable", @@ -191,6 +193,18 @@ describe("web bundle analyzer", () => { }); }); + it("keeps map-less captured source complete when no maps were requested", () => { + const analysis = analyzeCapturedWebBundle(inspection("export {};"), { + status: "not_requested", + requested: 0, + processed: 0, + items: [], + }); + + expect(analysis.completeness.status).toBe("complete"); + expect(analysis.unknowns).toEqual([]); + }); + it("rejects inconsistent source-map coverage counts", () => { const result = analyzeCapturedWebBundle(inspection("export {};")); expect( diff --git a/src/native/LldbCallTracer.ts b/src/native/LldbCallTracer.ts index a7d1e0a3f0..126852c7e2 100644 --- a/src/native/LldbCallTracer.ts +++ b/src/native/LldbCallTracer.ts @@ -164,7 +164,7 @@ export interface NativeCallTracer { /** Remediation for a target LLDB may not debug. */ const ATTACH_REMEDIATION = - "Allow debugging for this user (Developer Tools access: `DevToolsSecurity -enable`, or membership in the _developer group). A hardened-runtime target is debuggable only with the com.apple.security.get-task-allow entitlement; see inspect_signature's debugger-attach facet."; + "Allow debugging for this user (Developer Tools access: `DevToolsSecurity -enable`, or membership in the _developer group). A hardened-runtime target is debuggable only with the com.apple.security.get-task-allow entitlement; inspect_signature reports hardened_runtime and entitlements."; /** Production tracer: `lldb --batch` running the REA LLDB bridge. */ export class LldbCallTracer implements NativeCallTracer { diff --git a/tests/boundary/browser/cdpBrowserProvider.document_script.test.ts b/tests/boundary/browser/cdpBrowserProvider.document_script.test.ts index 5342dc9eeb..8127879871 100644 --- a/tests/boundary/browser/cdpBrowserProvider.document_script.test.ts +++ b/tests/boundary/browser/cdpBrowserProvider.document_script.test.ts @@ -316,6 +316,13 @@ describe("CdpBrowserProvider: WebMCP discovery and completeness", () => { expect(serialized).not.toContain("private-tool-secret"); const methods = browser.commands.map(({ method }) => method); expect(methods).toContain("WebMCP.enable"); + expect(methods).toContain("WebMCP.disable"); + expect(methods.indexOf("WebMCP.enable")).toBeLessThan( + methods.indexOf("WebMCP.disable"), + ); + expect(methods.indexOf("WebMCP.disable")).toBeLessThan( + methods.indexOf("Target.detachFromTarget"), + ); expect(methods).not.toContain("WebMCP.invokeTool"); expect(methods).not.toContain("Runtime.evaluate"); }, diff --git a/tests/boundary/cli/browserCli.test.ts b/tests/boundary/cli/browserCli.test.ts index 01fc53b53b..86a9db75d5 100644 --- a/tests/boundary/cli/browserCli.test.ts +++ b/tests/boundary/cli/browserCli.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { runCliJson as runCli } from "../../fixtures/cliJsonProcess.js"; +import { DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS } from "../../../src/domain/browserScenarioCaptureLimits.js"; import { startFakeCdpBrowser, type FakeCdpBrowser, @@ -665,13 +666,23 @@ const completeScenarioCapture = () => { header_values: false, }, event_families: [], + limits: DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS, }, duration_ms: 1, steps: [ step(0, "scenario_start", "scenario_start"), step(1, "open-settings", "click"), ], - events: { retained: 0, dropped: 0, items: [] }, + events: { + retained: 0, + dropped: 0, + items: [], + omissions: { + by_kind: [], + network_transactions: [], + network_transaction_range: null, + }, + }, completeness, limitations: [], }; @@ -706,6 +717,11 @@ const scenarioCaptureWithConsole = ( url: null, }, ], + omissions: { + by_kind: [], + network_transactions: [], + network_transaction_range: null, + }, }, }; }; diff --git a/tests/boundary/mcp/browserScenarioMcp.test.ts b/tests/boundary/mcp/browserScenarioMcp.test.ts index 8e086ed06e..81efed408a 100644 --- a/tests/boundary/mcp/browserScenarioMcp.test.ts +++ b/tests/boundary/mcp/browserScenarioMcp.test.ts @@ -86,6 +86,7 @@ const captureFor = (scenario: BrowserScenario): BrowserScenarioCapture => { secret_references: scenario.secrets.map(({ secret_id: id }) => id), network_content: scenario.capture.network, event_families: scenario.capture.events, + limits: scenario.capture.limits, }, duration_ms: 2, steps: [ @@ -94,7 +95,16 @@ const captureFor = (scenario: BrowserScenario): BrowserScenarioCapture => { step(index + 1, action.step_id, action.action), ), ], - events: { retained: 0, dropped: 0, items: [] }, + events: { + retained: 0, + dropped: 0, + items: [], + omissions: { + by_kind: [], + network_transactions: [], + network_transaction_range: null, + }, + }, completeness: { status: "complete", equality_eligible: true, diff --git a/tests/fixtures/webScriptCapture.ts b/tests/fixtures/webScriptCapture.ts index f7c021e665..fef099676a 100644 --- a/tests/fixtures/webScriptCapture.ts +++ b/tests/fixtures/webScriptCapture.ts @@ -2,6 +2,7 @@ import { createHash } from "node:crypto"; import { sanitizeBrowserUrl } from "../../src/domain/browserObservation.js"; import { browserScenarioCaptureSchema } from "../../src/domain/browserScenarioCapture.js"; +import { DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS } from "../../src/domain/browserScenarioCaptureLimits.js"; import { browserScenarioEventSchema } from "../../src/domain/browserScenarioCaptureValues.js"; import { createEvidence } from "../../src/domain/evidence.js"; import { jsonValueSchema } from "../../src/domain/jsonValue.js"; @@ -107,10 +108,20 @@ export const scriptScenarioFixture = ( header_values: false, }, event_families: ["network"], + limits: DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS, }, duration_ms: 2, steps: [step(0), step(1)], - events: { items, retained: items.length, dropped: 0 }, + events: { + items, + retained: items.length, + dropped: 0, + omissions: { + by_kind: [], + network_transactions: [], + network_transaction_range: null, + }, + }, completeness, limitations: [], }); diff --git a/tests/process-global/browserScenarioCleanup.test.ts b/tests/process-global/browserScenarioCleanup.test.ts index b2cf195f7c..815cfe786c 100644 --- a/tests/process-global/browserScenarioCleanup.test.ts +++ b/tests/process-global/browserScenarioCleanup.test.ts @@ -91,7 +91,16 @@ class RetrySession implements BrowserScenarioSessionPort { return 0; } events() { - return { retained: 0, dropped: 0, items: [] }; + return { + retained: 0, + dropped: 0, + items: [], + omissions: { + by_kind: [], + network_transactions: [], + network_transaction_range: null, + }, + }; } async perform(_action: BrowserScenarioAction) {} async capture(