From 2a0410f1717b3126e8a95063d77dea66a597010a Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 21 Oct 2026 03:27:31 +0800 Subject: [PATCH] fix(browser)!: bind session timelines to the observation window Sample the browser monotonic clock with observation already subscribed, filter historical lifecycle and network facts, and preserve scope and termination handling when event time is unknown. Bound pre-arm retention using transport-owned wire bytes. Fail closed when the relevant-event buffer fills, and keep unrelated protocol payloads outside that budget. BREAKING CHANGE: observe_web_session timeline timestamps can be null when the producer supplies no comparable timestamp. Consumers must handle unknown time explicitly. Refs #5609 --- docs/browser-observation.md | 14 + src/browser/CdpConnection.ts | 20 +- src/browser/CdpObservationSession.ts | 273 +++++++++-- src/domain/browserSession.ts | 8 +- .../webSessionLifecycleWindow.test.ts | 140 ++++++ ...BrowserProvider.lifecycle_failures.test.ts | 14 +- .../browser/cdpBrowserProvider.support.ts | 8 + tests/boundary/browser/cdpConnection.test.ts | 41 +- .../cdpObservationWindowBuffer.test.ts | 428 ++++++++++++++++++ .../browser/cdpObservationWindowScope.test.ts | 317 +++++++++++++ tests/fixtures/fakeCdpBrowserResults.ts | 4 + tests/fixtures/fakeCdpBrowserTypes.ts | 15 +- 12 files changed, 1218 insertions(+), 64 deletions(-) create mode 100644 tests/acceptance/webSessionLifecycleWindow.test.ts create mode 100644 tests/boundary/browser/cdpObservationWindowBuffer.test.ts create mode 100644 tests/boundary/browser/cdpObservationWindowScope.test.ts diff --git a/docs/browser-observation.md b/docs/browser-observation.md index 6916f96e83..5439244bf1 100644 --- a/docs/browser-observation.md +++ b/docs/browser-observation.md @@ -288,6 +288,20 @@ Call `list_browser_targets` first because target IDs are browser-instance-specif REA establishes a main-frame origin boundary before enabling Runtime, Debugger, or Network observation, rechecks it before document capture, and validates it again afterward. If the main frame navigates during final capture, REA discards the mixed result and returns `target_changed`. +The web-session timeline uses CDP monotonic seconds where the event producer +provides a comparable timestamp; absent or invalid timestamps are `null` rather +than a synthetic zero. Events buffered while REA samples the window boundary are +retained only when their timestamp places them after that boundary; older events +are excluded. A buffered event without a comparable timestamp may remain as +contextual evidence with `timestamp: null`, accompanied by a limitation stating +that it does not establish activity in the selected window. Main-frame scope +enforcement still applies to contextual navigation events. Network redirect +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. + ## Data minimization The Link projection parses repeated CDP header field lines independently and diff --git a/src/browser/CdpConnection.ts b/src/browser/CdpConnection.ts index 2c5512c154..f0588ac3e0 100644 --- a/src/browser/CdpConnection.ts +++ b/src/browser/CdpConnection.ts @@ -15,7 +15,12 @@ const MAX_WEBSOCKET_PAYLOAD_BYTES = 0x7fff_ffff; export interface CdpEvent { readonly method: string; readonly params: unknown; - readonly sessionId?: string; + readonly sessionId?: string | undefined; +} + +export interface CdpTransportEvent extends CdpEvent { + /** Original WebSocket message payload size, before JSON parsing. */ + readonly wireBytes: number; } interface PendingCommand { @@ -29,7 +34,7 @@ interface PendingCommand { /** Correlated JSON-RPC transport for one CDP target operation. */ export class CdpConnection { readonly #pending = new Map(); - readonly #listeners = new Set<(event: CdpEvent) => void>(); + readonly #listeners = new Set<(event: CdpTransportEvent) => void>(); readonly #disconnectListeners = new Set< (error: BrowserObservationError) => void >(); @@ -114,7 +119,7 @@ export class CdpConnection { } /** Subscribe to validated CDP event envelopes. */ - onEvent(listener: (event: CdpEvent) => void): () => void { + onEvent(listener: (event: CdpTransportEvent) => void): () => void { this.#listeners.add(listener); return () => this.#listeners.delete(listener); } @@ -316,9 +321,11 @@ export class CdpConnection { this.#failPending("protocol_error"); return; } - const event: CdpEvent = { + const wireBytes = rawByteLength(data); + const event: CdpTransportEvent = { method: message.method, params: "params" in message ? message.params : {}, + wireBytes, ...(typeof message.sessionId === "string" ? { sessionId: message.sessionId } : {}), @@ -437,6 +444,11 @@ const rawText = (data: RawData): string => ? data.toString("utf8") : Buffer.from(data).toString("utf8"); +const rawByteLength = (data: RawData): number => + Array.isArray(data) + ? data.reduce((bytes, chunk) => bytes + chunk.byteLength, 0) + : data.byteLength; + const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); diff --git a/src/browser/CdpObservationSession.ts b/src/browser/CdpObservationSession.ts index 5374573644..3500a3444d 100644 --- a/src/browser/CdpObservationSession.ts +++ b/src/browser/CdpObservationSession.ts @@ -5,8 +5,13 @@ import type { } from "../domain/browserSession.js"; import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; import { BrowserObservationError } from "../domain/browserObservationError.js"; +import { WEB_RUNTIME_LIMITS } from "../domain/webRuntime.js"; import type { CdpEndpointDiscovery, CdpEndpointTarget } from "./CdpEndpoint.js"; -import { CdpConnection, type CdpEvent } from "./CdpConnection.js"; +import { + CdpConnection, + type CdpEvent, + type CdpTransportEvent, +} from "./CdpConnection.js"; import { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js"; import { authorizedMainFrame } from "./CdpAuthorizedMainFrame.js"; import { @@ -15,6 +20,7 @@ import { isHttpUrl, numberValue, recordValue, + recordsValue, cdpStringValue, type UnknownRecord, } from "./CdpCaptureValues.js"; @@ -38,6 +44,7 @@ type TimelineEvent = WebObservationSession["timeline"][number]; interface TimelineEntryOptions { readonly type: TimelineEvent["type"]; readonly params: UnknownRecord; + readonly timestamp?: number | null; readonly rawUrl?: string | null; readonly requestId?: string | null; readonly detail?: string | null; @@ -60,9 +67,7 @@ export const observeCdpSession = async ( if (mainFrameId === undefined) throw new BrowserObservationError("inspect_web_page", "protocol_error"); const capture = new TimelineCapture(allowedOrigins, mainFrameId, initialUrl); - const removeListener = context.connection.onEvent((event) => { - if (cdpTargetEventMatches(event, context.sessionId)) capture.ingest(event); - }); + let removeListener = (): void => {}; const removeDisconnectListener = context.connection.onDisconnect(() => capture.targetTerminated(), ); @@ -79,13 +84,35 @@ export const observeCdpSession = async ( context.sessionId, context.signal, ); + await context.connection.send( + "Performance.enable", + { timeDomain: "timeTicks" }, + context.sessionId, + context.signal, + ); + removeListener = context.connection.onEvent((event) => { + if (cdpTargetEventMatches(event, context.sessionId)) + capture.ingest(event); + }); + // Host wall time marks command dispatch. The returned browser monotonic + // timestamp is the authoritative cutoff for timestamped lifecycle facts. + const armedAt = new Date().toISOString(); + const armTimestamp = performanceTimestamp( + await context.connection.send( + "Performance.getMetrics", + {}, + context.sessionId, + context.signal, + ), + ); + capture.assertPreArmBufferWithinLimit(); + capture.arm(armTimestamp); await context.connection.send( "Page.setLifecycleEventsEnabled", { enabled: true }, context.sessionId, context.signal, ); - const armedAt = new Date().toISOString(); await context.progress?.report({ phase: "browser_observation", completed: 1, @@ -127,6 +154,11 @@ export const observeCdpSession = async ( "The timeline begins after REA attaches; earlier navigation and network activity is unavailable.", "Only navigation metadata is retained; headers, bodies, cookies, console values, and payloads are not captured by this tool.", "An out-of-policy destination ends collection immediately and is recorded without its URL.", + ...(capture.hasUnknownEventTime + ? [ + "Timeline rows without a comparable producer timestamp have a null timestamp. Buffered events with unknown timing are retained as context only; they do not establish activity inside the selected observation window.", + ] + : []), ], }; } finally { @@ -143,6 +175,11 @@ class TimelineCapture { #pendingReload = false; #scopeExcluded = false; #endReason: EndReason | undefined; + #armTimestamp: number | undefined; + #eventsBeforeArm: CdpTransportEvent[] = []; + #eventsBeforeArmBytes = 0; + #preArmBufferExceeded = false; + #hasUnknownEventTime = false; readonly #listeners = new Set<(reason: EndReason) => void>(); constructor( @@ -154,36 +191,87 @@ class TimelineCapture { allowedSanitizedUrl(initialUrl, allowedOrigins)?.url ?? null; } - ingest(event: CdpEvent): void { + ingest(event: CdpTransportEvent): void { + if (this.#endReason !== undefined || this.#scopeExcluded) return; + if (!isTimelineEvent(event.method)) return; + if (this.#armTimestamp === undefined) { + this.#bufferBeforeArm(event); + return; + } + this.#ingestArmed(event); + } + + #ingestArmed(event: CdpTransportEvent, bufferedBeforeArm = false): void { if (this.#endReason !== undefined || this.#scopeExcluded) return; const params = recordValue(event.params); if (params === undefined) return; + const contextOnly = + bufferedBeforeArm && hasNoComparableProducerTimestamp(event.method); + if (contextOnly) this.#hasUnknownEventTime = true; switch (event.method) { case "Page.frameRequestedNavigation": - this.#navigationRequested(event, params); + this.#navigationRequested(event, params, contextOnly); break; case "Page.frameNavigated": - this.#navigationCommitted(event, params); + this.#navigationCommitted(event, params, contextOnly); break; case "Page.navigatedWithinDocument": - this.#sameDocument(event, params); + this.#sameDocument(event, params, contextOnly); break; case "Network.requestWillBeSent": - this.#redirect(params); + this.#redirect(params, bufferedBeforeArm); break; case "Network.loadingFailed": - this.#loadingFailed(params); + this.#loadingFailed(params, bufferedBeforeArm); break; case "Page.lifecycleEvent": this.#lifecycle(params); break; case "Inspector.targetCrashed": case "Target.detachedFromTarget": - this.targetTerminated(); + this.targetTerminated(params); break; } } + arm(timestamp: number): void { + this.#armTimestamp = timestamp; + // Events can arrive after the browser samples Timestamp but before its + // command response. Defer interpretation so timestamped facts use the + // sampled cutoff instead of being lost at the transport boundary. + const pending = this.#eventsBeforeArm; + this.#eventsBeforeArm = []; + this.#eventsBeforeArmBytes = 0; + for (const event of pending) this.#ingestArmed(event, true); + } + + assertPreArmBufferWithinLimit(): void { + if (!this.#preArmBufferExceeded) return; + this.#eventsBeforeArm = []; + this.#eventsBeforeArmBytes = 0; + throw new BrowserObservationError("observe_web_session", "payload_limit", { + detail: `The retained CDP event buffer exceeded its ${String(WEB_RUNTIME_LIMITS.retainedEventBytes)} byte limit before the observation clock was sampled.`, + }); + } + + #bufferBeforeArm(event: CdpTransportEvent): void { + if (this.#preArmBufferExceeded) return; + // The transport carries the original payload length, so budget admission + // does not reconstruct or reserialize parsed protocol data. + const bytes = event.wireBytes; + if ( + bytes > + WEB_RUNTIME_LIMITS.retainedEventBytes - this.#eventsBeforeArmBytes + ) { + this.#preArmBufferExceeded = true; + this.#eventsBeforeArm = []; + this.#eventsBeforeArmBytes = 0; + return; + } + this.#eventsBeforeArm.push(event); + this.#eventsBeforeArmBytes += bytes; + } + onEnd(listener: (reason: EndReason) => void): () => void { if (this.#endReason !== undefined) listener(this.#endReason); else this.#listeners.add(listener); @@ -197,11 +285,12 @@ class TimelineCapture { this.#listeners.clear(); } - targetTerminated(): void { + targetTerminated(params: UnknownRecord = {}): void { if (this.#endReason !== undefined) return; this.#add({ type: "target_terminated", - params: {}, + params, + timestamp: null, rawUrl: null, requestId: null, detail: "target_terminated", @@ -219,6 +308,10 @@ class TimelineCapture { return this.#scopeExcluded; } + get hasUnknownEventTime(): boolean { + return this.#hasUnknownEventTime; + } + excludeTargetScope( reason: "invalid_protocol_value" | "out_of_target_scope", ): void { @@ -228,58 +321,76 @@ class TimelineCapture { this.completeness.exclude("timeline", reason); } - #navigationRequested(event: CdpEvent, params: UnknownRecord): void { + #navigationRequested( + event: CdpEvent, + params: UnknownRecord, + contextOnly: boolean, + ): void { if (!isMainFrameNavigation(event, this.mainFrameId)) return; const reason = safeDetail(params.reason); - this.#pendingReload = reason === "reload"; + const rawUrl = cdpStringValue(params.url); + const destination = + rawUrl === undefined ? undefined : scopedUrl(rawUrl, this.allowedOrigins); + if (!contextOnly) this.#pendingReload = reason === "reload"; this.#add({ type: "navigation_requested", params, - rawUrl: cdpStringValue(params.url) ?? null, + timestamp: null, + rawUrl: rawUrl ?? null, requestId: null, detail: reason, }); + if (destination !== undefined) this.#stopIfOutOfScope(destination); } - #navigationCommitted(event: CdpEvent, params: UnknownRecord): void { + #navigationCommitted( + event: CdpEvent, + params: UnknownRecord, + contextOnly: boolean, + ): void { const frame = recordValue(params.frame); if (!isMainFrameNavigation(event, this.mainFrameId)) return; const rawUrl = cdpStringValue(frame?.url); const destination = scopedUrl(rawUrl, this.allowedOrigins); this.#add({ - type: this.#pendingReload ? "same_origin_reload" : "navigation_committed", + type: + !contextOnly && this.#pendingReload + ? "same_origin_reload" + : "navigation_committed", params: { ...params, frameId: frame?.id, loaderId: frame?.loaderId }, + timestamp: null, rawUrl: rawUrl ?? null, requestId: null, detail: safeDetail(frame?.transitionType), }); + if (this.#stopIfOutOfScope(destination)) return; + if (contextOnly) return; this.#pendingReload = false; - if (destination.scope === "approved") this.finalUrl = destination.url; - else { - this.excludeTargetScope("out_of_target_scope"); - this.end("target_left_scope"); - } + this.finalUrl = destination.url; } - #sameDocument(event: CdpEvent, params: UnknownRecord): void { + #sameDocument( + event: CdpEvent, + params: UnknownRecord, + contextOnly: boolean, + ): void { if (!isMainFrameNavigation(event, this.mainFrameId)) return; const rawUrl = cdpStringValue(params.url); const destination = scopedUrl(rawUrl, this.allowedOrigins); this.#add({ type: "same_document_navigation", params, + timestamp: null, rawUrl: rawUrl ?? null, requestId: null, detail: null, }); - if (destination.scope === "approved") this.finalUrl = destination.url; - else { - this.excludeTargetScope("out_of_target_scope"); - this.end("target_left_scope"); - } + if (this.#stopIfOutOfScope(destination)) return; + if (contextOnly) return; + this.finalUrl = destination.url; } - #redirect(params: UnknownRecord): void { + #redirect(params: UnknownRecord, bufferedBeforeArm: boolean): void { const resourceType = cdpStringValue(params.type); if (resourceType !== undefined && resourceType !== "Document") return; if ( @@ -287,36 +398,82 @@ class TimelineCapture { recordValue(params.redirectResponse) === undefined ) return; + const eventTime = this.#networkEventTime(params, bufferedBeforeArm); + if (!eventTime.keep) return; const request = recordValue(params.request); const rawUrl = cdpStringValue(request?.url); const destination = scopedUrl(rawUrl, this.allowedOrigins); this.#add({ type: "redirect", params, + ...(eventTime.timestamp === undefined + ? {} + : { timestamp: eventTime.timestamp }), rawUrl: rawUrl ?? null, requestId: cdpStringValue(params.requestId) ?? null, detail: null, }); - if (destination.scope !== "approved") { - this.excludeTargetScope("out_of_target_scope"); - this.end("target_left_scope"); - } + if (this.#stopIfOutOfScope(destination)) return; } - #loadingFailed(params: UnknownRecord): void { + #stopIfOutOfScope(destination: { + readonly url: string | null; + readonly scope: "approved" | "outside_policy" | "unsupported"; + }): boolean { + if (destination.scope === "approved") return false; + this.excludeTargetScope("out_of_target_scope"); + this.end("target_left_scope"); + return true; + } + + #loadingFailed(params: UnknownRecord, bufferedBeforeArm: boolean): void { const frame = cdpStringValue(params.frameId); if (frame !== undefined && frame !== this.mainFrameId) return; + const eventTime = this.#networkEventTime(params, bufferedBeforeArm); + if (!eventTime.keep) return; this.#add({ type: "load_failed", params, + ...(eventTime.timestamp === undefined + ? {} + : { timestamp: eventTime.timestamp }), rawUrl: null, requestId: cdpStringValue(params.requestId) ?? null, detail: safeNetworkError(params.errorText), }); } + #networkEventTime( + params: UnknownRecord, + bufferedBeforeArm: boolean, + ): { + readonly keep: boolean; + readonly timestamp?: number | null; + } { + if (!bufferedBeforeArm) return { keep: true }; + const timestamp = numberValue(params.timestamp); + if (timestamp === undefined || timestamp < 0) { + this.completeness.exclude("timeline", "invalid_protocol_value"); + this.#hasUnknownEventTime = true; + return { keep: true, timestamp: null }; + } + if (this.#armTimestamp === undefined || timestamp <= this.#armTimestamp) + return { keep: false, timestamp }; + return { keep: true, timestamp }; + } + #lifecycle(params: UnknownRecord): void { if (cdpStringValue(params.frameId) !== this.mainFrameId) return; + const timestamp = numberValue(params.timestamp); + if ( + timestamp === undefined || + timestamp < 0 || + this.#armTimestamp === undefined + ) { + this.completeness.exclude("timeline", "invalid_protocol_value"); + return; + } + if (timestamp <= this.#armTimestamp) return; this.#add({ type: "lifecycle", params, @@ -336,11 +493,27 @@ class TimelineCapture { } = options; const destination = rawUrl == null ? null : scopedUrl(rawUrl, this.allowedOrigins); + const observedTimestamp = numberValue(params.timestamp); + const timestamp = + options.timestamp === undefined ? observedTimestamp : options.timestamp; + if ( + timestamp === undefined || + timestamp === null || + (timestamp !== null && timestamp < 0) + ) { + this.#hasUnknownEventTime = true; + } + if (timestamp !== null && timestamp !== undefined && timestamp < 0) { + this.completeness.exclude("timeline", "invalid_protocol_value"); + } this.#sequence += 1; this.timeline.push({ sequence: this.#sequence, type, - timestamp: Math.max(0, numberValue(params.timestamp) ?? 0), + timestamp: + timestamp === undefined || (timestamp !== null && timestamp < 0) + ? null + : timestamp, frame_id: boundedId(params.frameId), loader_id: boundedId(params.loaderId), request_id: requestId ?? null, @@ -351,6 +524,19 @@ class TimelineCapture { } } +const performanceTimestamp = (value: unknown): number => { + const metrics = recordsValue(recordValue(value)?.metrics).filter( + ({ name }) => name === "Timestamp", + ); + const timestamp = numberValue(metrics[0]?.value); + if (metrics.length !== 1 || timestamp === undefined || timestamp < 0) + throw new BrowserObservationError("observe_web_session", "protocol_error", { + detail: + "Performance.getMetrics returned no valid monotonic Timestamp metric.", + }); + return timestamp; +}; + const waitForWindow = async ( durationMs: number, capture: TimelineCapture, @@ -423,6 +609,19 @@ const frameId = (result: unknown): string | undefined => recordValue(recordValue(recordValue(result)?.frameTree)?.frame)?.id, ); +const hasNoComparableProducerTimestamp = (method: string): boolean => + method === "Page.frameRequestedNavigation" || + method === "Page.frameNavigated" || + method === "Page.navigatedWithinDocument" || + method === "Inspector.targetCrashed" || + method === "Target.detachedFromTarget"; + +const isTimelineEvent = (method: string): boolean => + hasNoComparableProducerTimestamp(method) || + method === "Network.requestWillBeSent" || + method === "Network.loadingFailed" || + method === "Page.lifecycleEvent"; + const scopedUrl = ( value: string | undefined, allowedOrigins: ReadonlySet, diff --git a/src/domain/browserSession.ts b/src/domain/browserSession.ts index c2f00e9a28..d36737795c 100644 --- a/src/domain/browserSession.ts +++ b/src/domain/browserSession.ts @@ -30,7 +30,13 @@ const timelineEventSchema = z.object({ "lifecycle", "target_terminated", ]), - timestamp: z.number().min(0), + timestamp: z + .number() + .min(0) + .nullable() + .describe( + "CDP monotonic seconds when supplied; null when the event has no valid comparable producer timestamp.", + ), frame_id: z.string().nullable(), loader_id: z.string().nullable(), request_id: z.string().nullable(), diff --git a/tests/acceptance/webSessionLifecycleWindow.test.ts b/tests/acceptance/webSessionLifecycleWindow.test.ts new file mode 100644 index 0000000000..ee30f32e89 --- /dev/null +++ b/tests/acceptance/webSessionLifecycleWindow.test.ts @@ -0,0 +1,140 @@ +import { expect, onTestFinished } from "vitest"; +import { z } from "zod"; + +import { webObservationSessionSchema } from "../../src/domain/browserSession.js"; +import { toolContract } from "../../src/contracts/toolContracts.js"; +import { connectLocalToolsMcp } from "../fixtures/localToolsMcp.js"; +import { startFakeCdpBrowser } from "../fixtures/fakeCdpBrowser.js"; +import { cliTest } from "../support/cli/cliFixture.js"; + +cliTest.for(["CLI", "stdio MCP"] as const)( + "filters replayed lifecycle milestones before the observation window through %s", + async (surface, { cli }) => { + const browser = await startFakeCdpBrowser({ + eventsBeforeReply: [ + "Performance.getMetrics", + "Page.setLifecycleEventsEnabled", + ], + commandResult: (command) => { + if (command.method === "Performance.enable") return {}; + if (command.method === "Performance.getMetrics") + return { metrics: [{ name: "Timestamp", value: 20 }] }; + return undefined; + }, + commandEvents: (command, origin) => { + if (command.method === "Performance.getMetrics") + return [ + { + method: "Page.lifecycleEvent", + sessionId: command.sessionId, + params: { + frameId: "frame-main", + loaderId: "loader-before-window", + name: "load", + timestamp: 19, + }, + }, + { + method: "Page.navigatedWithinDocument", + sessionId: command.sessionId, + params: { + frameId: "frame-main", + url: `${origin}#during-clock-response`, + navigationType: "historyApi", + }, + }, + ]; + if (command.method === "Page.setLifecycleEventsEnabled") + return [ + { + method: "Page.lifecycleEvent", + sessionId: command.sessionId, + params: { + frameId: "frame-main", + loaderId: "loader-before-window-replayed-after-arm", + name: "load", + timestamp: 19, + }, + }, + { + method: "Page.lifecycleEvent", + sessionId: command.sessionId, + params: { + frameId: "frame-main", + loaderId: "loader-current-window", + name: "load", + timestamp: 21, + }, + }, + ]; + return undefined; + }, + }); + onTestFinished(() => browser.close()); + + let evidence: unknown; + if (surface === "CLI") { + const response = await cli.run({ + arguments: [ + "observe-web-session", + browser.endpoint, + "allowed-page", + "--allowed-origins", + browser.allowedOrigin, + "--observation-ms", + "5", + "--json", + ], + }); + expect(response.exitCode, response.stderr).toBe(0); + evidence = response.json; + } else { + const { call } = await connectLocalToolsMcp(); + const response = await call("observe_web_session", { + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + observation_ms: 5, + }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + evidence = response.structuredContent; + } + + const contract = toolContract("observe_web_session"); + const sourceEvidence = contract.outputSchema.parse(evidence); + const normalized = z + .object({ normalized_result: webObservationSessionSchema }) + .parse(sourceEvidence).normalized_result; + expect(normalized.timeline).toContainEqual( + expect.objectContaining({ + type: "lifecycle", + loader_id: "loader-current-window", + timestamp: 21, + }), + ); + expect(normalized.timeline).not.toContainEqual( + expect.objectContaining({ loader_id: "loader-before-window" }), + ); + expect(normalized.timeline).not.toContainEqual( + expect.objectContaining({ + loader_id: "loader-before-window-replayed-after-arm", + }), + ); + expect(normalized.timeline).toContainEqual( + expect.objectContaining({ + type: "same_document_navigation", + url: `${browser.allowedOrigin}#during-clock-response`, + timestamp: null, + }), + ); + expect(normalized.limitations).toContain( + "Timeline rows without a comparable producer timestamp have a null timestamp. Buffered events with unknown timing are retained as context only; they do not establish activity inside the selected observation window.", + ); + expect(browser.commands).toContainEqual( + expect.objectContaining({ + method: "Performance.enable", + params: { timeDomain: "timeTicks" }, + }), + ); + }, +); diff --git a/tests/boundary/browser/cdpBrowserProvider.lifecycle_failures.test.ts b/tests/boundary/browser/cdpBrowserProvider.lifecycle_failures.test.ts index 3aa173cf2e..2dba76b7e5 100644 --- a/tests/boundary/browser/cdpBrowserProvider.lifecycle_failures.test.ts +++ b/tests/boundary/browser/cdpBrowserProvider.lifecycle_failures.test.ts @@ -14,15 +14,7 @@ import { startFakeCdpBrowser, type FakeCdpBrowser, } from "../../fixtures/fakeCdpBrowser.js"; -import { trackBrowser } from "./cdpBrowserProvider.support.js"; - -const waitFor = async (ready: () => boolean, description: string) => { - const deadline = Date.now() + 5_000; - while (!ready()) { - if (Date.now() > deadline) throw new Error(description); - await new Promise((resolve) => setTimeout(resolve, 10)); - } -}; +import { trackBrowser, waitFor } from "./cdpBrowserProvider.support.js"; describe("CdpBrowserProvider: cancellation during cleanup", () => { it.each([ @@ -378,7 +370,11 @@ describe("CdpBrowserProvider: session termination and scope changes", () => { expect(result.value.timeline.at(-1)).toMatchObject({ type: "target_terminated", detail: "target_terminated", + timestamp: null, }); + expect(result.value.limitations).toContain( + "Timeline rows without a comparable producer timestamp have a null timestamp. Buffered events with unknown timing are retained as context only; they do not establish activity inside the selected observation window.", + ); }); it("ends a direct page session when its transport disconnects", async () => { diff --git a/tests/boundary/browser/cdpBrowserProvider.support.ts b/tests/boundary/browser/cdpBrowserProvider.support.ts index 8e59d2d031..ebb688df55 100644 --- a/tests/boundary/browser/cdpBrowserProvider.support.ts +++ b/tests/boundary/browser/cdpBrowserProvider.support.ts @@ -11,3 +11,11 @@ afterEach(async () => { export const trackBrowser = (browser: FakeCdpBrowser): void => { browsers.push(browser); }; + +export const waitFor = async (ready: () => boolean, description: string) => { + const deadline = Date.now() + 5_000; + while (!ready()) { + if (Date.now() > deadline) throw new Error(description); + await new Promise((resolve) => setTimeout(resolve, 10)); + } +}; diff --git a/tests/boundary/browser/cdpConnection.test.ts b/tests/boundary/browser/cdpConnection.test.ts index 592fe350d9..2ec08c8e49 100644 --- a/tests/boundary/browser/cdpConnection.test.ts +++ b/tests/boundary/browser/cdpConnection.test.ts @@ -4,7 +4,10 @@ import { createServer as createNetServer, type Socket } from "node:net"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { CdpConnection } from "../../../src/browser/CdpConnection.js"; +import { + CdpConnection, + type CdpTransportEvent, +} from "../../../src/browser/CdpConnection.js"; import { AnalysisCancelledError, AnalysisOutputError, @@ -125,6 +128,42 @@ describe("CDP connection response correlation", () => { await connection.close(); } }); + + it("carries the original event payload byte count before parsing", async () => { + const browser = await startFakeCdpBrowser(); + browsers.push(browser); + const connection = await CdpConnection.connect( + browser.browserWebSocketUrl, + "inspect_web_page", + ); + try { + const eventMessage = String.raw` { "method" : "Fixture.event", "params" : {"text":"escaped quote: \" slash: \\ unicode: \u96ea"}, "sessionId" : "session-1", "extra" : "counted from source bytes" } `; + let deliveredEvents = 0; + const eventPromise = new Promise((resolve) => { + connection.onEvent((event) => { + deliveredEvents += 1; + if (event.method === "Fixture.event") resolve(event); + }); + }); + browser.emitRawMessage(eventMessage); + const event = await eventPromise; + + expect(event.wireBytes).toBe(Buffer.byteLength(eventMessage, "utf8")); + expect(event.params).toEqual({ + text: 'escaped quote: " slash: \\ unicode: 雪', + }); + expect(deliveredEvents).toBe(1); + + const protocolFailure = new Promise((resolve) => + connection.onProtocolFailure(resolve), + ); + browser.emitRawMessage("{not-json"); + await protocolFailure; + expect(deliveredEvents).toBe(1); + } finally { + await connection.close(); + } + }); }); describe("CDP command cancellation causes", () => { diff --git a/tests/boundary/browser/cdpObservationWindowBuffer.test.ts b/tests/boundary/browser/cdpObservationWindowBuffer.test.ts new file mode 100644 index 0000000000..4366a72369 --- /dev/null +++ b/tests/boundary/browser/cdpObservationWindowBuffer.test.ts @@ -0,0 +1,428 @@ +import { expect, it, onTestFinished, vi } from "vitest"; + +import { CdpBrowserProvider } from "../../../src/browser/CdpBrowserProvider.js"; +import { CdpConnection } from "../../../src/browser/CdpConnection.js"; +import { recordValue } from "../../../src/browser/CdpCaptureValues.js"; +import { observeWebSessionInputSchema } from "../../../src/domain/browserSession.js"; +import { + startFakeCdpBrowser, + type FakeOptions, +} from "../../fixtures/fakeCdpBrowser.js"; +import { trackBrowser, waitFor } from "./cdpBrowserProvider.support.js"; + +const clockResponseEvents: NonNullable = ( + command, + origin, +) => { + if (command.method === "Page.enable") + return [ + { + method: "Page.frameNavigated", + sessionId: command.sessionId, + params: { + frame: { + id: "frame-main", + loaderId: "loader-before-window", + url: `${origin}/initial`, + }, + }, + }, + ]; + if (command.method === "Performance.getMetrics") + return [ + { + method: "Page.lifecycleEvent", + sessionId: command.sessionId, + params: { + frameId: "frame-main", + loaderId: "loader-before-window", + name: "load", + timestamp: 19, + }, + }, + { + method: "Page.navigatedWithinDocument", + sessionId: command.sessionId, + params: { + frameId: "frame-main", + url: `${origin}#during-clock-response`, + navigationType: "historyApi", + }, + }, + { + method: "Network.requestWillBeSent", + sessionId: command.sessionId, + params: { + requestId: "request-before-window", + timestamp: 19, + type: "Document", + frameId: "frame-main", + redirectResponse: { url: `${origin}/before-redirect` }, + request: { url: `${origin}/before-window` }, + }, + }, + { + method: "Network.requestWillBeSent", + sessionId: command.sessionId, + params: { + requestId: "request-window-redirect", + timestamp: 21, + type: "Document", + frameId: "frame-main", + redirectResponse: { url: `${origin}/prior-hop-context` }, + request: { url: `${origin}/after-cutoff` }, + }, + }, + { + method: "Network.loadingFailed", + sessionId: command.sessionId, + params: { + requestId: "failure-before-window", + timestamp: 19, + frameId: "frame-main", + errorText: "net::ERR_CONNECTION_REFUSED", + }, + }, + { + method: "Network.loadingFailed", + sessionId: command.sessionId, + params: { + requestId: "failure-window", + timestamp: 22, + frameId: "frame-main", + errorText: "net::ERR_CONNECTION_REFUSED", + }, + }, + ]; + if (command.method === "Page.setLifecycleEventsEnabled") + return [ + { + method: "Page.lifecycleEvent", + sessionId: command.sessionId, + params: { + frameId: "frame-main", + loaderId: "loader-current-window", + name: "load", + timestamp: 21, + }, + }, + { + method: "Page.navigatedWithinDocument", + sessionId: command.sessionId, + params: { + frameId: "frame-main", + url: `${origin}#in-window`, + navigationType: "historyApi", + }, + }, + ]; + return undefined; +}; + +it("buffers clock-response events while dropping lifecycle history before the clock boundary", async () => { + const browser = await startFakeCdpBrowser({ + eventsBeforeReply: [ + "Page.enable", + "Performance.getMetrics", + "Page.setLifecycleEventsEnabled", + ], + commandResult: (command) => { + if (command.method === "Performance.enable") return {}; + if (command.method === "Performance.getMetrics") + return { metrics: [{ name: "Timestamp", value: 20 }] }; + return undefined; + }, + commandEvents: clockResponseEvents, + }); + trackBrowser(browser); + const result = await new CdpBrowserProvider().observeSession( + observeWebSessionInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + observation_ms: 20, + }), + ); + + if (!result.ok) throw result.error; + expect(browser.commands.map(({ method }) => method)).toContain( + "Performance.getMetrics", + ); + expect(result.value.timeline).toContainEqual( + expect.objectContaining({ + type: "lifecycle", + loader_id: "loader-current-window", + timestamp: 21, + }), + ); + expect(result.value.timeline).not.toContainEqual( + expect.objectContaining({ loader_id: "loader-before-window" }), + ); + expect(result.value.timeline).not.toContainEqual( + expect.objectContaining({ type: "navigation_committed" }), + ); + expect(result.value.timeline).toContainEqual( + expect.objectContaining({ + type: "same_document_navigation", + url: `${browser.allowedOrigin}#during-clock-response`, + timestamp: null, + }), + ); + expect(result.value.timeline).not.toContainEqual( + expect.objectContaining({ request_id: "request-before-window" }), + ); + expect(result.value.timeline).toContainEqual( + expect.objectContaining({ + type: "redirect", + request_id: "request-window-redirect", + timestamp: 21, + url: `${browser.allowedOrigin}/after-cutoff`, + }), + ); + expect(result.value.timeline).not.toContainEqual( + expect.objectContaining({ request_id: "failure-before-window" }), + ); + expect(result.value.timeline).toContainEqual( + expect.objectContaining({ + type: "load_failed", + request_id: "failure-window", + timestamp: 22, + }), + ); + expect(result.value.limitations).toContain( + "Timeline rows without a comparable producer timestamp have a null timestamp. Buffered events with unknown timing are retained as context only; they do not establish activity inside the selected observation window.", + ); +}); + +it.each([ + ["missing Timestamp", { metrics: [] }], + ["invalid Timestamp", { metrics: [{ name: "Timestamp", value: -1 }] }], +])( + "fails closed when Performance.getMetrics returns %s", + async (_name, resultValue) => { + const browser = await startFakeCdpBrowser({ + commandResult: (command) => + command.method === "Performance.enable" + ? {} + : command.method === "Performance.getMetrics" + ? resultValue + : undefined, + }); + trackBrowser(browser); + const result = await new CdpBrowserProvider().observeSession( + observeWebSessionInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + observation_ms: 20, + }), + ); + + expect(result).toMatchObject({ + ok: false, + error: { + _tag: "BrowserObservationError", + reason: "protocol_error", + }, + }); + expect(browser.commands.map(({ method }) => method)).not.toContain( + "Page.setLifecycleEventsEnabled", + ); + }, +); + +it.each([ + ["out-of-scope navigation", "Page.frameNavigated"], + ["target termination", "Inspector.targetCrashed"], +])( + "fails closed when the pre-clock event buffer is exceeded before %s", + async (_name, eventAfterBudget) => { + const oversizedHash = "x".repeat(8 * 1024 * 1024); + const browser = await startFakeCdpBrowser({ + eventsBeforeReply: ["Performance.getMetrics"], + commandResult: (command) => + command.method === "Performance.enable" + ? {} + : command.method === "Performance.getMetrics" + ? { metrics: [{ name: "Timestamp", value: 20 }] } + : undefined, + commandEvents: (command, origin) => { + if (command.method !== "Performance.getMetrics") return undefined; + return [ + { + method: "Page.frameNavigated", + sessionId: command.sessionId, + params: { + frame: { + id: "frame-main", + loaderId: "oversized-loader", + url: `${origin}#${oversizedHash}`, + }, + }, + }, + eventAfterBudget === "Page.frameNavigated" + ? { + method: "Page.frameNavigated", + sessionId: command.sessionId, + params: { + frame: { + id: "frame-main", + loaderId: "outside-after-budget", + url: "https://outside.example/path", + }, + }, + } + : { + method: "Inspector.targetCrashed", + sessionId: command.sessionId, + params: {}, + }, + ]; + }, + }); + trackBrowser(browser); + const result = await new CdpBrowserProvider().observeSession( + observeWebSessionInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + observation_ms: 20, + }), + ); + + expect(result).toMatchObject({ + ok: false, + error: { + _tag: "BrowserObservationError", + reason: "payload_limit", + userMessage: expect.stringContaining("retained CDP event buffer"), + }, + }); + expect(browser.commands.map(({ method }) => method)).not.toContain( + "Page.setLifecycleEventsEnabled", + ); + }, +); + +it("ignores unrelated protocol payloads before charging the timeline buffer", async () => { + const browser = await startFakeCdpBrowser({ + eventsBeforeReply: ["Performance.getMetrics"], + commandResult: (command) => + command.method === "Performance.getMetrics" + ? { metrics: [{ name: "Timestamp", value: 20 }] } + : undefined, + commandEvents: (command, origin) => { + if (command.method !== "Performance.getMetrics") return undefined; + return [ + { + method: "Network.responseReceived", + sessionId: command.sessionId, + params: { + requestId: "unselected-response", + timestamp: 21, + type: "Document", + response: { + url: origin, + status: 200, + headers: { "x-large-field": "x".repeat(8 * 1024 * 1024) }, + }, + }, + }, + { + method: "Page.navigatedWithinDocument", + sessionId: command.sessionId, + params: { frameId: "frame-main", url: `${origin}#selected` }, + }, + ]; + }, + }); + trackBrowser(browser); + const result = await new CdpBrowserProvider().observeSession( + observeWebSessionInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + observation_ms: 1, + }), + ); + if (!result.ok) throw result.error; + expect(result.value.timeline).toContainEqual( + expect.objectContaining({ + type: "same_document_navigation", + url: `${browser.allowedOrigin}#selected`, + timestamp: null, + }), + ); +}); + +it("keeps cancellation authoritative while an over-budget clock request is pending", async () => { + let received = false; + const onEvent = CdpConnection.prototype.onEvent; + const subscribe = vi + .spyOn(CdpConnection.prototype, "onEvent") + .mockImplementation(function (this: CdpConnection, listener) { + return onEvent.call(this, (event) => { + listener(event); + if ( + event.method === "Page.frameNavigated" && + recordValue(recordValue(event.params)?.frame)?.loaderId === + "oversized-before-clock" + ) + // All subscribers finish synchronously before this microtask, including + // the timeline owner that admits or rejects the received payload. + queueMicrotask(() => { + received = true; + }); + }); + }); + onTestFinished(() => subscribe.mockRestore()); + const browser = await startFakeCdpBrowser({ + hangOnMethod: "Performance.getMetrics", + }); + trackBrowser(browser); + const controller = new AbortController(); + const pending = new CdpBrowserProvider().observeSession( + observeWebSessionInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + observation_ms: 20, + }), + { signal: controller.signal }, + ); + await waitFor( + () => + browser.commands.some( + ({ method }) => method === "Performance.getMetrics", + ), + "the performance clock request did not start", + ); + browser.emitEvent({ + method: "Page.frameNavigated", + sessionId: "session-1", + params: { + frame: { + id: "frame-main", + loaderId: "oversized-before-clock", + url: `https://outside.example/${"x".repeat(8 * 1024 * 1024)}`, + }, + }, + }); + await waitFor(() => received, "the over-budget event was not received"); + controller.abort(); + + await expect(pending).resolves.toMatchObject({ + ok: false, + error: { + _tag: "AnalysisCancelledError", + operation: "observe_web_session", + }, + }); + expect(browser.commands.map(({ method }) => method)).not.toContain( + "Page.setLifecycleEventsEnabled", + ); + await waitFor( + () => browser.activeConnectionCount() === 0, + "the cancelled observation did not release its CDP connection", + ); +}); diff --git a/tests/boundary/browser/cdpObservationWindowScope.test.ts b/tests/boundary/browser/cdpObservationWindowScope.test.ts new file mode 100644 index 0000000000..9d5f44a965 --- /dev/null +++ b/tests/boundary/browser/cdpObservationWindowScope.test.ts @@ -0,0 +1,317 @@ +import { expect, it } from "vitest"; + +import { CdpBrowserProvider } from "../../../src/browser/CdpBrowserProvider.js"; +import { observeWebSessionInputSchema } from "../../../src/domain/browserSession.js"; +import { startFakeCdpBrowser } from "../../fixtures/fakeCdpBrowser.js"; +import { trackBrowser } from "./cdpBrowserProvider.support.js"; + +it("fails closed on an untimestamped cross-origin main-frame commit during clock sampling", async () => { + const browser = await startFakeCdpBrowser({ + eventsBeforeReply: ["Performance.getMetrics"], + commandResult: (command) => + command.method === "Performance.getMetrics" + ? { metrics: [{ name: "Timestamp", value: 20 }] } + : undefined, + commandEvents: (command) => { + if (command.method !== "Performance.getMetrics") return undefined; + return [ + { + method: "Page.frameNavigated", + sessionId: command.sessionId, + params: { + frame: { + id: "frame-main", + loaderId: "outside-loader", + url: "https://outside.invalid/path", + }, + }, + }, + { + method: "Network.requestWillBeSent", + sessionId: command.sessionId, + params: { + requestId: "later-in-window-request", + timestamp: 21, + type: "Document", + frameId: "frame-main", + redirectResponse: { url: `${browser.allowedOrigin}/prior` }, + request: { url: `${browser.allowedOrigin}/later` }, + }, + }, + ]; + }, + }); + trackBrowser(browser); + const result = await new CdpBrowserProvider().observeSession( + observeWebSessionInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + observation_ms: 20, + }), + ); + + if (!result.ok) throw result.error; + expect(result.value.window.end_reason).toBe("target_left_scope"); + expect(result.value.target.final_url).toBeNull(); + expect(result.value.timeline).toContainEqual( + expect.objectContaining({ + type: "navigation_committed", + loader_id: "outside-loader", + timestamp: null, + destination_scope: "outside_policy", + url: null, + }), + ); + expect(result.value.timeline).not.toContainEqual( + expect.objectContaining({ request_id: "later-in-window-request" }), + ); +}); + +it("fails closed on an untimestamped out-of-scope main-frame request during clock sampling", async () => { + const browser = await startFakeCdpBrowser({ + eventsBeforeReply: ["Performance.getMetrics"], + commandResult: (command) => + command.method === "Performance.getMetrics" + ? { metrics: [{ name: "Timestamp", value: 20 }] } + : undefined, + commandEvents: (command, origin) => { + if (command.method !== "Performance.getMetrics") return undefined; + return [ + { + method: "Page.frameRequestedNavigation", + sessionId: command.sessionId, + params: { + frameId: "frame-main", + url: "https://outside.invalid/path", + reason: "scriptInitiated", + }, + }, + { + method: "Network.requestWillBeSent", + sessionId: command.sessionId, + params: { + requestId: "later-in-window-request", + timestamp: 21, + type: "Document", + frameId: "frame-main", + redirectResponse: { url: `${origin}/prior` }, + request: { url: `${origin}/later` }, + }, + }, + ]; + }, + }); + trackBrowser(browser); + const result = await new CdpBrowserProvider().observeSession( + observeWebSessionInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + observation_ms: 20, + }), + ); + + if (!result.ok) throw result.error; + expect(result.value.window.end_reason).toBe("target_left_scope"); + expect(result.value.timeline).toContainEqual( + expect.objectContaining({ + type: "navigation_requested", + timestamp: null, + destination_scope: "outside_policy", + url: null, + }), + ); + expect(result.value.timeline).not.toContainEqual( + expect.objectContaining({ request_id: "later-in-window-request" }), + ); +}); + +it("fails closed on an untimestamped out-of-scope redirect during clock sampling", async () => { + const browser = await startFakeCdpBrowser({ + eventsBeforeReply: ["Performance.getMetrics"], + commandResult: (command) => + command.method === "Performance.getMetrics" + ? { metrics: [{ name: "Timestamp", value: 20 }] } + : undefined, + commandEvents: (command, origin) => { + if (command.method !== "Performance.getMetrics") return undefined; + return [ + { + method: "Network.requestWillBeSent", + sessionId: command.sessionId, + params: { + requestId: "unknown-time-outside-redirect", + type: "Document", + frameId: "frame-main", + redirectResponse: { url: `${origin}/prior` }, + request: { url: "https://outside.invalid/redirected" }, + }, + }, + { + method: "Network.requestWillBeSent", + sessionId: command.sessionId, + params: { + requestId: "later-in-window-request", + timestamp: 21, + type: "Document", + frameId: "frame-main", + redirectResponse: { url: `${origin}/prior` }, + request: { url: `${origin}/later` }, + }, + }, + ]; + }, + }); + trackBrowser(browser); + const result = await new CdpBrowserProvider().observeSession( + observeWebSessionInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + observation_ms: 20, + }), + ); + + if (!result.ok) throw result.error; + expect(result.value.window.end_reason).toBe("target_left_scope"); + expect(result.value.target.final_url).toBeNull(); + expect(result.value.timeline).toContainEqual( + expect.objectContaining({ + type: "redirect", + request_id: "unknown-time-outside-redirect", + timestamp: null, + destination_scope: "outside_policy", + url: null, + }), + ); + expect(result.value.timeline).not.toContainEqual( + expect.objectContaining({ request_id: "later-in-window-request" }), + ); +}); + +it.each([ + [ + "Inspector.targetCrashed", + { + method: "Inspector.targetCrashed", + sessionId: "session-1", + params: {}, + }, + ], + [ + "Target.detachedFromTarget", + { + method: "Target.detachedFromTarget", + params: { sessionId: "session-1", targetId: "allowed-page" }, + }, + ], +] as const)( + "ends on buffered untimestamped %s before collecting later events", + async (_method, terminalEvent) => { + const browser = await startFakeCdpBrowser({ + eventsBeforeReply: ["Performance.getMetrics"], + commandResult: (command) => + command.method === "Performance.getMetrics" + ? { metrics: [{ name: "Timestamp", value: 20 }] } + : undefined, + commandEvents: (command, origin) => { + if (command.method !== "Performance.getMetrics") return undefined; + return [ + terminalEvent, + { + method: "Network.requestWillBeSent", + sessionId: command.sessionId, + params: { + requestId: "after-terminal-event", + timestamp: 21, + type: "Document", + frameId: "frame-main", + redirectResponse: { url: `${origin}/prior` }, + request: { url: `${origin}/later` }, + }, + }, + ]; + }, + }); + trackBrowser(browser); + const result = await new CdpBrowserProvider().observeSession( + observeWebSessionInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + observation_ms: 20, + }), + ); + + if (!result.ok) throw result.error; + expect(result.value.window.end_reason).toBe("target_terminated"); + expect(result.value.timeline).toContainEqual( + expect.objectContaining({ + type: "target_terminated", + timestamp: null, + }), + ); + expect(result.value.timeline).not.toContainEqual( + expect.objectContaining({ request_id: "after-terminal-event" }), + ); + expect(result.value.limitations).toContain( + "Timeline rows without a comparable producer timestamp have a null timestamp. Buffered events with unknown timing are retained as context only; they do not establish activity inside the selected observation window.", + ); + }, +); + +it("represents absent timeline timestamps as unknown and preserves an observed zero", async () => { + const browser = await startFakeCdpBrowser({ + eventsBeforeReply: ["Page.setLifecycleEventsEnabled"], + commandEvents: (command, origin) => { + if (command.method !== "Page.setLifecycleEventsEnabled") return undefined; + return [ + { + method: "Page.navigatedWithinDocument", + sessionId: command.sessionId, + params: { + frameId: "frame-main", + url: `${origin}/without-clock-value`, + navigationType: "historyApi", + }, + }, + { + method: "Network.loadingFailed", + sessionId: command.sessionId, + params: { + requestId: "actual-zero-clock-value", + frameId: "frame-main", + errorText: "net::ERR_CONNECTION_REFUSED", + timestamp: 0, + }, + }, + ]; + }, + }); + trackBrowser(browser); + const result = await new CdpBrowserProvider().observeSession( + observeWebSessionInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + observation_ms: 20, + }), + ); + + if (!result.ok) throw result.error; + expect(result.value.timeline).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "same_document_navigation", + url: `${browser.allowedOrigin}/without-clock-value`, + timestamp: null, + }), + expect.objectContaining({ + type: "load_failed", + request_id: "actual-zero-clock-value", + timestamp: 0, + }), + ]), + ); +}); diff --git a/tests/fixtures/fakeCdpBrowserResults.ts b/tests/fixtures/fakeCdpBrowserResults.ts index 4a489227f6..0ea85ed569 100644 --- a/tests/fixtures/fakeCdpBrowserResults.ts +++ b/tests/fixtures/fakeCdpBrowserResults.ts @@ -49,6 +49,8 @@ export const resultFor = ( case "Page.enable": case "Page.disable": case "Page.setLifecycleEventsEnabled": + case "Performance.enable": + case "Performance.disable": case "Network.enable": case "Network.disable": case "Debugger.disable": @@ -58,6 +60,8 @@ export const resultFor = ( case "DOM.enable": case "DOM.disable": return {}; + case "Performance.getMetrics": + return { metrics: [{ name: "Timestamp", value: 0 }] }; case "Debugger.enable": return { debuggerId: "fake-debugger" }; case "Target.detachFromTarget": diff --git a/tests/fixtures/fakeCdpBrowserTypes.ts b/tests/fixtures/fakeCdpBrowserTypes.ts index e00ea02c4b..56b03659b6 100644 --- a/tests/fixtures/fakeCdpBrowserTypes.ts +++ b/tests/fixtures/fakeCdpBrowserTypes.ts @@ -1,4 +1,5 @@ import type { JsonValue } from "../../src/domain/jsonValue.js"; +import type { CdpEvent } from "../../src/browser/CdpConnection.js"; export interface FakeCdpCommand { readonly id: number; readonly method: string; @@ -17,11 +18,7 @@ export interface FakeCdpBrowser { openedConnectionCount(): number; /** Deliver actual malformed wire data without bypassing the production transport parser. */ emitRawMessage(message: string): void; - emitEvent(event: { - readonly method: string; - readonly params: unknown; - readonly sessionId?: string; - }): void; + emitEvent(event: CdpEvent): void; readonly httpRequests: readonly { readonly url: string; readonly authorization: string | undefined; @@ -48,13 +45,7 @@ export interface FakeOptions { readonly commandEvents?: ( command: FakeCdpCommand, origin: string, - ) => - | readonly { - readonly method: string; - readonly params: unknown; - readonly sessionId?: string; - }[] - | undefined; + ) => readonly CdpEvent[] | undefined; readonly malformedDiscovery?: boolean; readonly oversizedDiscovery?: boolean; readonly invalidBrowserWebSocket?: boolean;