From ba781330d6d28b22d15498e63c144fb40e086156 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:36:25 +0800 Subject: [PATCH] fix(browser): separate domain readiness and worker attribution coverage --- docs/browser-observation.md | 14 +++++ src/browser/CdpCaptureCompleteness.ts | 3 +- .../CdpCaptureEvents.coverageStart.test.ts | 29 ++++++++++ src/browser/CdpCaptureEvents.ts | 21 +++++++ src/browser/CdpPageCapture.ts | 16 +++--- src/browser/CdpPageCaptureWorkers.ts | 17 +++++- src/domain/browserCompleteness.ts | 1 + .../cdpBrowserProvider.network.test.ts | 57 +++++++++++++++++++ 8 files changed, 147 insertions(+), 11 deletions(-) create mode 100644 src/browser/CdpCaptureEvents.coverageStart.test.ts diff --git a/docs/browser-observation.md b/docs/browser-observation.md index 9bd8527919..0184920972 100644 --- a/docs/browser-observation.md +++ b/docs/browser-observation.md @@ -54,6 +54,20 @@ from a protocol omission. Rejected DOM facts also mark derived URL and agent-hin metadata incomplete, while valid facts from other nodes and documents remain available in the capture. +Page network and console coverage starts independently at the first observed +event from that domain or its successful enable acknowledgment. The capture's +lifecycle start is earlier and does not establish domain readiness. Events +received before the enable reply remain part of the observation. + +Worker output is a final target-inventory snapshot. `attached` describes any +debugging client, not an REA-owned observing session. The selected page-session +collector does not collect worker-session requests, console events, or scripts, +and workers that disappeared before the snapshot may be missing; worker +coverage is therefore attach-limited. Missing ownership metadata is reported as +`unknown_target_relationship` with unavailable worker attribution, separately +from a proven foreign owner excluded as `out_of_target_scope`. A shared origin +alone establishes neither relationship. + Passive comparison keeps lookup identity, cross-capture correspondence, content evidence, and inventory coverage separate: diff --git a/src/browser/CdpCaptureCompleteness.ts b/src/browser/CdpCaptureCompleteness.ts index 4c53f835c8..8c81b25059 100644 --- a/src/browser/CdpCaptureCompleteness.ts +++ b/src/browser/CdpCaptureCompleteness.ts @@ -62,7 +62,8 @@ export class CdpCaptureCompleteness { if (POLICY_REASONS.has(reason)) this.#policyFiltered.add(section); if ( reason === "provider_unavailable" || - reason === "invalid_protocol_value" + reason === "invalid_protocol_value" || + reason === "unknown_target_relationship" ) this.#unavailable.add(section); if (reason === "resource_budget_exhausted") this.#truncated.add(section); diff --git a/src/browser/CdpCaptureEvents.coverageStart.test.ts b/src/browser/CdpCaptureEvents.coverageStart.test.ts new file mode 100644 index 0000000000..d196cf0430 --- /dev/null +++ b/src/browser/CdpCaptureEvents.coverageStart.test.ts @@ -0,0 +1,29 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { inspectWebPageInputSchema } from "../domain/browserObservation.js"; +import { CdpCaptureEvents } from "./CdpCaptureEvents.js"; + +afterEach(() => vi.useRealTimers()); + +it("starts each domain at its first event or enable acknowledgment, preserving events before the reply", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-10-24T00:00:00.000Z")); + const events = new CdpCaptureEvents( + inspectWebPageInputSchema.parse({ + cdp_endpoint: "http://127.0.0.1:9222", + target_id: "page", + allowed_origins: ["https://example.test"], + }), + new Set(["https://example.test"]), + ); + events.beginAuthorizedFrame("main"); + vi.setSystemTime(new Date("2026-10-24T00:00:01.000Z")); + events.ingest({ + method: "Network.loadingFinished", + params: { requestId: "unobserved", encodedDataLength: 0 }, + }); + vi.setSystemTime(new Date("2026-10-24T00:00:02.000Z")); + events.markDomainEnabled("Network"); + events.markDomainEnabled("Runtime"); + expect(events.coverageStartedAt("Network")).toBe("2026-10-24T00:00:01.000Z"); + expect(events.coverageStartedAt("Runtime")).toBe("2026-10-24T00:00:02.000Z"); +}); diff --git a/src/browser/CdpCaptureEvents.ts b/src/browser/CdpCaptureEvents.ts index c29df633da..02f06cfce5 100644 --- a/src/browser/CdpCaptureEvents.ts +++ b/src/browser/CdpCaptureEvents.ts @@ -16,6 +16,7 @@ export interface ResponseBodyCandidate { /** Event accumulator that validates and normalizes payloads at ingestion. */ export class CdpCaptureEvents implements CdpCaptureEventsState { + readonly #coverageStarts = new Map<"Network" | "Runtime", string>(); #scriptMetadataBudgetExclusionsRecorded = false; readonly #networkRequestOccurrences = new Map(); readonly #responseBodyExaminedOccurrences = new Map(); @@ -60,6 +61,7 @@ export class CdpCaptureEvents implements CdpCaptureEventsState { beginAuthorizedFrame(frameId: string): void { this.mainFrameId = frameId; + this.#coverageStarts.clear(); this.originViolation = false; this.navigationDuringCapture = false; this.unknownRootIdentity = false; @@ -91,6 +93,19 @@ export class CdpCaptureEvents implements CdpCaptureEventsState { this.completeness.exclude("websocket_shapes", "not_approved", null); } + /** The first observed domain event or successful enable reply proves activity. */ + markDomainEnabled(domain: "Network" | "Runtime"): void { + if (!this.#coverageStarts.has(domain)) + this.#coverageStarts.set(domain, new Date().toISOString()); + } + + coverageStartedAt(domain: "Network" | "Runtime"): string { + const started = this.#coverageStarts.get(domain); + if (started === undefined) + throw new Error(`${domain} observation has not been enabled`); + return started; + } + beginFinalCapture(frameId: string): void { this.mainFrameId = frameId; // Deliberately does not clear `navigationDuringCapture`. A main-frame @@ -126,6 +141,12 @@ export class CdpCaptureEvents implements CdpCaptureEventsState { } ingest(event: CdpEvent): void { + if (this.mainFrameId !== undefined) { + if (event.method.startsWith("Network.")) + this.markDomainEnabled("Network"); + if (event.method.startsWith("Runtime.")) + this.markDomainEnabled("Runtime"); + } if (event.method === "Page.frameNavigated") { handlers.handleFrameNavigated(this, event.params); return; diff --git a/src/browser/CdpPageCapture.ts b/src/browser/CdpPageCapture.ts index 305548da40..7c1de584aa 100644 --- a/src/browser/CdpPageCapture.ts +++ b/src/browser/CdpPageCapture.ts @@ -86,6 +86,7 @@ export const capturePage = async ( allowedOrigins, limitations: [ "Observation starts when REA attaches; prior network and console activity is unavailable.", + "Worker targets are a final inventory snapshot, not worker-session observation. Their attached flag describes any debugging client; this page-session collector does not observe worker network, console, or script events, and short-lived workers may be absent from the snapshot.", "Raw network headers, bodies, cookies, storage values, console objects, and WebSocket payloads are never retained; explicitly requested primitive console text is retained verbatim, while payload captures retain value-free shapes.", "Source maps are reported only as declarative URLs and are not fetched.", "URL-less scripts and console events without an allowed source URL are excluded because their origin cannot be proven.", @@ -263,7 +264,7 @@ const authorizeObservationWindow = async ( if (mainFrame === undefined) throw new BrowserObservationError("inspect_web_page", "target_not_allowed"); events.beginAuthorizedFrame(mainFrame.frame_id); - await enableObservationDomains(connection, sessionId, signal); + await enableObservationDomains(state); await report(context.progress, 2, "Observing page events"); await delayWithCancellation( context.input.observation_ms, @@ -368,12 +369,12 @@ const normalizedInspection = ( network: { requests: [...state.events.network.values()], websocket_connections: [...state.events.webSocketConnectionsById.values()], - coverage_started_at: state.startedAt, + coverage_started_at: state.events.coverageStartedAt("Network"), prior_activity_available: false, }, console: { events: [...state.events.console], - coverage_started_at: state.startedAt, + coverage_started_at: state.events.coverageStartedAt("Runtime"), prior_activity_available: false, }, workers: [...captured.workers], @@ -391,14 +392,13 @@ const normalizedInspection = ( limitations: state.limitations, }); -const enableObservationDomains = async ( - connection: CdpConnection, - sessionId: string | undefined, - signal?: AbortSignal, -): Promise => { +const enableObservationDomains = async (state: CaptureState): Promise => { + const { connection, sessionId, signal } = state.context; await connection.send("Runtime.enable", {}, sessionId, signal); + state.events.markDomainEnabled("Runtime"); await connection.send("Debugger.enable", {}, sessionId, signal); await connection.send("Network.enable", {}, sessionId, signal); + state.events.markDomainEnabled("Network"); }; const accessibilityForFrames = async ( diff --git a/src/browser/CdpPageCaptureWorkers.ts b/src/browser/CdpPageCaptureWorkers.ts index 1ff14ad9d4..82ee53a6e1 100644 --- a/src/browser/CdpPageCaptureWorkers.ts +++ b/src/browser/CdpPageCaptureWorkers.ts @@ -23,6 +23,9 @@ export const captureWorkers = async ( ): Promise => { const { context, allowedOrigins, limitations, events } = input; const completeness = events.completeness; + // This final target snapshot cannot establish worker lifetimes or observed + // worker-session activity during the preceding page observation window. + completeness.attachLimited("workers"); const result = await optionalCdpCommand( { ...context, sessionId: undefined }, "Target.getTargets", @@ -78,8 +81,18 @@ export const captureWorkers = async ( const relatedToPage = openerId === context.target.id || (parentFrameId !== undefined && frameIds.has(parentFrameId)); - if (!relatedToPage) { - completeness.exclude("workers", "out_of_target_scope"); + const relationship = relatedToPage + ? "related" + : openerId !== undefined || parentFrameId !== undefined + ? "foreign" + : "unknown"; + if (relationship !== "related") { + completeness.exclude( + "workers", + relationship === "foreign" + ? "out_of_target_scope" + : "unknown_target_relationship", + ); continue; } if (url === undefined) { diff --git a/src/domain/browserCompleteness.ts b/src/domain/browserCompleteness.ts index bf57ea9a2c..21dda74be1 100644 --- a/src/domain/browserCompleteness.ts +++ b/src/domain/browserCompleteness.ts @@ -36,6 +36,7 @@ const browserExclusionReasonSchema = z.enum([ "unattributed_origin", "not_approved", "out_of_target_scope", + "unknown_target_relationship", "provider_unavailable", "invalid_protocol_value", "resource_budget_exhausted", diff --git a/tests/boundary/browser/cdpBrowserProvider.network.test.ts b/tests/boundary/browser/cdpBrowserProvider.network.test.ts index 73a3e34fdf..f60a9a04f9 100644 --- a/tests/boundary/browser/cdpBrowserProvider.network.test.ts +++ b/tests/boundary/browser/cdpBrowserProvider.network.test.ts @@ -5,6 +5,63 @@ import { inspectWebPageInputSchema } from "../../../src/domain/browserObservatio import { startFakeCdpBrowser } from "../../fixtures/fakeCdpBrowser.js"; import { trackBrowser } from "./cdpBrowserProvider.support.js"; +it.each([ + ["related", { openerId: "allowed-page" }, 1, null], + ["foreign", { openerId: "other-page" }, 0, "out_of_target_scope"], + ["unknown", {}, 0, "unknown_target_relationship"], +] as const)( + "keeps %s worker ownership separate from attachment state", + async (_relation, ownership, count, exclusion) => { + const browser = await startFakeCdpBrowser({ + commandResult: (command, origin) => + command.method === "Target.getTargets" + ? { + targetInfos: [ + { + targetId: "worker", + type: "worker", + url: `${origin}/worker.js`, + attached: true, + ...ownership, + }, + ], + } + : undefined, + }); + trackBrowser(browser); + const result = await new CdpBrowserProvider().inspectPage( + inspectWebPageInputSchema.parse({ + cdp_endpoint: browser.endpoint, + target_id: "allowed-page", + allowed_origins: [browser.allowedOrigin], + observation_ms: 0, + }), + ); + if (!result.ok) throw result.error; + expect(result.value.workers).toHaveLength(count); + expect(result.value.completeness.attach_limited_sections).toContain( + "workers", + ); + expect(result.value.limitations.join(" ")).toContain( + "not worker-session observation", + ); + if (exclusion !== null) + expect(result.value.completeness.excluded).toContainEqual({ + section: "workers", + reason: exclusion, + count: 1, + }); + if (_relation === "unknown") { + expect(result.value.completeness.unavailable_sections).toContain( + "workers", + ); + expect(result.value.completeness.policy_filtered_sections).not.toContain( + "workers", + ); + } + }, +); + it("preserves Document Link fields, structured policies and derived agent hints", async () => { const browser = await startFakeCdpBrowser({ commandEvents(command, origin) {