Files
reverse-engineering-anything/src/browser/CdpCaptureValues.ts
T
morluto 3e9400873e refactor(browser): register target, storage and acknowledgement CDP replies (#10630)
* fix(browser): type CDP command replies and parse frame trees once

CdpConnection.send is typed by method name. A command registered in
src/browser/protocol/CdpCommands.ts has its parameters typed from the
protocol table Playwright ships and its reply parsed once by its own result
schema; a mismatch rejects that command with CdpMalformedReply
(protocol_error, command, JSON pointer) without poisoning the connection.
Playwright storage sessions admit replies through the same table.

Page.getFrameTree and Page.getResourceTree are the first registered
commands. Their one parser keeps per-frame facts tolerant (malformed child
entries remain explicit omissions) and replaces the per-site readers:
observeMainFrameUrl, mainFrameUrl, walkFrameTrees, frameTreeResources,
runtimeFrameTreeSchema and the scenario storage frame schema.

A reply without a frame tree used to be retried for two seconds and
reported as target_not_allowed; it is now one protocol_error.

A guard test counts literal CDP command names without a result schema per
source file; the baseline can only shrink.

* fix(browser): consume parsed CDP facts across boundaries

* refactor(browser): register target, storage and acknowledgement CDP replies

Register result schemas for Target.attachToTarget, Target.getTargetInfo,
Storage.getStorageKey, Page.addScriptToEvaluateOnNewDocument,
Debugger.setBreakpointByUrl, Debugger.evaluateOnCallFrame and fourteen
commands whose reply REA reads only as success. Their callers use the
parsed reply; the per-site readers are removed (attachedSessionId and the
scenario storage and service-worker zod schemas, which threw raw ZodError).

Playwright's Target.getTargetInfo in scenario attach now goes through the
same admission. The untyped-command baseline drops from 96 to 48.
2026-11-14 23:40:44 +00:00

116 lines
4.0 KiB
TypeScript

import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import { BrowserObservationError } from "../domain/browserObservationError.js";
import type { BrowserObservationOperation } from "../domain/browserObservationErrors.js";
import { sanitizeBrowserUrl } from "../domain/browserObservation.js";
import { createWebTextArtifact } from "../domain/webContentArtifact.js";
import { err, ok, type Result } from "../domain/result.js";
export type UnknownRecord = Readonly<Record<string, unknown>>;
const isRecord = (value: unknown): value is UnknownRecord =>
typeof value === "object" && value !== null && !Array.isArray(value);
export const recordValue = (value: unknown): UnknownRecord | undefined =>
isRecord(value) ? value : undefined;
/** Preserve an omitted producer inventory as unknown rather than empty. */
export const admitRecords = (value: unknown): readonly unknown[] | undefined =>
Array.isArray(value) ? value : undefined;
export const recordsValue = (value: unknown): readonly UnknownRecord[] =>
Array.isArray(value)
? value.flatMap((item) => {
const record = recordValue(item);
return record === undefined ? [] : [record];
})
: [];
export const cdpStringValue = (value: unknown): string | undefined =>
typeof value === "string" ? value : undefined;
export const numberValue = (value: unknown): number | undefined =>
typeof value === "number" && Number.isFinite(value) ? value : undefined;
/** An omitted resource size is unknown; a supplied invalid size is malformed. */
export const resourceContentSize = (
value: unknown,
): Result<number | null, { readonly _tag: "InvalidCdpResourceSize" }> => {
if (value === undefined) return ok(null);
const size = numberValue(value);
return size !== undefined && size >= 0
? ok(size)
: err({ _tag: "InvalidCdpResourceSize" });
};
export const requiredRecord = (value: unknown): UnknownRecord => {
const record = recordValue(value);
if (record === undefined)
throw new BrowserObservationError("inspect_web_page", "protocol_error");
return record;
};
export const isHttpUrl = (value: string | undefined): boolean => {
if (value === undefined) return false;
try {
const protocol = new URL(value).protocol;
return protocol === "http:" || protocol === "https:";
} catch (cause: unknown) {
// Non-URL input is not an HTTP URL.
void cause;
return false;
}
};
export const allowedSanitizedUrl = (
value: unknown,
allowedOrigins: ReadonlySet<string>,
): ReturnType<typeof sanitizeBrowserUrl> | undefined => {
const text = cdpStringValue(value);
if (text === undefined) return undefined;
const sanitized = sanitizeBrowserUrl(text);
return sanitized.origin !== null && allowedOrigins.has(sanitized.origin)
? sanitized
: undefined;
};
export const sourceResult = (
source: string,
): { included: true; artifact: ReturnType<typeof createWebTextArtifact> } => ({
included: true,
artifact: createWebTextArtifact(source, "text/javascript"),
});
export const sourceExcluded = (
reason: string,
): { included: false; reason: string } => ({ included: false, reason });
export const delayWithCancellation = async (
durationMs: number,
operation: BrowserObservationOperation,
signal?: AbortSignal,
): Promise<void> => {
const maximumTimerDelay = 2_147_483_647;
let remaining = durationMs;
while (remaining > 0) {
if (signal?.aborted === true)
throw new AnalysisCancelledError(operation, { cause: signal.reason });
const delay = Math.min(remaining, maximumTimerDelay);
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
signal?.removeEventListener("abort", onAbort);
resolve();
}, delay);
const onAbort = (): void => {
clearTimeout(timer);
signal?.removeEventListener("abort", onAbort);
reject(
new AnalysisCancelledError(operation, { cause: signal?.reason }),
);
};
signal?.addEventListener("abort", onAbort, { once: true });
if (signal?.aborted === true) onAbort();
});
remaining -= delay;
}
};