fix(browser): separate domain readiness and worker attribution coverage

This commit is contained in:
morluto committed 2026-10-24 02:37:47 +08:00
1 parent 91f06f55a3
commit ba781330d6
8 files changed
+147 -11

No files matched your search

+14
View File
@@ -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:
+2 -1
View File
@@ -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);
@@ -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");
});
+21
View File
@@ -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<string, number>();
readonly #responseBodyExaminedOccurrences = new Map<string, number>();
@@ -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;
+8 -8
View File
@@ -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<void> => {
const enableObservationDomains = async (state: CaptureState): Promise<void> => {
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 (
+15 -2
View File
@@ -23,6 +23,9 @@ export const captureWorkers = async (
): Promise<WebPageInspection["workers"]> => {
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) {
+1
View File
@@ -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",
@@ -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) {