Files
reverse-engineering-anything/src/browser/CdpCaptureEvents.ts
T
morluto db4e360846 fix(browser)!: preserve WebSocket connections and comparable payload coverage
Retain source URLs, empty connections, and ordered frame streams. Compare same-URL streams without relying on transient request IDs, and compare selected shapes and byte counts only through common known coverage. Malformed binary frames retain known metadata with an unknown byte count.

BREAKING CHANGE: inspect_web_page network.websocket_events is replaced by network.websocket_connections, whose records contain request_id, url, and events. Frame payload_bytes is nullable when decoding cannot establish its size. No legacy alias is retained.

Validation: 78 domain/browser/MCP focused tests, check:fast, docs:check, and real Chrome 154 CDP capture passed. Real capture retained six connections and twelve frames with source URLs and selected shapes. Full browser verifier was interrupted during an unrelated slow scenario stage; its assertions were exercised by the bounded capture proof.
2026-10-11 09:43:25 +08:00

189 lines
7.4 KiB
TypeScript

import type { InspectWebPageInput } from "../domain/browserObservation.js";
import type { WebPageInspection } from "../domain/browserObservationSchemas.js";
import type { CdpEvent } from "./CdpConnection.js";
import { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js";
import * as bodyShapes from "./CdpCaptureEventBodyShapes.js";
import type { CdpCaptureEventsState } from "./CdpCaptureEventState.js";
import type { CapturedScript, NetworkState } from "./CdpCaptureEventTypes.js";
import * as handlers from "./CdpCaptureEventHandlers.js";
import { isJsonMediaType } from "./CdpCaptureEventHelpers.js";
import { recordValue } from "./CdpCaptureValues.js";
/** Event accumulator that validates and normalizes payloads at ingestion. */
export class CdpCaptureEvents implements CdpCaptureEventsState {
#scriptMetadataBudgetExclusionsRecorded = false;
readonly scripts = new Map<string, CapturedScript>();
readonly scriptMetadataBudgetOmissionsById = new Map<string, true>();
rejectedScriptMetadataBudgetCount = 0;
rejectedSourceMapMetadataBudgetCount = 0;
readonly scriptMetadataBytesById = new Map<string, number>();
retainedScriptMetadataBytes = 0;
readonly executionContextFrames = new Map<string, string>();
readonly network = new Map<string, NetworkState>();
readonly networkRequestTimestamps = new Map<string, number>();
readonly malformedRedirectRequestIds = new Set<string>();
readonly webSocketConnectionsById = new Map<
string,
WebPageInspection["network"]["websocket_connections"][number]
>();
console: WebPageInspection["console"]["events"] = [];
responseMetadata: WebPageInspection["metadata"]["responses"] = [];
agentHints: WebPageInspection["metadata"]["agent_hints"] = [];
readonly completeness = new CdpCaptureCompleteness([
"network_requests",
"console_events",
"websocket_connections",
"websocket_frames",
"metadata",
]);
originViolation = false;
navigationDuringCapture = false;
mainFrameId: string | undefined = undefined;
committedDocument: string | undefined = undefined;
readonly input: InspectWebPageInput;
readonly allowedOrigins: ReadonlySet<string>;
constructor(input: InspectWebPageInput, allowedOrigins: ReadonlySet<string>) {
this.input = input;
this.allowedOrigins = allowedOrigins;
}
beginAuthorizedFrame(frameId: string): void {
this.mainFrameId = frameId;
this.originViolation = false;
this.navigationDuringCapture = false;
this.committedDocument = undefined;
this.scripts.clear();
this.scriptMetadataBudgetOmissionsById.clear();
this.rejectedScriptMetadataBudgetCount = 0;
this.rejectedSourceMapMetadataBudgetCount = 0;
this.scriptMetadataBytesById.clear();
this.retainedScriptMetadataBytes = 0;
this.#scriptMetadataBudgetExclusionsRecorded = false;
this.executionContextFrames.clear();
this.network.clear();
this.networkRequestTimestamps.clear();
this.malformedRedirectRequestIds.clear();
this.webSocketConnectionsById.clear();
this.console.length = 0;
this.responseMetadata.length = 0;
this.agentHints.length = 0;
this.completeness.reset();
if (!this.input.include_console_text)
this.completeness.exclude("console_text", "not_approved", null);
if (!this.input.include_json_body_shapes)
this.completeness.exclude("json_body_shapes", "not_approved", null);
if (!this.input.include_websocket_shapes)
this.completeness.exclude("websocket_shapes", "not_approved", null);
}
beginFinalCapture(frameId: string): void {
this.mainFrameId = frameId;
// Deliberately does not clear `navigationDuringCapture`. A main-frame
// navigation observed during the observation window means the page never
// settled, and clearing the flag here reported the capture as clean while
// carrying pre-navigation scripts, network and console into the inventory
// of the new document. The caller now reports `target_changed` instead.
// `committedDocument` is dropped so the new document's frame events are
// compared against itself rather than the document we just left.
this.committedDocument = undefined;
}
/** Record final script metadata omissions after all scriptParsed replacements. */
recordScriptMetadataBudgetExclusions(): void {
if (this.#scriptMetadataBudgetExclusionsRecorded) return;
this.#scriptMetadataBudgetExclusionsRecorded = true;
const omittedScripts = this.rejectedScriptMetadataBudgetCount;
const omittedSourceMaps =
this.rejectedSourceMapMetadataBudgetCount +
this.scriptMetadataBudgetOmissionsById.size;
if (omittedScripts > 0)
this.completeness.exclude(
"scripts",
"resource_budget_exhausted",
omittedScripts,
);
if (omittedSourceMaps > 0)
this.completeness.exclude(
"source_maps",
"resource_budget_exhausted",
omittedSourceMaps,
);
}
ingest(event: CdpEvent): void {
const params = recordValue(event.params);
if (params === undefined) return;
switch (event.method) {
case "Runtime.executionContextCreated":
handlers.handleExecutionContextCreated(this, params);
break;
case "Runtime.executionContextDestroyed":
handlers.handleExecutionContextDestroyed(this, params);
break;
case "Runtime.executionContextsCleared":
this.executionContextFrames.clear();
break;
case "Debugger.scriptParsed":
handlers.handleScriptParsed(this, params);
break;
case "Network.requestWillBeSent":
handlers.handleRequestWillBeSent(this, params);
break;
case "Network.responseReceived":
handlers.handleResponseReceived(this, params);
break;
case "Network.loadingFinished":
handlers.handleLoadingFinished(this, params);
break;
case "Runtime.consoleAPICalled":
handlers.handleConsoleAPICalled(this, params);
break;
case "Page.frameNavigated":
handlers.handleFrameNavigated(this, params);
break;
case "Network.webSocketCreated":
handlers.handleWebSocketCreated(this, params);
break;
case "Network.webSocketFrameSent":
handlers.handleWebSocketFrame(this, params, "sent");
break;
case "Network.webSocketFrameReceived":
handlers.handleWebSocketFrame(this, params, "received");
break;
}
}
/** Resolve a transient execution context to one already authorized frame. */
frameForScript(
script: CapturedScript,
allowedFrameIds: ReadonlySet<string>,
): string | null {
return script.frameId !== null && allowedFrameIds.has(script.frameId)
? script.frameId
: null;
}
responseBodyRequestIds(): readonly string[] {
if (!this.input.include_json_body_shapes) return [];
return [...this.network.values()]
.filter(
(request) => !this.malformedRedirectRequestIds.has(request.request_id),
)
.filter((request) => isJsonMediaType(request.mime_type))
.map((request) => request.request_id);
}
responseBodyUnavailable(requestId: string): void {
if (this.malformedRedirectRequestIds.has(requestId)) return;
bodyShapes.updateResponseBodyShape(this, requestId, null);
this.completeness.unavailable("json_body_shapes");
}
ingestResponseBody(requestId: string, value: unknown): void {
if (this.malformedRedirectRequestIds.has(requestId)) return;
bodyShapes.ingestResponseBodyShape(this, requestId, value);
}
}