diff --git a/src/browser/CdpCaptureEventHandlers.ts b/src/browser/CdpCaptureEventHandlers.ts index b1b8b2ad67..493925d63f 100644 --- a/src/browser/CdpCaptureEventHandlers.ts +++ b/src/browser/CdpCaptureEventHandlers.ts @@ -25,7 +25,7 @@ import { initiatorLocation, integerOrNull, isJsonMediaType, - isMainFrameNavigation, + cdpFrameIdentity, } from "./CdpCaptureEventHelpers.js"; import type { CdpCaptureEventsState } from "./CdpCaptureEventState.js"; import { @@ -607,25 +607,47 @@ export const handleWebSocketCreated = ( export const handleFrameNavigated = ( state: CdpCaptureEventsState, - params: UnknownRecord, + params: unknown, ): void => { - const frame = recordValue(params.frame); if (state.mainFrameId === undefined) return; - if ( - !isMainFrameNavigation( - { method: "Page.frameNavigated", params }, - state.mainFrameId, + const identity = cdpFrameIdentity( + { method: "Page.frameNavigated", params }, + state.mainFrameId, + ); + if (identity.identityIssue !== null || identity.parentIssue !== null) { + state.completeness.exclude("frames", "invalid_protocol_value"); + if ( + identity.selection === "unknown" && + identity.parentRelation === "child" && + identity.identityIssue !== null ) - ) + return; + state.unknownRootIdentity = true; return; + } + if (identity.selection === "other" && identity.parentRelation === "child") + return; + if (identity.selection === "unknown") { + state.completeness.exclude("frames", "invalid_protocol_value"); + state.unknownRootIdentity = true; + return; + } + if (identity.selection === "other" && !identity.isRootReplacement) { + state.completeness.exclude("frames", "invalid_protocol_value"); + state.unknownRootIdentity = true; + return; + } + const frame = recordValue(recordValue(params)?.frame); const rawUrl = cdpStringValue(frame?.url); const loaderId = cdpStringValue(frame?.loaderId); // `frameNavigated` re-fires for a document already reported (same URL and // loader). Treating that as a navigation aborted a valid capture with // `target_changed`, which is a false claim that the target moved. - const document = `${String(loaderId ?? "")}\0${String(rawUrl ?? "")}`; + const document = `${String(identity.frameId ?? "")}\0${String(loaderId ?? "")}\0${String(rawUrl ?? "")}`; if (state.committedDocument === document) return; state.committedDocument = document; + if (identity.isRootReplacement) + state.mainFrameId = identity.frameId ?? undefined; state.navigationDuringCapture = true; if ( isHttpUrl(rawUrl) && diff --git a/src/browser/CdpCaptureEventHelpers.test.ts b/src/browser/CdpCaptureEventHelpers.test.ts new file mode 100644 index 0000000000..ad34274e45 --- /dev/null +++ b/src/browser/CdpCaptureEventHelpers.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "vitest"; +import { cdpFrameIdentity } from "./CdpCaptureEventHelpers.js"; + +describe("CDP frame identity ownership", () => { + it.each([ + ["missing", {}], + ["null", { frame: null }], + ["non-object", { frame: "frame" }], + ])("does not infer a root from a %s frame object", (_label, params) => { + expect( + cdpFrameIdentity( + { method: "Page.frameNavigated", params }, + "selected-main", + ), + ).toMatchObject({ + applicability: "applicable", + selection: "unknown", + isRootReplacement: false, + identityIssue: "missing_frame", + parentRelation: "unknown", + parentId: null, + parentIssue: null, + }); + }); + + it("recognizes a valid frame with no parent property as a root", () => { + expect( + cdpFrameIdentity( + { + method: "Page.frameNavigated", + params: { frame: { id: "replacement-main" } }, + }, + "selected-main", + ), + ).toMatchObject({ + applicability: "applicable", + selection: "other", + isRootReplacement: true, + identityIssue: null, + parentRelation: "root", + parentId: null, + parentIssue: null, + }); + }); + + it("does not interpret a malformed parent value as root evidence", () => { + expect( + cdpFrameIdentity( + { + method: "Page.frameNavigated", + params: { frame: { id: "replacement-main", parentId: "" } }, + }, + "selected-main", + ), + ).toMatchObject({ + selection: "other", + isRootReplacement: false, + identityIssue: null, + parentRelation: "unknown", + parentId: null, + parentIssue: "invalid_parent_id", + }); + }); + + it("preserves a self-parented frame ID but leaves its parent relation unknown", () => { + expect( + cdpFrameIdentity( + { + method: "Page.frameNavigated", + params: { + frame: { id: "candidate-root", parentId: "candidate-root" }, + }, + }, + "selected-main", + ), + ).toMatchObject({ + selection: "other", + isRootReplacement: false, + frameId: "candidate-root", + identityIssue: null, + parentRelation: "unknown", + parentId: null, + parentIssue: "self_parent", + }); + }); + + it("retains the selected-main parent issue for a self-parented selected frame", () => { + expect( + cdpFrameIdentity( + { + method: "Page.frameNavigated", + params: { frame: { id: "selected-main", parentId: "selected-main" } }, + }, + "selected-main", + ), + ).toMatchObject({ + selection: "main", + parentRelation: "unknown", + parentIssue: "main_frame_has_parent", + }); + }); +}); diff --git a/src/browser/CdpCaptureEventHelpers.ts b/src/browser/CdpCaptureEventHelpers.ts index 6f718f0d6c..d3e60f2450 100644 --- a/src/browser/CdpCaptureEventHelpers.ts +++ b/src/browser/CdpCaptureEventHelpers.ts @@ -8,21 +8,132 @@ import { } from "./CdpCaptureValues.js"; import type { CdpEvent } from "./CdpConnection.js"; -/** Identify a top-level navigation event from its untrusted CDP envelope. */ -export const isMainFrameNavigation = ( +export type FrameSelection = "main" | "other" | "unknown"; +export type FrameParentRelation = + | "root" + | "child" + | "unknown" + | "not_applicable"; + +/** Normalized frame identity facts shared by all selected-frame consumers. */ +export interface CdpFrameIdentity { + readonly applicability: "applicable" | "not_applicable"; + readonly selection: FrameSelection; + readonly isRootReplacement: boolean; + readonly frameId: string | null; + readonly identityIssue: + | "missing_params" + | "invalid_params" + | "missing_frame" + | "missing_id" + | "invalid_id" + | null; + readonly parentRelation: FrameParentRelation; + readonly parentId: string | null; + readonly parentIssue: + | "invalid_parent_id" + | "self_parent" + | "main_frame_has_parent" + | null; +} + +const nonEmptyString = (value: unknown): string | undefined => + typeof value === "string" && value.length > 0 ? value : undefined; + +const identityIssueFor = ( + navigated: boolean, + frame: UnknownRecord | undefined, + rawId: unknown, +): CdpFrameIdentity["identityIssue"] => { + if (navigated && frame === undefined) return "missing_frame"; + if (rawId === undefined) return "missing_id"; + return nonEmptyString(rawId) === undefined ? "invalid_id" : null; +}; + +const parentFacts = ( + navigated: boolean, + frame: UnknownRecord | undefined, +): Pick => { + if (!navigated) + return { + parentRelation: "not_applicable", + parentId: null, + parentIssue: null, + }; + if (frame === undefined) + return { parentRelation: "unknown", parentId: null, parentIssue: null }; + if (!("parentId" in frame)) + return { parentRelation: "root", parentId: null, parentIssue: null }; + const parentId = nonEmptyString(frame.parentId); + return parentId === undefined || parentId === nonEmptyString(frame.id) + ? { + parentRelation: "unknown", + parentId: null, + parentIssue: + parentId === undefined ? "invalid_parent_id" : "self_parent", + } + : { parentRelation: "child", parentId, parentIssue: null }; +}; + +/** Parse required frame identity separately from the optional parent fact. */ +export const cdpFrameIdentity = ( event: CdpEvent, mainFrameId: string, -): boolean => { +): CdpFrameIdentity => { + const navigated = event.method === "Page.frameNavigated"; + const requested = event.method === "Page.frameRequestedNavigation"; + const sameDocument = event.method === "Page.navigatedWithinDocument"; + if (!navigated && !requested && !sameDocument) + return { + applicability: "not_applicable", + selection: "unknown", + isRootReplacement: false, + frameId: null, + identityIssue: null, + parentRelation: "not_applicable", + parentId: null, + parentIssue: null, + }; const params = recordValue(event.params); - if (params === undefined) return false; - const frameId = - event.method === "Page.frameNavigated" - ? cdpStringValue(recordValue(params.frame)?.id) - : event.method === "Page.frameRequestedNavigation" || - event.method === "Page.navigatedWithinDocument" - ? cdpStringValue(params.frameId) - : undefined; - return frameId === mainFrameId; + if (params === undefined) + return { + applicability: "applicable", + selection: "unknown", + isRootReplacement: false, + frameId: null, + identityIssue: + event.params === undefined ? "missing_params" : "invalid_params", + parentRelation: "not_applicable", + parentId: null, + parentIssue: null, + }; + const frame = navigated ? recordValue(params.frame) : undefined; + const rawId = navigated ? frame?.id : params.frameId; + const frameId = nonEmptyString(rawId); + const selection = + frameId === undefined + ? "unknown" + : frameId === mainFrameId + ? "main" + : "other"; + const identityIssue = identityIssueFor(navigated, frame, rawId); + const parent = parentFacts(navigated, frame); + return { + applicability: "applicable", + selection, + isRootReplacement: + selection === "other" && parent.parentRelation === "root", + frameId: frameId ?? null, + identityIssue, + parentRelation: parent.parentRelation, + parentId: parent.parentId, + parentIssue: + selection === "main" && + (parent.parentRelation === "child" || + parent.parentIssue === "self_parent") + ? "main_frame_has_parent" + : parent.parentIssue, + }; }; export const firstCallFrame = ( diff --git a/src/browser/CdpCaptureEventState.ts b/src/browser/CdpCaptureEventState.ts index 9883a3a3c1..2e72bd37ce 100644 --- a/src/browser/CdpCaptureEventState.ts +++ b/src/browser/CdpCaptureEventState.ts @@ -26,6 +26,7 @@ export interface CdpCaptureEventsState { readonly completeness: CdpCaptureCompleteness; originViolation: boolean; navigationDuringCapture: boolean; + unknownRootIdentity: boolean; mainFrameId: string | undefined; /** * Identity of the document last observed in the main frame. `frameNavigated` diff --git a/src/browser/CdpCaptureEvents.ts b/src/browser/CdpCaptureEvents.ts index b3f1ce41b8..96b8bba7b7 100644 --- a/src/browser/CdpCaptureEvents.ts +++ b/src/browser/CdpCaptureEvents.ts @@ -45,6 +45,7 @@ export class CdpCaptureEvents implements CdpCaptureEventsState { ]); originViolation = false; navigationDuringCapture = false; + unknownRootIdentity = false; mainFrameId: string | undefined = undefined; committedDocument: string | undefined = undefined; @@ -60,6 +61,7 @@ export class CdpCaptureEvents implements CdpCaptureEventsState { this.mainFrameId = frameId; this.originViolation = false; this.navigationDuringCapture = false; + this.unknownRootIdentity = false; this.committedDocument = undefined; this.scripts.clear(); this.scriptMetadataBudgetOmissionsById.clear(); @@ -122,6 +124,10 @@ export class CdpCaptureEvents implements CdpCaptureEventsState { } ingest(event: CdpEvent): void { + if (event.method === "Page.frameNavigated") { + handlers.handleFrameNavigated(this, event.params); + return; + } const params = recordValue(event.params); if (params === undefined) return; switch (event.method) { @@ -158,9 +164,6 @@ export class CdpCaptureEvents implements CdpCaptureEventsState { case "Runtime.consoleAPICalled": handlers.handleConsoleAPICalled(this, params); break; - case "Page.frameNavigated": - handlers.handleFrameNavigated(this, params); - break; case "Network.webSocketCreated": handlers.handleWebSocketCreated(this, params); break; diff --git a/src/browser/CdpObservationSession.ts b/src/browser/CdpObservationSession.ts index 3500a3444d..51adfe5612 100644 --- a/src/browser/CdpObservationSession.ts +++ b/src/browser/CdpObservationSession.ts @@ -25,7 +25,7 @@ import { type UnknownRecord, } from "./CdpCaptureValues.js"; import { mainFrameUrl } from "./CdpCaptureDocuments.js"; -import { isMainFrameNavigation } from "./CdpCaptureEventHelpers.js"; +import { cdpFrameIdentity } from "./CdpCaptureEventHelpers.js"; import { cdpTargetEventMatches } from "./CdpTargetEvents.js"; interface ObservationContext { @@ -184,7 +184,7 @@ class TimelineCapture { constructor( private readonly allowedOrigins: ReadonlySet, - private readonly mainFrameId: string, + private mainFrameId: string, initialUrl: string, ) { this.finalUrl = @@ -204,7 +204,15 @@ class TimelineCapture { #ingestArmed(event: CdpTransportEvent, bufferedBeforeArm = false): void { if (this.#endReason !== undefined || this.#scopeExcluded) return; const params = recordValue(event.params); - if (params === undefined) return; + if (params === undefined) { + if ( + event.method === "Page.frameRequestedNavigation" || + event.method === "Page.frameNavigated" || + event.method === "Page.navigatedWithinDocument" + ) + this.completeness.exclude("timeline", "invalid_protocol_value"); + return; + } const contextOnly = bufferedBeforeArm && hasNoComparableProducerTimestamp(event.method); if (contextOnly) this.#hasUnknownEventTime = true; @@ -326,7 +334,10 @@ class TimelineCapture { params: UnknownRecord, contextOnly: boolean, ): void { - if (!isMainFrameNavigation(event, this.mainFrameId)) return; + const identity = cdpFrameIdentity(event, this.mainFrameId); + if (identity.identityIssue !== null || identity.parentIssue !== null) + this.completeness.exclude("timeline", "invalid_protocol_value"); + if (identity.selection !== "main") return; const reason = safeDetail(params.reason); const rawUrl = cdpStringValue(params.url); const destination = @@ -349,12 +360,23 @@ class TimelineCapture { contextOnly: boolean, ): void { const frame = recordValue(params.frame); - if (!isMainFrameNavigation(event, this.mainFrameId)) return; + const identity = cdpFrameIdentity(event, this.mainFrameId); + if (identity.identityIssue !== null || identity.parentIssue !== null) + this.completeness.exclude("timeline", "invalid_protocol_value"); + if (identity.selection !== "main" && !identity.isRootReplacement) return; const rawUrl = cdpStringValue(frame?.url); const destination = scopedUrl(rawUrl, this.allowedOrigins); + if (identity.isRootReplacement) { + this.mainFrameId = identity.frameId ?? this.mainFrameId; + this.#pendingReload = false; + if (contextOnly) { + this.#stopIfOutOfScope(destination); + return; + } + } this.#add({ type: - !contextOnly && this.#pendingReload + !identity.isRootReplacement && !contextOnly && this.#pendingReload ? "same_origin_reload" : "navigation_committed", params: { ...params, frameId: frame?.id, loaderId: frame?.loaderId }, @@ -374,7 +396,10 @@ class TimelineCapture { params: UnknownRecord, contextOnly: boolean, ): void { - if (!isMainFrameNavigation(event, this.mainFrameId)) return; + const identity = cdpFrameIdentity(event, this.mainFrameId); + if (identity.identityIssue !== null || identity.parentIssue !== null) + this.completeness.exclude("timeline", "invalid_protocol_value"); + if (identity.selection !== "main") return; const rawUrl = cdpStringValue(params.url); const destination = scopedUrl(rawUrl, this.allowedOrigins); this.#add({ diff --git a/src/browser/CdpPageCapture.ts b/src/browser/CdpPageCapture.ts index a986366cb0..ba3eb00ef5 100644 --- a/src/browser/CdpPageCapture.ts +++ b/src/browser/CdpPageCapture.ts @@ -170,6 +170,8 @@ const captureAuthorizedPage = async ( operation: context.operation, }); const completedUrl = mainFrameUrl(completedFrameResult) ?? ""; + if (state.events.unknownRootIdentity) + throw new BrowserObservationError("inspect_web_page", "protocol_error"); if (state.events.originViolation) throw new BrowserObservationError("inspect_web_page", "target_not_allowed"); if (state.events.navigationDuringCapture || completedUrl !== attachedUrl) @@ -264,6 +266,8 @@ const authorizeObservationWindow = async ( context.operation, signal, ); + if (events.unknownRootIdentity) + throw new BrowserObservationError("inspect_web_page", "protocol_error"); if (events.originViolation) throw new BrowserObservationError("inspect_web_page", "target_not_allowed"); }; diff --git a/src/browser/CdpScreenshot.ts b/src/browser/CdpScreenshot.ts index e4093479b1..59a9e7768d 100644 --- a/src/browser/CdpScreenshot.ts +++ b/src/browser/CdpScreenshot.ts @@ -5,7 +5,7 @@ import type { } from "../domain/webScreenshot.js"; import { createWebScreenshotArtifact } from "../domain/webScreenshot.js"; import { BrowserObservationError } from "../domain/browserObservationError.js"; -import { isMainFrameNavigation } from "./CdpCaptureEventHelpers.js"; +import { cdpFrameIdentity } from "./CdpCaptureEventHelpers.js"; import type { CdpEndpointDiscovery, CdpEndpointTarget } from "./CdpEndpoint.js"; import type { CdpConnection, CdpEvent } from "./CdpConnection.js"; import { @@ -63,12 +63,15 @@ export const captureCdpScreenshot = async ( const navigation = { changed: false, leftScope: false, + invalidIdentity: false, + frameId: mainFrame.frame_id, url: beforeUrl, loaderId: cdpStringValue(initialFrame?.loaderId), + completeness: new CdpCaptureCompleteness(), }; const removeListener = context.connection.onEvent((event) => { if (event.sessionId !== context.sessionId) return; - observeScreenshotNavigation(event, mainFrame.frame_id, origins, navigation); + observeScreenshotNavigation(event, origins, navigation); }); let result: ReturnType; let after: unknown; @@ -90,13 +93,10 @@ export const captureCdpScreenshot = async ( } finally { removeListener(); } - const encoded = cdpStringValue(result?.data); - const bytes = - encoded === undefined ? undefined : decodeCanonicalBase64(encoded); - if (bytes === undefined) + const screenshot = decodeScreenshot(result); + if (screenshot === undefined) throw new BrowserObservationError("inspect_web_page", "protocol_error"); - const dimensions = readPngHeaderDimensions(bytes); - if (dimensions === undefined) + if (navigation.invalidIdentity) throw new BrowserObservationError("inspect_web_page", "protocol_error"); if (navigation.leftScope) throw new BrowserObservationError("inspect_web_page", "target_not_allowed"); @@ -122,9 +122,9 @@ export const captureCdpScreenshot = async ( origin: authorized.origin ?? "", }, captured_at: new Date().toISOString(), - viewport: dimensions, - artifact: createWebScreenshotArtifact(bytes), - completeness: new CdpCaptureCompleteness().snapshot(), + viewport: screenshot.dimensions, + artifact: createWebScreenshotArtifact(screenshot.bytes), + completeness: navigation.completeness.snapshot(), limitations: [ "The screenshot contains the visible viewport, including on-screen content.", "REA does not scroll, evaluate JavaScript, or capture beyond the current viewport.", @@ -132,24 +132,62 @@ export const captureCdpScreenshot = async ( }; }; +const decodeScreenshot = ( + result: ReturnType, +): + | { + readonly bytes: Buffer; + readonly dimensions: { width: number; height: number }; + } + | undefined => { + const encoded = cdpStringValue(result?.data); + const bytes = + encoded === undefined ? undefined : decodeCanonicalBase64(encoded); + if (bytes === undefined) return undefined; + const dimensions = readPngHeaderDimensions(bytes); + return dimensions === undefined ? undefined : { bytes, dimensions }; +}; + const observeScreenshotNavigation = ( event: CdpEvent, - mainFrameId: string, origins: ReadonlySet, state: { changed: boolean; leftScope: boolean; - readonly url: string | undefined; - readonly loaderId: string | undefined; + invalidIdentity: boolean; + frameId: string; + url: string | undefined; + loaderId: string | undefined; + readonly completeness: CdpCaptureCompleteness; }, ): void => { - if (!isMainFrameNavigation(event, mainFrameId)) return; + const identity = cdpFrameIdentity(event, state.frameId); + if (identity.applicability === "not_applicable") return; + if (identity.identityIssue !== null || identity.parentIssue !== null) { + state.completeness.exclude("screenshot", "invalid_protocol_value"); + if ( + identity.selection === "unknown" && + identity.parentRelation === "child" && + identity.identityIssue !== null + ) + return; + state.invalidIdentity = true; + return; + } + if (identity.selection === "unknown") { + if (identity.parentRelation === "child") return; + state.completeness.exclude("screenshot", "invalid_protocol_value"); + state.invalidIdentity = true; + return; + } + if (identity.selection === "other" && !identity.isRootReplacement) return; const params = recordValue(event.params); if (params === undefined) return; const frame = event.method === "Page.frameNavigated" ? recordValue(params.frame) : params; if ( event.method === "Page.frameNavigated" && + !identity.isRootReplacement && state.loaderId !== undefined && state.loaderId !== "" && cdpStringValue(frame?.loaderId) === state.loaderId && @@ -160,4 +198,12 @@ const observeScreenshotNavigation = ( state.changed = true; if (allowedSanitizedUrl(frame?.url, origins) === undefined) state.leftScope = true; + if (event.method === "Page.frameNavigated") { + if (identity.isRootReplacement) + state.frameId = identity.frameId ?? state.frameId; + state.url = cdpStringValue(frame?.url) ?? state.url; + state.loaderId = cdpStringValue(frame?.loaderId); + } else if (event.method === "Page.navigatedWithinDocument") { + state.url = cdpStringValue(frame?.url) ?? state.url; + } }; diff --git a/src/browser/PlaywrightScenarioSession.test.ts b/src/browser/PlaywrightScenarioSession.test.ts index 7615c27dd6..62c3f3e179 100644 --- a/src/browser/PlaywrightScenarioSession.test.ts +++ b/src/browser/PlaywrightScenarioSession.test.ts @@ -216,7 +216,7 @@ const policyResponse = (method: string): unknown => ? { result: { value: null } } : {}; -const prepareLateStopRetry = async () => { +const prepareHeldPolicyRestoration = async () => { const { cleanup, page, storage } = fixture(); const cancellation = new AbortController(); storage?.close.mockResolvedValue({ loadingStopped: false }); @@ -241,6 +241,28 @@ const prepareLateStopRetry = async () => { return policyResponse(method); }); + return { + cleanup, + page, + storage, + cancellation, + session, + finishRestoration, + started, + transportClosed, + }; +}; + +const prepareLateStopRetry = async () => { + const { + cleanup, + page, + cancellation, + session, + finishRestoration, + started, + transportClosed, + } = await prepareHeldPolicyRestoration(); vi.useFakeTimers(); try { const firstClose = session.close(false).then( @@ -417,29 +439,16 @@ it("transfers a late stop request after storage has already released", async () it.each(["pending", "settled"] as const)( "honors a late stop after policy restoration is %s", async (phase) => { - const { cleanup, page, storage } = fixture(); - const cancellation = new AbortController(); - storage?.close.mockResolvedValue({ loadingStopped: false }); - const attached = connectedScenario("block"); - const session = await PlaywrightScenarioSession.open( - attached, - {}, - { signal: cancellation.signal }, - ); - const { promise: held, resolve: finishRestoration } = deferred(); - const { promise: started, resolve: restorationStarted } = deferred(); - const { promise: transportClosed, resolve: signalTransportClosed } = - deferred(); - mocks.policyTransportClose.mockImplementation(async () => { - signalTransportClosed(); - }); - mocks.policySend.mockImplementation(async (method: string) => { - if (method === "Runtime.evaluate") { - restorationStarted(); - await held; - } - return policyResponse(method); - }); + const { + cleanup, + page, + storage, + cancellation, + session, + finishRestoration, + started, + transportClosed, + } = await prepareHeldPolicyRestoration(); vi.useFakeTimers(); try { diff --git a/src/browser/execution/CdpExecutionWindow.test.ts b/src/browser/execution/CdpExecutionWindow.test.ts index e906be92ab..4a6268148d 100644 --- a/src/browser/execution/CdpExecutionWindow.test.ts +++ b/src/browser/execution/CdpExecutionWindow.test.ts @@ -63,3 +63,57 @@ it("settles caller cancellation and removes its local timer", async () => { vi.useRealTimers(); } }); + +it("keeps a root document transition distinct from a child frame event", async () => { + const root = new CdpExecutionWindow({ + target: { frame_id: "selected-main" }, + operation: "observe_web_execution", + options: {}, + }); + const rootResult = root.start(30_000); + root.ingest({ + method: "Page.frameNavigated", + params: { frame: { id: "new-main", url: "https://example.test/" } }, + }); + await expect(rootResult).resolves.toBe("document_changed"); + + const child = new CdpExecutionWindow({ + target: { frame_id: "selected-main" }, + operation: "observe_web_execution", + options: {}, + }); + const childResult = child.start(30_000); + child.ingest({ + method: "Page.frameNavigated", + params: { + frame: { + id: "child-frame", + parentId: "selected-main", + url: "https://example.test/child", + }, + }, + }); + expect(child.active).toBe(true); + child.end("window_elapsed"); + await expect(childResult).resolves.toBe("window_elapsed"); +}); + +it("surfaces malformed frame identity as a protocol failure without guessing a transition", async () => { + const window = new CdpExecutionWindow({ + target: { frame_id: "selected-main" }, + operation: "observe_web_execution", + options: {}, + }); + const result = window.start(30_000); + window.ingest({ + method: "Page.frameNavigated", + params: { + frame: { id: "child-frame", parentId: 7, url: "https://example.test/" }, + }, + }); + await expect(result).rejects.toMatchObject({ + _tag: "BrowserObservationError", + reason: "protocol_error", + }); + expect(window.reason).toBeUndefined(); +}); diff --git a/src/browser/execution/CdpExecutionWindow.ts b/src/browser/execution/CdpExecutionWindow.ts index f8fa4b7eff..fddfe10329 100644 --- a/src/browser/execution/CdpExecutionWindow.ts +++ b/src/browser/execution/CdpExecutionWindow.ts @@ -3,6 +3,8 @@ import type { AnalysisError } from "../../domain/analysisErrorBase.js"; import type { WebExecution } from "../../domain/webExecution.js"; import type { CdpEvent } from "../CdpConnection.js"; import type { CdpRuntimeSession } from "./CdpRuntimeSession.js"; +import { BrowserObservationError } from "../../domain/browserObservationError.js"; +import { cdpFrameIdentity } from "../CdpCaptureEventHelpers.js"; type EndReason = WebExecution["window"]["end_reason"]; /** One finite document-scoped window, with an actual armed point and event-driven termination. */ @@ -33,15 +35,30 @@ export class CdpExecutionWindow { if (!this.active) return; if (event.method === "Runtime.executionContextsCleared") this.end("document_changed"); - if ( - event.method === "Page.frameNavigated" && - typeof event.params === "object" && - event.params !== null && - "frame" in event.params - ) { - const frame = event.params.frame; - if (typeof frame === "object" && frame !== null && !("parentId" in frame)) + if (event.method === "Page.frameNavigated") { + const identity = cdpFrameIdentity(event, this.session.target.frame_id); + if (identity.identityIssue !== null || identity.parentIssue !== null) { + this.fail( + new BrowserObservationError(this.session.operation, "protocol_error"), + ); + return; + } + if (identity.selection === "main") { this.end("document_changed"); + return; + } + if (identity.parentRelation === "child") return; + if ( + identity.selection === "other" && + identity.parentRelation === "root" + ) { + this.end("document_changed"); + return; + } + this.fail( + new BrowserObservationError(this.session.operation, "protocol_error"), + ); + return; } if ( event.method === "Page.frameDetached" && diff --git a/tests/boundary/browser/cdpBrowserProvider.discovery_authorization.test.ts b/tests/boundary/browser/cdpBrowserProvider.discovery_authorization.test.ts index 053cd673ea..3f17c1d813 100644 --- a/tests/boundary/browser/cdpBrowserProvider.discovery_authorization.test.ts +++ b/tests/boundary/browser/cdpBrowserProvider.discovery_authorization.test.ts @@ -5,8 +5,30 @@ import { inspectWebPageInputSchema, listBrowserTargetsInputSchema, } from "../../../src/domain/browserObservation.js"; -import { startFakeCdpBrowser } from "../../fixtures/fakeCdpBrowser.js"; -import { trackBrowser } from "./cdpBrowserProvider.support.js"; +import { + startFakeCdpBrowser, + type FakeOptions, +} from "../../fixtures/fakeCdpBrowser.js"; +import { frameNavigated, trackBrowser } from "./cdpBrowserProvider.support.js"; + +const inspectWithNetworkEvents = async ( + commandEvents: NonNullable, +) => { + const browser = await startFakeCdpBrowser({ + eventsBeforeReply: ["Network.enable"], + commandEvents, + }); + trackBrowser(browser); + const result = await new CdpBrowserProvider().inspectPage( + inspectWebPageInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + observation_ms: 1, + }), + ); + return { browser, result }; +}; describe("CdpBrowserProvider: discovery authorization 1", () => { it("discovers pages and captures a selected target without an origin list", async () => { @@ -158,6 +180,107 @@ describe("CdpBrowserProvider: discovery authorization 1", () => { }); }); +it("fails with a protocol error after an unidentifiable frame event and detaches", async () => { + const { browser, result } = await inspectWithNetworkEvents((command) => + command.method === "Network.enable" + ? [ + { + method: "Page.frameNavigated", + ...(command.sessionId === undefined + ? {} + : { sessionId: command.sessionId }), + params: undefined, + }, + ] + : undefined, + ); + expect(result).toMatchObject({ + ok: false, + error: { _tag: "BrowserObservationError", reason: "protocol_error" }, + }); + await expect.poll(() => browser.activeConnectionCount()).toBe(0); +}); + +it("retains malformed child-frame identity as frame coverage while completing capture", async () => { + const { browser, result } = await inspectWithNetworkEvents((command) => + command.method === "Network.enable" + ? [ + frameNavigated(command, { + id: 3, + parentId: "frame-main", + url: "https://outside.invalid/child", + }), + ] + : undefined, + ); + if (!result.ok) throw result.error; + expect(result.value.completeness.unavailable_sections).toContain("frames"); + expect(result.value.target.url).toBe( + `${browser.allowedOrigin}/app?token=frame-secret`, + ); +}); + +it("fails capture on a contradictory self-parented frame instead of accepting it as a child", async () => { + const { browser, result } = await inspectWithNetworkEvents( + (command, origin) => + command.method === "Network.enable" + ? [ + frameNavigated(command, { + id: "candidate-root", + parentId: "candidate-root", + url: `${origin}/app?token=frame-secret`, + }), + ] + : undefined, + ); + expect(result).toMatchObject({ + ok: false, + error: { reason: "protocol_error" }, + }); + await expect.poll(() => browser.activeConnectionCount()).toBe(0); +}); + +it("tracks a known root replacement through later frame-scope events", async () => { + const { result } = await inspectWithNetworkEvents((command, origin) => + command.method === "Network.enable" + ? [ + frameNavigated(command, { + id: "replacement-main", + loaderId: "replacement-loader", + url: `${origin}/app?token=frame-secret`, + }), + frameNavigated(command, { + id: "replacement-main", + loaderId: "outside-loader", + url: "https://outside.invalid/path", + }), + ] + : undefined, + ); + expect(result).toMatchObject({ + ok: false, + error: { _tag: "BrowserObservationError", reason: "target_not_allowed" }, + }); +}); + +it("treats a same-URL and same-loader root frame ID change as a capture change", async () => { + const { result } = await inspectWithNetworkEvents((command, origin) => + command.method === "Network.enable" + ? [ + frameNavigated(command, { + id: "replacement-main", + loaderId: "loader-main", + url: `${origin}/app?token=frame-secret`, + }), + ] + : undefined, + ); + expect(result).toMatchObject({ + ok: false, + error: { _tag: "BrowserObservationError", reason: "target_changed" }, + }); +}); + describe("CdpBrowserProvider: discovery authorization 2", () => { it("rejects empty browser attachment session identifiers", async () => { const browser = await startFakeCdpBrowser({ invalidAttachedSession: true }); diff --git a/tests/boundary/browser/cdpBrowserProvider.support.ts b/tests/boundary/browser/cdpBrowserProvider.support.ts index ebb688df55..d1d2357191 100644 --- a/tests/boundary/browser/cdpBrowserProvider.support.ts +++ b/tests/boundary/browser/cdpBrowserProvider.support.ts @@ -1,6 +1,9 @@ import { afterEach } from "vitest"; -import type { FakeCdpBrowser } from "../../fixtures/fakeCdpBrowser.js"; +import type { + FakeCdpBrowser, + FakeOptions, +} from "../../fixtures/fakeCdpBrowser.js"; const browsers: FakeCdpBrowser[] = []; @@ -12,6 +15,15 @@ export const trackBrowser = (browser: FakeCdpBrowser): void => { browsers.push(browser); }; +export const frameNavigated = ( + command: Parameters>[0], + frame: unknown, +) => ({ + method: "Page.frameNavigated", + ...(command.sessionId === undefined ? {} : { sessionId: command.sessionId }), + params: { frame }, +}); + export const waitFor = async (ready: () => boolean, description: string) => { const deadline = Date.now() + 5_000; while (!ready()) { diff --git a/tests/boundary/browser/cdpObservationWindowScope.test.ts b/tests/boundary/browser/cdpObservationWindowScope.test.ts index c3b8e5f10c..7e76b967c8 100644 --- a/tests/boundary/browser/cdpObservationWindowScope.test.ts +++ b/tests/boundary/browser/cdpObservationWindowScope.test.ts @@ -6,12 +6,12 @@ import { startFakeCdpBrowser, type FakeOptions, } from "../../fixtures/fakeCdpBrowser.js"; -import { trackBrowser } from "./cdpBrowserProvider.support.js"; +import { frameNavigated, trackBrowser } from "./cdpBrowserProvider.support.js"; const observeDuringClockSampling = async ( commandEvents: NonNullable, ) => { - const browser = await startFakeCdpBrowser({ + return observeWithFake({ eventsBeforeReply: ["Performance.getMetrics"], commandResult: (command) => command.method === "Performance.getMetrics" @@ -19,6 +19,10 @@ const observeDuringClockSampling = async ( : undefined, commandEvents, }); +}; + +const observeWithFake = async (options: FakeOptions) => { + const browser = await startFakeCdpBrowser(options); trackBrowser(browser); const result = await new CdpBrowserProvider().observeSession( observeWebSessionInputSchema.parse({ @@ -32,34 +36,47 @@ const observeDuringClockSampling = async ( return { browser, observation: result.value }; }; +const laterInWindowRequest = ( + command: Parameters>[0], + origin: string, +) => ({ + 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` }, + }, +}); + +const expectNoCommittedFrameWithInvalidTimeline = ( + observation: Awaited>["observation"], +) => { + expect(observation.timeline).not.toContainEqual( + expect.objectContaining({ type: "navigation_committed" }), + ); + expect(observation.completeness.excluded).toContainEqual( + expect.objectContaining({ + section: "timeline", + reason: "invalid_protocol_value", + }), + ); +}; + it("fails closed on an untimestamped cross-origin main-frame commit during clock sampling", async () => { const { observation } = await observeDuringClockSampling( (command, origin) => { 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: `${origin}/prior` }, - request: { url: `${origin}/later` }, - }, - }, + frameNavigated(command, { + id: "frame-main", + loaderId: "outside-loader", + url: "https://outside.invalid/path", + }), + laterInWindowRequest(command, origin), ]; }, ); @@ -80,6 +97,93 @@ it("fails closed on an untimestamped cross-origin main-frame commit during clock ); }); +it("keeps an unidentifiable frame commit unknown without inventing a main-frame event", async () => { + const { observation } = await observeDuringClockSampling((command) => + command.method === "Performance.getMetrics" + ? [frameNavigated(command, { url: "https://outside.invalid/unknown" })] + : undefined, + ); + + expect(observation.window.end_reason).toBe("window_elapsed"); + expectNoCommittedFrameWithInvalidTimeline(observation); +}); + +it("marks a frame event with absent parameters as unknown timeline evidence", async () => { + const { observation } = await observeDuringClockSampling((command) => + command.method === "Performance.getMetrics" + ? [ + { + method: "Page.frameNavigated", + sessionId: command.sessionId, + params: undefined, + }, + ] + : undefined, + ); + + expectNoCommittedFrameWithInvalidTimeline(observation); + expect(observation.completeness.unavailable_sections).toContain("timeline"); +}); + +it("records a malformed child identity without treating it as the main frame", async () => { + const { observation } = await observeDuringClockSampling((command) => + command.method === "Performance.getMetrics" + ? [ + frameNavigated(command, { + id: 3, + parentId: "frame-main", + url: "https://outside.invalid/child", + }), + ] + : undefined, + ); + + expect(observation.window.end_reason).toBe("window_elapsed"); + expectNoCommittedFrameWithInvalidTimeline(observation); +}); + +it("updates the main frame from a pre-arm root replacement without inventing a navigation row", async () => { + const { observation } = await observeDuringClockSampling((command, origin) => + command.method === "Performance.getMetrics" + ? [ + frameNavigated(command, { + id: "replacement-main", + loaderId: "replacement-loader", + url: `${origin}/app?token=frame-secret`, + }), + { + method: "Page.lifecycleEvent", + sessionId: command.sessionId, + params: { + frameId: "replacement-main", + loaderId: "replacement-loader", + name: "DOMContentLoaded", + timestamp: 21, + }, + }, + ] + : undefined, + ); + + expect(observation.timeline).not.toContainEqual( + expect.objectContaining({ type: "navigation_committed" }), + ); + expect(observation.timeline).toContainEqual( + expect.objectContaining({ + type: "lifecycle", + frame_id: "replacement-main", + loader_id: "replacement-loader", + timestamp: 21, + detail: "domcontentloaded", + }), + ); + expect(observation.limitations).toEqual( + expect.arrayContaining([ + expect.stringContaining("Buffered events with unknown timing"), + ]), + ); +}); + it("fails closed on an untimestamped out-of-scope main-frame request during clock sampling", async () => { const { observation } = await observeDuringClockSampling( (command, origin) => { @@ -94,18 +198,7 @@ it("fails closed on an untimestamped out-of-scope main-frame request during cloc 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` }, - }, - }, + laterInWindowRequest(command, origin), ]; }, ); @@ -191,7 +284,7 @@ it.each([ ] as const)( "ends on buffered untimestamped %s before collecting later events", async (_method, terminalEvent) => { - const browser = await startFakeCdpBrowser({ + const { observation } = await observeWithFake({ eventsBeforeReply: ["Performance.getMetrics"], commandResult: (command) => command.method === "Performance.getMetrics" @@ -216,35 +309,25 @@ it.each([ ]; }, }); - 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(observation.window.end_reason).toBe("target_terminated"); + expect(observation.timeline).toContainEqual( expect.objectContaining({ type: "target_terminated", timestamp: null, }), ); - expect(result.value.timeline).not.toContainEqual( + expect(observation.timeline).not.toContainEqual( expect.objectContaining({ request_id: "after-terminal-event" }), ); - expect(result.value.limitations).toContain( + expect(observation.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({ + const { browser, observation } = await observeWithFake({ eventsBeforeReply: ["Page.setLifecycleEventsEnabled"], commandEvents: (command, origin) => { if (command.method !== "Page.setLifecycleEventsEnabled") return undefined; @@ -271,18 +354,8 @@ it("represents absent timeline timestamps as unknown and preserves an observed z ]; }, }); - 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(observation.timeline).toEqual( expect.arrayContaining([ expect.objectContaining({ type: "same_document_navigation", diff --git a/tests/boundary/browser/cdpScreenshotDocumentIdentity.test.ts b/tests/boundary/browser/cdpScreenshotDocumentIdentity.test.ts index b768485989..6657769292 100644 --- a/tests/boundary/browser/cdpScreenshotDocumentIdentity.test.ts +++ b/tests/boundary/browser/cdpScreenshotDocumentIdentity.test.ts @@ -1,26 +1,33 @@ import { describe, expect, it, onTestFinished } from "vitest"; import { CdpBrowserProvider } from "../../../src/browser/CdpBrowserProvider.js"; import { captureWebScreenshotInputSchema } from "../../../src/domain/webScreenshot.js"; -import { startFakeCdpBrowser } from "../../fixtures/fakeCdpBrowser.js"; +import { + startFakeCdpBrowser, + type FakeOptions, +} from "../../fixtures/fakeCdpBrowser.js"; +import { frameNavigated } from "./cdpBrowserProvider.support.js"; + +const captureWithFake = async (options: FakeOptions) => { + const browser = await startFakeCdpBrowser(options); + onTestFinished(() => browser.close()); + const result = await new CdpBrowserProvider().captureScreenshot( + captureWebScreenshotInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + }), + ); + return { browser, result }; +}; describe("screenshot committed document identity", () => { it.each([ ["missing screenshot data", {}], ["invalid base64 screenshot data", { data: "not base64%%%" }], ])("reports %s as a protocol error", async (_label, result) => { - const browser = await startFakeCdpBrowser({ + const { result: observation } = await captureWithFake({ commandResult: ({ method }) => method === "Page.captureScreenshot" ? result : undefined, }); - onTestFinished(async () => { - await browser.close(); - }); - const observation = await new CdpBrowserProvider().captureScreenshot( - captureWebScreenshotInputSchema.parse({ - cdp_endpoint: browser.endpoint, - allowed_origins: [browser.allowedOrigin], - target_id: "allowed-page", - }), - ); expect(observation).toMatchObject({ ok: false, error: { reason: "protocol_error" }, @@ -37,22 +44,12 @@ describe("screenshot committed document identity", () => { "base64", ); bytes.writeUInt32BE(value, offset); - const browser = await startFakeCdpBrowser({ + const { result } = await captureWithFake({ commandResult: ({ method }) => method === "Page.captureScreenshot" ? { data: bytes.toString("base64") } : undefined, }); - onTestFinished(async () => { - await browser.close(); - }); - const result = await new CdpBrowserProvider().captureScreenshot( - captureWebScreenshotInputSchema.parse({ - cdp_endpoint: browser.endpoint, - allowed_origins: [browser.allowedOrigin], - target_id: "allowed-page", - }), - ); expect(result).toMatchObject({ ok: false, error: { reason: "protocol_error" }, @@ -61,17 +58,7 @@ describe("screenshot committed document identity", () => { it.each(["loader-main", "loader-reloaded"])( "distinguishes %s for the same URL", async (screenshotDocumentLoader) => { - const browser = await startFakeCdpBrowser({ screenshotDocumentLoader }); - onTestFinished(async () => { - await browser.close(); - }); - const result = await new CdpBrowserProvider().captureScreenshot( - captureWebScreenshotInputSchema.parse({ - cdp_endpoint: browser.endpoint, - allowed_origins: [browser.allowedOrigin], - target_id: "allowed-page", - }), - ); + const { result } = await captureWithFake({ screenshotDocumentLoader }); if (screenshotDocumentLoader === "loader-main") expect(result.ok).toBe(true); else @@ -81,4 +68,159 @@ describe("screenshot committed document identity", () => { }); }, ); + + it("defers an unidentifiable navigation failure until screenshot cleanup completes", async () => { + const { browser, result } = await captureWithFake({ + commandEvents: (command) => + command.method === "Page.captureScreenshot" + ? [ + { + method: "Page.frameNavigated", + ...(command.sessionId === undefined + ? {} + : { sessionId: command.sessionId }), + params: undefined, + }, + ] + : undefined, + }); + expect(result).toMatchObject({ + ok: false, + error: { reason: "protocol_error" }, + }); + await expect.poll(() => browser.activeConnectionCount()).toBe(0); + }); +}); + +describe("screenshot frame identity transitions", () => { + it("rejects a self-parented root candidate as malformed identity", async () => { + const { browser, result } = await captureWithFake({ + commandEvents: (command, origin) => + command.method === "Page.captureScreenshot" + ? [ + frameNavigated(command, { + id: "candidate-root", + parentId: "candidate-root", + url: `${origin}/app?token=frame-secret`, + }), + ] + : undefined, + }); + expect(result).toMatchObject({ + ok: false, + error: { reason: "protocol_error" }, + }); + await expect.poll(() => browser.activeConnectionCount()).toBe(0); + }); + + it("keeps a malformed known child as screenshot coverage", async () => { + const { result } = await captureWithFake({ + commandEvents: (command) => + command.method === "Page.captureScreenshot" + ? [ + frameNavigated(command, { + id: 7, + parentId: "frame-main", + url: "https://outside.invalid/child", + }), + ] + : undefined, + }); + if (!result.ok) throw result.error; + expect(result.value.completeness.excluded).toContainEqual( + expect.objectContaining({ + section: "screenshot", + reason: "invalid_protocol_value", + }), + ); + }); + + it("detects a same-URL root replacement and scopes later events to its new identity", async () => { + const { result } = await captureWithFake({ + commandEvents: (command, origin) => + command.method === "Page.captureScreenshot" + ? [ + frameNavigated(command, { + id: "replacement-main", + loaderId: "loader-main", + url: `${origin}/app?token=frame-secret`, + }), + { + method: "Page.navigatedWithinDocument", + ...(command.sessionId === undefined + ? {} + : { sessionId: command.sessionId }), + params: { + frameId: "replacement-main", + url: "https://outside.invalid/path", + }, + }, + ] + : undefined, + }); + expect(result).toMatchObject({ + ok: false, + error: { reason: "target_not_allowed" }, + }); + }); +}); + +describe("screenshot navigation event applicability", () => { + it("ignores non-navigation events with selected-frame or malformed parameters", async () => { + const { result } = await captureWithFake({ + commandEvents: (command) => + command.method === "Page.captureScreenshot" + ? [ + { + method: "Runtime.consoleAPICalled", + sessionId: command.sessionId, + params: { + type: "log", + args: [], + executionContextId: 1, + timestamp: 1, + }, + }, + { + method: "Page.lifecycleEvent", + sessionId: command.sessionId, + params: { + frameId: "frame-main", + loaderId: "loader-main", + name: "DOMContentLoaded", + timestamp: 1, + }, + }, + { + method: "Runtime.consoleAPICalled", + sessionId: command.sessionId, + params: { frameId: "frame-main", args: "not-an-array" }, + }, + ] + : undefined, + }); + expect(result.ok).toBe(true); + }); + + it.each([ + "Page.frameRequestedNavigation", + "Page.navigatedWithinDocument", + ] as const)("ignores a known non-main frame's %s event", async (method) => { + const { result } = await captureWithFake({ + commandEvents: (command) => + command.method === "Page.captureScreenshot" + ? [ + { + method, + sessionId: command.sessionId, + params: { + frameId: "frame-child", + url: "https://outside.invalid/child", + }, + }, + ] + : undefined, + }); + expect(result.ok).toBe(true); + }); }); diff --git a/tests/boundary/browser/cdpWebRuntime.test.ts b/tests/boundary/browser/cdpWebRuntime.test.ts index 7b5c772ca4..db44df1868 100644 --- a/tests/boundary/browser/cdpWebRuntime.test.ts +++ b/tests/boundary/browser/cdpWebRuntime.test.ts @@ -109,6 +109,45 @@ describe("native runtime producer boundary", () => { }); describe("runtime window and producer granularity", () => { + it("fails malformed frame identity and completes execution cleanup", async () => { + const { browser, provider, execution } = await fixture(); + const result = await provider.observeExecution( + { ...execution, observation_ms: 30_000 }, + { + progress: { + report: async (update) => { + if (update.completed !== 1) return; + browser.emitEvent({ + method: "Page.frameNavigated", + sessionId: "session-1", + params: { + frame: { + id: "other-frame", + parentId: 7, + url: "https://example.test/child", + }, + }, + }); + }, + }, + }, + ); + expect(result).toMatchObject({ + ok: false, + error: { _tag: "BrowserObservationError", reason: "protocol_error" }, + }); + expect( + browser.commands.some( + (command) => command.method === "Profiler.stopPreciseCoverage", + ), + ).toBe(true); + expect( + browser.commands.some( + (command) => command.method === "Target.detachFromTarget", + ), + ).toBe(true); + }); + it("ends at document replacement and leaves coverage explicitly unavailable", async () => { const { browser, provider, execution } = await fixture(); const result = await provider.observeExecution(