From f01f56156efb0a98e35718c69374bcb3b6c9694b Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 24 Oct 2026 03:18:04 +0800 Subject: [PATCH] fix(browser): retain truthful cleanup facts and document request ownership --- docs/browser-observation.md | 7 +- docs/web-runtime.md | 2 +- src/application/WebRuntimeService.test.ts | 40 ++++ src/application/WebRuntimeService.ts | 49 ++-- src/browser/CdpObservationSession.ts | 86 ++++++- src/browser/execution/CdpEventListeners.ts | 6 +- src/browser/execution/CdpExecutionCapture.ts | 5 +- .../execution/CdpWebRuntimeProvider.ts | 28 ++- src/domain/analysisErrorBase.ts | 8 +- src/domain/webEventListeners.ts | 12 +- src/domain/webExecution.ts | 13 +- .../browser/browserLifecycleFollowup.test.ts | 223 ++++++++++++++++++ .../cdpObservationWindowBuffer.test.ts | 26 +- .../browser/cdpObservationWindowScope.test.ts | 13 +- .../browser/cdpSessionRequestBudget.test.ts | 69 ++++++ tests/fixtures/fakeCdpBrowserEvents.ts | 10 +- 16 files changed, 540 insertions(+), 57 deletions(-) create mode 100644 tests/boundary/browser/browserLifecycleFollowup.test.ts create mode 100644 tests/boundary/browser/cdpSessionRequestBudget.test.ts diff --git a/docs/browser-observation.md b/docs/browser-observation.md index fae8fea940..988040911e 100644 --- a/docs/browser-observation.md +++ b/docs/browser-observation.md @@ -387,7 +387,12 @@ events retain their paired prior-hop response context when the timestamped next-request event falls inside the window. The pre-arm timeline buffer is bounded by original CDP message bytes. If clock sampling completes after that budget is exhausted, the operation returns `payload_limit` instead of a partial window. -Cancellation and clock-request failures retain their own outcomes. +Cancellation and clock-request failures retain their own outcomes. Session +`load_failed` rows require a previously observed main-frame `Document` request; +subresources, child-frame requests and failures without admitted request ownership +are excluded. Redirects reuse their request identity, and completed/failed requests +release it. Active request identity metadata uses the existing 8 MiB retained-event +allowance; exhaustion fails the observation explicitly instead of guessing scope. ## Data minimization diff --git a/docs/web-runtime.md b/docs/web-runtime.md index 1c92416f26..abccf9dc55 100644 --- a/docs/web-runtime.md +++ b/docs/web-runtime.md @@ -34,7 +34,7 @@ Same-document navigation such as `history.pushState()` remains valid when the fr Target detachment ends the window with `session_detached`, even when the browser's root connection stays open. An `Inspector.targetCrashed` event ends it with `target_terminated`. A lost transport records `transport_disconnected` in browser observation; execution capture returns the typed transport failure. These outcomes retain the selected target and the observed lifecycle fact. -When an ended session cannot acknowledge cleanup, the operation returns a failure with the failed commands and original typed producer reason. A coverage-stop failure also retains the observed window's end reason in its diagnostics; cleanup remains unconfirmed until the owning session can resolve it. +When an ended session cannot acknowledge cleanup, the operation returns a failure with the failed commands and original typed producer reason. Completed capture facts retained with that failure report cleanup as `unconfirmed`; only the session cleanup owner can mark a successful result `confirmed`. Such retained Evidence preserves coverage, sources, target and listener facts without becoming a successful operation. A caller cancellation remains primary when an incomplete retained snapshot cannot be promoted to completed Evidence. A coverage-stop failure also retains the observed window's end reason in its diagnostics; cleanup remains unconfirmed until the owning session can resolve it. Child frames, workers, service workers, WebAssembly, chronological execution order and framework-specific causal interpretation are outside this operation. Script ownership comes from frame/context metadata, never URL equality. Repeated URLs and synthetic names therefore retain separate identities. diff --git a/src/application/WebRuntimeService.test.ts b/src/application/WebRuntimeService.test.ts index f28be157d9..63fe30f8c2 100644 --- a/src/application/WebRuntimeService.test.ts +++ b/src/application/WebRuntimeService.test.ts @@ -282,3 +282,43 @@ it("drops a non-JSON execution partial and keeps timeout primary", async () => { ); expect(projectAnalysisError(error).category).toBe("timeout"); }); + +it.each(["observe", "inspect"] as const)( + "keeps %s cancellation primary when completed-shaped partial cannot be promoted within origin scope", + async (operation) => { + const partial = + operation === "observe" ? webExecutionFixture() : webListenersFixture(); + const reason = { stop: "retained fact projection" }; + const failure = new AnalysisCancelledError( + operation === "observe" + ? "observe_web_execution" + : "inspect_web_event_listeners", + { cause: reason, partialObservation: partial }, + ); + const service = new WebRuntimeService({ + identity: runtimeIdentity, + observeExecution: async () => err(failure), + inspectEventListeners: async () => err(failure), + }); + const input = { + cdp_endpoint: "http://127.0.0.1:9222", + target_id: "selected-page", + allowed_origins: ["https://selected.example"], + }; + const result = + operation === "observe" + ? await service.observe({ ...input, observation_ms: 5 }) + : await service.inspect({ ...input, selector: "#selected" }); + expect(result).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisCancelledError", + cause: reason, + partialObservation: partial, + }, + }); + if (result.ok) throw new Error("Expected original cancellation"); + expect(result.error).toBe(failure); + expect(result.error.partialObservation).not.toHaveProperty("evidence_id"); + }, +); diff --git a/src/application/WebRuntimeService.ts b/src/application/WebRuntimeService.ts index 627ada4906..acdacb0f32 100644 --- a/src/application/WebRuntimeService.ts +++ b/src/application/WebRuntimeService.ts @@ -13,10 +13,12 @@ import { err, ok, type Result } from "../domain/result.js"; import { observeWebExecutionInputSchema, webExecutionSchema, + webExecutionObservationSchema, } from "../domain/webExecution.js"; import { inspectWebEventListenersInputSchema, webEventListenersSchema, + webEventListenersObservationSchema, } from "../domain/webEventListeners.js"; import { completeProviderEvidence, @@ -49,7 +51,7 @@ export class WebRuntimeService { (partial) => { if (!("instrumentation" in partial) || !("window" in partial)) return undefined; - const verified = webExecutionSchema.safeParse(partial); + const verified = webExecutionObservationSchema.safeParse(partial); if (!verified.success) return undefined; return this.evidence(operation, parsed.value, verified.data); }, @@ -57,16 +59,6 @@ export class WebRuntimeService { const verified = webExecutionSchema.safeParse(result.value); if (!verified.success) return err(new AnalysisOutputError(operation, verified.error.message)); - if ( - verified.data.target.target_id !== parsed.value.target_id || - verified.data.window.requested_ms !== parsed.value.observation_ms - ) - return err( - new AnalysisOutputError( - operation, - "Provider changed the selected target or requested window.", - ), - ); return this.evidence(operation, parsed.value, verified.data, options); } @@ -95,7 +87,8 @@ export class WebRuntimeService { (partial) => { if (!("selected_node" in partial) || !("listeners" in partial)) return undefined; - const verified = webEventListenersSchema.safeParse(partial); + const verified = + webEventListenersObservationSchema.safeParse(partial); if (!verified.success) return undefined; return this.evidence(operation, parsed.value, verified.data); }, @@ -103,16 +96,6 @@ export class WebRuntimeService { const verified = webEventListenersSchema.safeParse(result.value); if (!verified.success) return err(new AnalysisOutputError(operation, verified.error.message)); - if ( - verified.data.target.target_id !== parsed.value.target_id || - verified.data.selected_node.selector !== parsed.value.selector - ) - return err( - new AnalysisOutputError( - operation, - "Provider changed the selected target or node selector.", - ), - ); return this.evidence(operation, parsed.value, verified.data, options); } @@ -133,7 +116,6 @@ export class WebRuntimeService { error.partialObservation = evidence.value; return err(error); } - if (error instanceof AnalysisCancelledError) return evidence; } } return err(keepIncompleteObservation(error, operation, nonJsonReason)); @@ -145,10 +127,27 @@ export class WebRuntimeService { | z.infer | z.infer, result: - | z.infer - | z.infer, + | z.infer + | z.infer, options?: ExecutionOptions, ): Result { + if ( + result.target.target_id !== input.target_id || + ("observation_ms" in input && + (!("window" in result) || + result.window.requested_ms !== input.observation_ms)) || + ("selector" in input && + (!("selected_node" in result) || + result.selected_node.selector !== input.selector)) + ) + return err( + new AnalysisOutputError( + operation, + "observation_ms" in input + ? "Provider changed the selected target or requested window." + : "Provider changed the selected target or node selector.", + ), + ); if ( input.allowed_origins.length > 0 && !input.allowed_origins.includes(result.target.origin) diff --git a/src/browser/CdpObservationSession.ts b/src/browser/CdpObservationSession.ts index a23c0102b9..74f457cc4c 100644 --- a/src/browser/CdpObservationSession.ts +++ b/src/browser/CdpObservationSession.ts @@ -113,6 +113,7 @@ export const observeCdpSession = async ( ); capture.assertPreArmBufferWithinLimit(); capture.arm(armTimestamp); + capture.assertRequestOwnershipWithinLimit(); await context.connection.send( "Page.setLifecycleEventsEnabled", { enabled: true }, @@ -132,8 +133,10 @@ export const observeCdpSession = async ( context.connection, context.signal, ); + capture.assertRequestOwnershipWithinLimit(); if (endReason === "window_elapsed") endReason = await captureFinalUrl(context, capture, allowedOrigins); + capture.assertRequestOwnershipWithinLimit(); await context.progress?.report({ phase: "browser_observation", completed: 2, @@ -188,6 +191,12 @@ class TimelineCapture { #eventsBeforeArmBytes = 0; #preArmBufferExceeded = false; #hasUnknownEventTime = false; + readonly #documentRequests = new Map< + string, + { frameId: string; loaderId: string | undefined } + >(); + #documentRequestBytes = 0; + #requestOwnershipFailure: BrowserObservationError | undefined; readonly #listeners = new Set<(reason: EndReason) => void>(); constructor( @@ -235,11 +244,15 @@ class TimelineCapture { this.#sameDocument(event, params, contextOnly); break; case "Network.requestWillBeSent": + this.#requestStarted(params); this.#redirect(params, bufferedBeforeArm); break; case "Network.loadingFailed": this.#loadingFailed(params, bufferedBeforeArm); break; + case "Network.loadingFinished": + this.#forgetRequest(cdpStringValue(params.requestId)); + break; case "Page.lifecycleEvent": this.#lifecycle(params); break; @@ -508,19 +521,83 @@ class TimelineCapture { return true; } + /** Request ingress owns frame/type attribution; loadingFailed has no frameId. */ + #requestStarted(params: UnknownRecord): void { + const id = cdpStringValue(params.requestId); + const frameId = cdpStringValue(params.frameId); + if (id === undefined) { + if ( + frameId === this.mainFrameId && + cdpStringValue(params.type) === "Document" + ) + this.completeness.exclude("timeline", "invalid_protocol_value"); + return; + } + this.#forgetRequest(id); + if ( + frameId !== this.mainFrameId || + cdpStringValue(params.type) !== "Document" + ) + return; + const loaderId = cdpStringValue(params.loaderId); + const bytes = + Buffer.byteLength(id) + + Buffer.byteLength(frameId) + + Buffer.byteLength(loaderId ?? ""); + if ( + bytes > + WEB_RUNTIME_LIMITS.retainedEventBytes - this.#documentRequestBytes + ) { + this.#requestOwnershipFailure = new BrowserObservationError( + "observe_web_session", + "payload_limit", + { + detail: `Active document request identities exceeded the ${WEB_RUNTIME_LIMITS.retainedEventBytes}-byte retained event allowance.`, + }, + ); + this.end("window_elapsed"); + return; + } + this.#documentRequests.set(id, { frameId, loaderId }); + this.#documentRequestBytes += bytes; + } + + #forgetRequest(id: string | undefined) { + if (id === undefined) return undefined; + const request = this.#documentRequests.get(id); + if (request !== undefined) { + this.#documentRequests.delete(id); + this.#documentRequestBytes -= + Buffer.byteLength(id) + + Buffer.byteLength(request.frameId) + + Buffer.byteLength(request.loaderId ?? ""); + } + return request; + } + + assertRequestOwnershipWithinLimit(): void { + if (this.#requestOwnershipFailure !== undefined) + throw this.#requestOwnershipFailure; + } + #loadingFailed(params: UnknownRecord, bufferedBeforeArm: boolean): void { - const frame = cdpStringValue(params.frameId); - if (frame !== undefined && frame !== this.mainFrameId) return; + const requestId = cdpStringValue(params.requestId); + const request = this.#forgetRequest(requestId); + if (request === undefined) return; const eventTime = this.#networkEventTime(params, bufferedBeforeArm); if (!eventTime.keep) return; this.#add({ type: "load_failed", - params, + params: { + ...params, + frameId: request.frameId, + loaderId: request.loaderId, + }, ...(eventTime.timestamp === undefined ? {} : { timestamp: eventTime.timestamp }), rawUrl: null, - requestId: cdpStringValue(params.requestId) ?? null, + requestId: requestId ?? null, detail: safeNetworkError(params.errorText), }); } @@ -706,6 +783,7 @@ const isTimelineEvent = (method: string): boolean => hasNoComparableProducerTimestamp(method) || method === "Network.requestWillBeSent" || method === "Network.loadingFailed" || + method === "Network.loadingFinished" || method === "Page.lifecycleEvent"; const scopedUrl = ( diff --git a/src/browser/execution/CdpEventListeners.ts b/src/browser/execution/CdpEventListeners.ts index eda55ed113..c6baa210cf 100644 --- a/src/browser/execution/CdpEventListeners.ts +++ b/src/browser/execution/CdpEventListeners.ts @@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto"; import { z } from "zod"; import type { InspectWebEventListenersInput, - WebEventListeners, + WebEventListenersObservation, } from "../../domain/webEventListeners.js"; import type { CdpRuntimeSources } from "./CdpRuntimeSources.js"; import { runtimeListenersSchema } from "./CdpRuntimeProtocol.js"; @@ -13,7 +13,7 @@ export const captureWebEventListeners = async ( input: InspectWebEventListenersInput, sources: CdpRuntimeSources, group: string, -): Promise => { +): Promise => { const session = sources.session; session.beginCompletion(); const unsubscribe = session.transport.connection.onEvent((event) => @@ -92,7 +92,7 @@ export const captureWebEventListeners = async ( execution: "unknown", })), sources: retained, - cleanup: "confirmed", + cleanup: "unconfirmed", limitations: [ ...(session.discovery.limitations ?? []), "Inspects listeners registered directly on the first native CSS match in the selected main document. Delegated ancestor listeners, descendants, closed shadow roots and framework registries are not enumerated.", diff --git a/src/browser/execution/CdpExecutionCapture.ts b/src/browser/execution/CdpExecutionCapture.ts index 691d37d209..9d8bd43abf 100644 --- a/src/browser/execution/CdpExecutionCapture.ts +++ b/src/browser/execution/CdpExecutionCapture.ts @@ -8,6 +8,7 @@ import { import type { ObserveWebExecutionInput, WebExecution, + WebExecutionObservation, } from "../../domain/webExecution.js"; import { CdpExecutionCollection } from "./CdpExecutionCollection.js"; import { preciseCoverageSchema } from "./CdpRuntimeProtocol.js"; @@ -18,7 +19,7 @@ import type { CdpRuntimeSources } from "./CdpRuntimeSources.js"; export const captureWebExecution = async ( input: ObserveWebExecutionInput, sources: CdpRuntimeSources, -): Promise => { +): Promise => { const session = sources.session; const collection = new CdpExecutionCollection(sources); const { window, requests } = collection; @@ -150,7 +151,7 @@ export const captureWebExecution = async ( disables_optimized_execution: true, takes_one_resetting_sample: sample !== undefined, executes_selected_code: false, - cleanup: "confirmed", + cleanup: "unconfirmed", page_ownership: "external", }, limitations: [ diff --git a/src/browser/execution/CdpWebRuntimeProvider.ts b/src/browser/execution/CdpWebRuntimeProvider.ts index 8df29707c3..e0952961ad 100644 --- a/src/browser/execution/CdpWebRuntimeProvider.ts +++ b/src/browser/execution/CdpWebRuntimeProvider.ts @@ -14,12 +14,12 @@ import { ProviderAdapterError } from "../../domain/providerAdapterError.js"; import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js"; import { err, ok, type Result } from "../../domain/result.js"; import { - webExecutionSchema, + webExecutionObservationSchema, type ObserveWebExecutionInput, type WebExecution, } from "../../domain/webExecution.js"; import { - webEventListenersSchema, + webEventListenersObservationSchema, type InspectWebEventListenersInput, type WebEventListeners, } from "../../domain/webEventListeners.js"; @@ -55,8 +55,15 @@ export class CdpWebRuntimeProvider implements WebRuntimePort { input, "observe_web_execution", options, - webExecutionSchema, + webExecutionObservationSchema, (sources) => captureWebExecution(input, sources), + (observation) => ({ + ...observation, + instrumentation: { + ...observation.instrumentation, + cleanup: "confirmed" as const, + }, + }), ); } /** Inspect one selected node's registered callback locations. */ @@ -69,20 +76,22 @@ export class CdpWebRuntimeProvider implements WebRuntimePort { input, "inspect_web_event_listeners", options, - webEventListenersSchema, + webEventListenersObservationSchema, (sources) => captureWebEventListeners(input, sources, group), + (observation) => ({ ...observation, cleanup: "confirmed" as const }), group, ); } - private async run( + private async run( input: ObserveWebExecutionInput | InspectWebEventListenersInput, operation: BrowserObservationOperation, options: ExecutionOptions, schema: z.ZodType, capture: (sources: CdpRuntimeSources) => Promise, + confirmCleanup: (observation: T) => Completed, group?: string, - ): Promise> { + ): Promise> { let session: CdpRuntimeSession | undefined; let result: Result; try { @@ -125,13 +134,14 @@ export class CdpWebRuntimeProvider implements WebRuntimePort { : analysisErrorWithCleanupFailure(result.error, failure, operation), ); } - if (result.ok && options.signal?.aborted === true) + const completed = result.ok ? ok(confirmCleanup(result.value)) : result; + if (completed.ok && options.signal?.aborted === true) return err( new AnalysisCancelledError(operation, { cause: options.signal.reason, - partialObservation: result.value, + partialObservation: completed.value, }), ); - return result; + return completed; } } diff --git a/src/domain/analysisErrorBase.ts b/src/domain/analysisErrorBase.ts index aa54c7981b..25a1e657ab 100644 --- a/src/domain/analysisErrorBase.ts +++ b/src/domain/analysisErrorBase.ts @@ -246,8 +246,8 @@ import type { } from "./native/recordedCrash.js"; import type { WebPageInspection } from "./browserObservationSchemas.js"; import type { WebBundleAnalysis } from "./webBundleAnalysis.js"; -import type { WebExecution } from "./webExecution.js"; -import type { WebEventListeners } from "./webEventListeners.js"; +import type { WebExecutionObservation } from "./webExecution.js"; +import type { WebEventListenersObservation } from "./webEventListeners.js"; import type { NativeLoadImagePartialObservation } from "./native/nativeLoadImage.js"; import type { NativeAotSnapshotObservation } from "./native/nativeAotSnapshotObservation.js"; @@ -273,8 +273,8 @@ export type AnalysisPartialObservation = | BrowserScenarioPartialObservation | WebPageInspection | WebBundleAnalysis - | WebExecution - | WebEventListeners + | WebExecutionObservation + | WebEventListenersObservation | JavaScriptRuntimeObservation | ArtifactInventoryPartialObservation | ArtifactExtractionPartialObservation diff --git a/src/domain/webEventListeners.ts b/src/domain/webEventListeners.ts index b7d893dcab..e48bd8e14b 100644 --- a/src/domain/webEventListeners.ts +++ b/src/domain/webEventListeners.ts @@ -18,7 +18,7 @@ export type InspectWebEventListenersInput = z.infer< >; /** A listener declaration and its source location do not establish that it ran. */ -export const webEventListenersSchema = z.object({ +export const webEventListenersObservationSchema = z.object({ browser: browserVersionSchema, target: webRuntimeTargetSchema, inspected_at: z.iso.datetime(), @@ -38,7 +38,15 @@ export const webEventListenersSchema = z.object({ }), ), sources: z.array(webRuntimeSourceSchema), - cleanup: z.literal("confirmed"), + cleanup: z.enum(["confirmed", "unconfirmed"]), limitations: z.array(z.string()), }); +/** Successful operations require the cleanup owner's acknowledgement. */ +export const webEventListenersSchema = + webEventListenersObservationSchema.extend({ + cleanup: z.literal("confirmed"), + }); +export type WebEventListenersObservation = z.infer< + typeof webEventListenersObservationSchema +>; export type WebEventListeners = z.infer; diff --git a/src/domain/webExecution.ts b/src/domain/webExecution.ts index d6f8d6fe7b..f249e01ded 100644 --- a/src/domain/webExecution.ts +++ b/src/domain/webExecution.ts @@ -29,7 +29,7 @@ const functionSchema = z.object({ }); /** Precise V8 ranges retain their nested structure and counts without summing them. */ -export const webExecutionSchema = z.object({ +export const webExecutionObservationSchema = z.object({ browser: browserVersionSchema, target: webRuntimeTargetSchema, window: z.object({ @@ -86,9 +86,18 @@ export const webExecutionSchema = z.object({ disables_optimized_execution: z.literal(true), takes_one_resetting_sample: z.boolean(), executes_selected_code: z.literal(false), - cleanup: z.literal("confirmed"), + cleanup: z.enum(["confirmed", "unconfirmed"]), page_ownership: z.literal("external"), }), limitations: z.array(z.string()), }); +/** Successful operations require the cleanup owner's acknowledgement. */ +export const webExecutionSchema = webExecutionObservationSchema.extend({ + instrumentation: webExecutionObservationSchema.shape.instrumentation.extend({ + cleanup: z.literal("confirmed"), + }), +}); +export type WebExecutionObservation = z.infer< + typeof webExecutionObservationSchema +>; export type WebExecution = z.infer; diff --git a/tests/boundary/browser/browserLifecycleFollowup.test.ts b/tests/boundary/browser/browserLifecycleFollowup.test.ts new file mode 100644 index 0000000000..f915deacaf --- /dev/null +++ b/tests/boundary/browser/browserLifecycleFollowup.test.ts @@ -0,0 +1,223 @@ +import { expect, it, onTestFinished } from "vitest"; +import { WebRuntimeService } from "../../../src/application/WebRuntimeService.js"; +import { CdpWebRuntimeProvider } from "../../../src/browser/execution/CdpWebRuntimeProvider.js"; +import { CdpBrowserProvider } from "../../../src/browser/CdpBrowserProvider.js"; +import { observeWebSessionInputSchema } from "../../../src/domain/browserSession.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { + webExecutionSchema, + webExecutionObservationSchema, +} from "../../../src/domain/webExecution.js"; +import { + webEventListenersSchema, + webEventListenersObservationSchema, +} from "../../../src/domain/webEventListeners.js"; +import { startRuntimeBrowser } from "../../fixtures/webRuntime.js"; +import { waitFor } from "./cdpBrowserProvider.support.js"; +import { startFakeCdpBrowser } from "../../fixtures/fakeCdpBrowser.js"; + +it.each(["observe", "inspect"] as const)( + "retains truthful %s facts after rejected cleanup and preserves the successful control", + async (operation) => { + let deny = false; + const browser = await startRuntimeBrowser({ + commandError: (command) => + deny && command.method === "Page.disable" + ? { code: -32000, message: "injected cleanup rejection" } + : undefined, + }); + const provider = new CdpWebRuntimeProvider(); + onTestFinished(async () => { + await provider.close(); + await browser.close(); + }); + const service = new WebRuntimeService(provider); + const input = { cdp_endpoint: browser.endpoint, target_id: "allowed-page" }; + const run = () => + operation === "observe" + ? service.observe({ ...input, observation_ms: 5 }) + : service.inspect({ ...input, selector: "#missing" }); + const healthy = await run(); + if (!healthy.ok) throw healthy.error; + const cleanup = ( + value: ReturnType["normalized_result"], + ) => + operation === "observe" + ? webExecutionObservationSchema.parse(value).instrumentation.cleanup + : webEventListenersObservationSchema.parse(value).cleanup; + expect(cleanup(healthy.value.normalized_result)).toBe("confirmed"); + deny = true; + const failed = await run(); + if (failed.ok) throw new Error("Rejected cleanup cannot be successful"); + expect(failed.error.cleanupIncomplete).toBe(true); + const partial = parseEvidence(failed.error.partialObservation); + expect(cleanup(partial.normalized_result)).toBe("unconfirmed"); + expect( + (operation === "observe" + ? webExecutionSchema + : webEventListenersSchema + ).safeParse(partial.normalized_result).success, + ).toBe(false); + expect(partial.normalized_result).toMatchObject( + operation === "observe" + ? { + target: { target_id: "allowed-page" }, + coverage: { state: "captured" }, + sources: expect.any(Array), + } + : { + target: { target_id: "allowed-page" }, + selected_node: { match: "not_found" }, + listeners: [], + }, + ); + expect(JSON.stringify(failed.error.diagnostics)).toContain("Page.disable"); + expect(browser.commands.map(({ method }) => method)).toContain( + "Target.detachFromTarget", + ); + expect(browser.commands.map(({ method }) => method)).not.toContain( + "Browser.close", + ); + expect(browser.commands.map(({ method }) => method)).not.toContain( + "Target.closeTarget", + ); + }, +); + +it("classifies failures by their admitted document request and retires completed identities", async () => { + const browser = await startFakeCdpBrowser(); + const provider = new CdpBrowserProvider(); + onTestFinished(async () => { + await provider.close(); + await browser.close(); + }); + const result = await provider.observeSession( + observeWebSessionInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + observation_ms: 20, + }), + { + progress: { + report: async (update) => { + if (update.completed !== 1) return; + const request = ( + id: string | number, + type: string, + frameId: string, + redirect = false, + ) => + browser.emitEvent({ + method: "Network.requestWillBeSent", + sessionId: "session-1", + params: { + requestId: id, + type, + frameId, + loaderId: "observed-loader", + request: { + url: `${browser.allowedOrigin}/destination`, + method: "GET", + }, + timestamp: 100, + ...(redirect ? { redirectResponse: { status: 302 } } : {}), + }, + }); + const fail = (id: string, type: string) => + browser.emitEvent({ + method: "Network.loadingFailed", + sessionId: "session-1", + params: { + requestId: id, + type, + timestamp: 101, + errorText: "net::ERR_CONNECTION_REFUSED", + }, + }); + request("image", "Image", "frame-main"); + fail("image", "Image"); + request("child", "Document", "child-frame"); + fail("child", "Document"); + fail("unobserved", "Document"); + request(42, "Document", "frame-main"); + request("complete", "Document", "frame-main"); + browser.emitEvent({ + method: "Network.loadingFinished", + sessionId: "session-1", + params: { requestId: "complete", timestamp: 101 }, + }); + fail("complete", "Document"); + request("reused", "Document", "frame-main"); + request("reused", "Image", "frame-main"); + fail("reused", "Image"); + request("navigation", "Document", "frame-main", true); + fail("navigation", "Document"); + fail("navigation", "Document"); + }, + }, + }, + ); + if (!result.ok) throw result.error; + expect( + result.value.timeline.map(({ type, request_id }) => [type, request_id]), + ).toEqual([ + ["redirect", "navigation"], + ["load_failed", "navigation"], + ]); + expect(result.value.completeness.excluded).toContainEqual({ + section: "timeline", + reason: "invalid_protocol_value", + count: 1, + }); + expect(result.value.timeline[1]).toMatchObject({ + frame_id: "frame-main", + loader_id: "observed-loader", + detail: "net::ERR_CONNECTION_REFUSED", + }); +}); + +it.each([false, true])( + "keeps cleanup facts truthful when cancellation arrives during finalization (rejected=$0)", + async (reject) => { + const controller = new AbortController(); + const reason = { stop: "during final cleanup" }; + const browser = await startRuntimeBrowser({ + commandError: (command) => { + if (command.method !== "Page.disable") return undefined; + controller.abort(reason); + return reject + ? { code: -32000, message: "injected cleanup rejection after abort" } + : undefined; + }, + }); + const provider = new CdpWebRuntimeProvider(); + onTestFinished(async () => { + await provider.close(); + await browser.close(); + }); + const result = await new WebRuntimeService(provider).observe( + { + cdp_endpoint: browser.endpoint, + target_id: "allowed-page", + observation_ms: 5, + }, + { signal: controller.signal }, + ); + if (result.ok) + throw new Error("Finalization cancellation must be retained"); + const primary = result.error.cleanupPrimary ?? result.error; + expect(primary).toMatchObject({ _tag: "AnalysisCancelledError" }); + expect(primary.cause).toBe(reason); + expect(result.error.cleanupIncomplete).toBe(reject); + const partial = parseEvidence(result.error.partialObservation); + expect( + webExecutionObservationSchema.parse(partial.normalized_result) + .instrumentation.cleanup, + ).toBe(reject ? "unconfirmed" : "confirmed"); + await waitFor( + () => browser.activeConnectionCount() === 0, + "Runtime transport did not close after finalization", + ); + }, +); diff --git a/tests/boundary/browser/cdpObservationWindowBuffer.test.ts b/tests/boundary/browser/cdpObservationWindowBuffer.test.ts index 4366a72369..7e34764306 100644 --- a/tests/boundary/browser/cdpObservationWindowBuffer.test.ts +++ b/tests/boundary/browser/cdpObservationWindowBuffer.test.ts @@ -73,23 +73,45 @@ const clockResponseEvents: NonNullable = ( request: { url: `${origin}/after-cutoff` }, }, }, + { + method: "Network.requestWillBeSent", + sessionId: command.sessionId, + params: { + requestId: "failure-before-window", + type: "Document", + frameId: "frame-main", + timestamp: 18, + request: { url: `${origin}/document`, method: "GET" }, + }, + }, { method: "Network.loadingFailed", sessionId: command.sessionId, params: { requestId: "failure-before-window", timestamp: 19, - frameId: "frame-main", + type: "Document", errorText: "net::ERR_CONNECTION_REFUSED", }, }, + { + method: "Network.requestWillBeSent", + sessionId: command.sessionId, + params: { + requestId: "failure-window", + type: "Document", + frameId: "frame-main", + timestamp: 21, + request: { url: `${origin}/document`, method: "GET" }, + }, + }, { method: "Network.loadingFailed", sessionId: command.sessionId, params: { requestId: "failure-window", timestamp: 22, - frameId: "frame-main", + type: "Document", errorText: "net::ERR_CONNECTION_REFUSED", }, }, diff --git a/tests/boundary/browser/cdpObservationWindowScope.test.ts b/tests/boundary/browser/cdpObservationWindowScope.test.ts index c07188c7a6..b72454cddc 100644 --- a/tests/boundary/browser/cdpObservationWindowScope.test.ts +++ b/tests/boundary/browser/cdpObservationWindowScope.test.ts @@ -345,12 +345,23 @@ it("represents absent timeline timestamps as unknown and preserves an observed z navigationType: "historyApi", }, }, + { + method: "Network.requestWillBeSent", + sessionId: command.sessionId, + params: { + requestId: "actual-zero-clock-value", + type: "Document", + frameId: "frame-main", + timestamp: 0, + request: { url: `${origin}/document`, method: "GET" }, + }, + }, { method: "Network.loadingFailed", sessionId: command.sessionId, params: { requestId: "actual-zero-clock-value", - frameId: "frame-main", + type: "Document", errorText: "net::ERR_CONNECTION_REFUSED", timestamp: 0, }, diff --git a/tests/boundary/browser/cdpSessionRequestBudget.test.ts b/tests/boundary/browser/cdpSessionRequestBudget.test.ts new file mode 100644 index 0000000000..bdeae17e8a --- /dev/null +++ b/tests/boundary/browser/cdpSessionRequestBudget.test.ts @@ -0,0 +1,69 @@ +import { expect, it, onTestFinished, vi } from "vitest"; +import { CdpBrowserProvider } from "../../../src/browser/CdpBrowserProvider.js"; +import { observeWebSessionInputSchema } from "../../../src/domain/browserSession.js"; +import { startFakeCdpBrowser } from "../../fixtures/fakeCdpBrowser.js"; + +// Scale only the existing retained-event allowance to exercise refusal without +// allocating megabytes. The real HTTP/WebSocket owner and producer parser run. +vi.mock("../../../src/domain/webRuntime.js", async (importOriginal) => { + const domain = + await importOriginal(); + return { + ...domain, + WEB_RUNTIME_LIMITS: { + ...domain.WEB_RUNTIME_LIMITS, + retainedEventBytes: 128, + }, + }; +}); + +it("refuses active document identity retention before reporting a complete session", async () => { + const browser = await startFakeCdpBrowser({ commandEvents: () => [] }); + const provider = new CdpBrowserProvider(); + onTestFinished(async () => { + await provider.close(); + await browser.close(); + }); + const result = await provider.observeSession( + observeWebSessionInputSchema.parse({ + cdp_endpoint: browser.endpoint, + target_id: "allowed-page", + allowed_origins: [browser.allowedOrigin], + observation_ms: 1_000, + }), + { + signal: AbortSignal.timeout(2_000), + progress: { + report: async (update) => { + if (update.completed === 1) + browser.emitEvent({ + method: "Network.requestWillBeSent", + sessionId: "session-1", + params: { + requestId: "r".repeat(128), + type: "Document", + frameId: "frame-main", + loaderId: "loader", + timestamp: 100, + request: { + url: `${browser.allowedOrigin}/page`, + method: "GET", + }, + }, + }); + }, + }, + }, + ); + expect(result).toMatchObject({ + ok: false, + error: { + _tag: "BrowserObservationError", + reason: "payload_limit", + detail: expect.stringContaining("Active document request identities"), + }, + }); + expect(browser.commands.map(({ method }) => method)).toContain( + "Target.detachFromTarget", + ); +}); diff --git a/tests/fixtures/fakeCdpBrowserEvents.ts b/tests/fixtures/fakeCdpBrowserEvents.ts index 16dd2879cf..fc66503b6c 100644 --- a/tests/fixtures/fakeCdpBrowserEvents.ts +++ b/tests/fixtures/fakeCdpBrowserEvents.ts @@ -279,10 +279,18 @@ const emitSessionTimeline = ( redirectResponse: { status: 302 }, timestamp: 13, }); - event(socket, "Network.loadingFailed", command.sessionId, { + event(socket, "Network.requestWillBeSent", command.sessionId, { requestId: "failed-request", + type: "Document", frameId: "frame-main", loaderId: "loader-reload", + request: { url: allowed, method: "GET" }, + initiator: { type: "other" }, + timestamp: 13.5, + }); + event(socket, "Network.loadingFailed", command.sessionId, { + requestId: "failed-request", + type: "Document", errorText: "net::ERR_CONNECTION_REFUSED", timestamp: 14, });