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.
This commit is contained in:
morluto authored and GitHub committed 2026-11-14 23:40:44 +00:00
1 parent f3b697da5a
commit 3e9400873e
20 files changed
+239 -223

No files matched your search

-14
View File
@@ -49,20 +49,6 @@ export const requiredRecord = (value: unknown): UnknownRecord => {
return record;
};
/** Require producer source text; an absent source is distinct from an empty script. */
export const requiredScriptSource = (
value: unknown,
operation: BrowserObservationOperation,
): string => {
const source = cdpStringValue(recordValue(value)?.scriptSource);
if (source === undefined)
throw new BrowserObservationError(operation, "protocol_error", {
detail:
"Debugger.getScriptSource returned malformed source text: scriptSource must be a string.",
});
return source;
};
export const isHttpUrl = (value: string | undefined): boolean => {
if (value === undefined) return false;
try {
+2 -7
View File
@@ -2,11 +2,7 @@ import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import type { BrowserObservationOperation } from "../domain/browserObservationErrors.js";
import type { CdpConnection } from "./CdpConnection.js";
import type { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js";
import {
requiredScriptSource,
sourceExcluded,
sourceResult,
} from "./CdpCaptureValues.js";
import { sourceExcluded, sourceResult } from "./CdpCaptureValues.js";
import {
CDP_SCRIPT_SOURCE_BYTES_OMISSION_REASON,
cdpScriptSourceDeadlineOmissionReason,
@@ -44,14 +40,13 @@ export const capturePassiveCdpScriptSource = async (input: {
throwIfCancelled();
if (input.sourceBudget.timedOut) return omitForBudget();
try {
const response = await input.connection.send(
const { scriptSource: text } = await input.connection.send(
"Debugger.getScriptSource",
{ scriptId: input.scriptId },
input.sessionId,
input.sourceBudget.signal,
);
throwIfCancelled();
const text = requiredScriptSource(response, input.operation);
if (input.sourceBudget.timedOut || !input.sourceBudget.admit(text))
return omitForBudget();
return sourceResult(text);
+2 -18
View File
@@ -7,7 +7,6 @@ import type { BrowserObservationEndpoint } from "../domain/browserObservation.js
import { AnalysisError } from "../domain/analysisErrorBase.js";
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import { BrowserObservationError } from "../domain/browserObservationError.js";
import type { BrowserObservationOperation } from "../domain/browserObservationErrors.js";
import { CdpCommandRejection } from "./CdpCommandRejection.js";
import { CdpConnection } from "./CdpConnection.js";
@@ -204,13 +203,13 @@ export const attachCdpTargetSession = async (
owner?: CdpTargetSessionOwner,
): Promise<CdpTargetSession> => {
try {
const attached = await connection.send(
const { sessionId } = await connection.send(
"Target.attachToTarget",
{ targetId: target.id, flatten: true },
undefined,
signal,
);
return { connection, sessionId: attachedSessionId(attached, operation) };
return { connection, sessionId };
} catch (cause: unknown) {
if (owner === undefined)
return await failCdpOpenAfterCleanup(connection, cause, operation);
@@ -338,18 +337,3 @@ const isAlreadyDetachedSessionRejection = (cause: unknown): boolean =>
/no session with given id|session with given id not found/iu.test(
cause.reportedMessage,
)));
const attachedSessionId = (
value: unknown,
operation: BrowserObservationOperation,
): string => {
if (
typeof value !== "object" ||
value === null ||
!("sessionId" in value) ||
typeof value.sessionId !== "string" ||
value.sessionId.length === 0
)
throw new BrowserObservationError(operation, "protocol_error");
return value.sessionId;
};
+9 -4
View File
@@ -37,6 +37,7 @@ import {
settleScenarioCommand,
withScenarioCommandDeadline,
} from "./ScenarioCommandDeadline.js";
import { admitCdpReply } from "./protocol/CdpCommands.js";
const OPERATION = "capture_browser_scenario" as const;
@@ -417,11 +418,15 @@ const findConnectedPage = async (
}
try {
throwIfScenarioCancelled(signal);
const { targetInfo } = await withScenarioCommandDeadline(
() => session.send("Target.getTargetInfo"),
commandTimeoutMs,
"command",
const { targetInfo } = admitCdpReply(
"Target.getTargetInfo",
await withScenarioCommandDeadline(
() => session.send("Target.getTargetInfo"),
commandTimeoutMs,
"command",
"Target.getTargetInfo",
),
OPERATION,
);
throwIfScenarioCancelled(signal);
if (targetInfo.targetId !== targetId) continue;
@@ -24,6 +24,7 @@ import {
type CdpTargetSession,
} from "./CdpTargetSession.js";
import { withPlaywrightExecutionBoundary } from "./PlaywrightExecutionBoundary.js";
import type { CdpSendParams, CdpSendResult } from "./protocol/CdpCommands.js";
const OPERATION = "capture_browser_scenario" as const;
// Restoration performs several sequential CDP mutations before the transport's
@@ -46,7 +47,6 @@ const serviceWorkerPolicyCheckTimeout = (): AnalysisTimeoutError => {
});
};
const scriptResponse = z.object({ identifier: z.string() });
const restorationResponse = z.object({
result: z.object({ value: z.literal("policy_owned").nullable() }),
});
@@ -218,8 +218,10 @@ export class PlaywrightScenarioServiceWorkers {
if (this.#closed) return;
await this.send(target.id, "Page.enable");
if (this.#closed) return;
const added = scriptResponse.parse(
await this.send(target.id, "Page.addScriptToEvaluateOnNewDocument", {
const added = await this.send(
target.id,
"Page.addScriptToEvaluateOnNewDocument",
{
runImmediately: true,
source: `(() => {
if (!("serviceWorker" in navigator)) return;
@@ -240,7 +242,7 @@ export class PlaywrightScenarioServiceWorkers {
}});
Object.defineProperty(prototype, "register", { ...descriptor, configurable: true, value: register });
})()`,
}),
},
);
target.identifier = added.identifier;
if (!this.#closed)
@@ -261,11 +263,11 @@ export class PlaywrightScenarioServiceWorkers {
}
}
private send(
private send<M extends string>(
id: string,
method: string,
params: Readonly<Record<string, unknown>> = {},
): Promise<unknown> {
method: M,
params: CdpSendParams<M> = {} as CdpSendParams<M>,
): Promise<CdpSendResult<M>> {
if (this.#session === undefined)
throw new BrowserObservationError(OPERATION, "disconnected");
return this.#session.connection.send(method, params, id);
@@ -61,7 +61,8 @@ class FakeCdp extends EventEmitter {
if (method === "Storage.getStorageKey")
return { storageKey: this.storageKey };
if (method === "Page.getFrameTree") return { frameTree: this.frameTree };
if (method === "Debugger.evaluateOnCallFrame") return {};
if (method === "Debugger.evaluateOnCallFrame")
return { result: { type: "undefined" } };
return {};
}
+21 -30
View File
@@ -25,12 +25,6 @@ import {
withScenarioCommandDeadline,
} from "./ScenarioCommandDeadline.js";
const breakpointResponse = z.object({ breakpointId: z.string() });
const scriptIdentifierResponse = z.object({ identifier: z.string() });
const storageKeyResponse = z.object({ storageKey: z.string() });
const targetInfoResponse = z.object({
targetInfo: z.object({ targetId: z.string() }),
});
type StorageEventMethod =
| "Runtime.executionContextCreated"
| "Runtime.executionContextDestroyed"
@@ -74,9 +68,6 @@ const pausedEvent = z.object({
}),
),
});
const evaluationResponse = z.object({
exceptionDetails: z.unknown().optional(),
});
const STORAGE_CLEANUP_TIMEOUT_MS = 1_000;
type StorageSeed = {
@@ -244,13 +235,15 @@ export class PlaywrightScenarioStorage {
page: Page,
context: BrowserContext,
): Promise<void> {
const rootInfo = targetInfoResponse.parse(
const rootInfo = admitCdpReply(
"Target.getTargetInfo",
await withScenarioCommandDeadline(
() => this.cdp.send("Target.getTargetInfo"),
this.commandTimeoutMs,
"command",
"Target.getTargetInfo",
),
"capture_browser_scenario",
);
if (this.#closed) return;
const root = this.createTarget(
@@ -363,19 +356,15 @@ export class PlaywrightScenarioStorage {
if (this.#closed) return;
await target.send("Debugger.enable");
if (this.#closed) return;
breakpointResponse.parse(
await target.send("Debugger.setBreakpointByUrl", {
url: this.#sourceUrl,
lineNumber: 1,
}),
);
await target.send("Debugger.setBreakpointByUrl", {
url: this.#sourceUrl,
lineNumber: 1,
});
if (this.#closed) return;
scriptIdentifierResponse.parse(
await target.send("Page.addScriptToEvaluateOnNewDocument", {
worldName: this.#sourceUrl,
source: `(() => {\n void 0;\n})()\n//# sourceURL=${this.#sourceUrl}`,
}),
);
await target.send("Page.addScriptToEvaluateOnNewDocument", {
worldName: this.#sourceUrl,
source: `(() => {\n void 0;\n})()\n//# sourceURL=${this.#sourceUrl}`,
});
if (this.#closed) return;
await target.send("Target.setAutoAttach", {
autoAttach: true,
@@ -505,11 +494,11 @@ export class PlaywrightScenarioStorage {
const seed = this.#seeds.get(origin);
if (seed === undefined) return;
const storageKey = storageKeyResponse.parse(
await this.sendObserved(target, "Storage.getStorageKey", {
frameId,
}),
).storageKey;
const { storageKey } = await this.sendObserved(
target,
"Storage.getStorageKey",
{ frameId },
);
const sessionKey = JSON.stringify([target.topLevelTargetId, storageKey]);
const local =
this.#initializedLocal.has(storageKey) || seed.local.length === 0
@@ -525,12 +514,14 @@ export class PlaywrightScenarioStorage {
"<",
"\\u003c",
);
const response = evaluationResponse.parse(
await this.sendObserved(target, "Debugger.evaluateOnCallFrame", {
const response = await this.sendObserved(
target,
"Debugger.evaluateOnCallFrame",
{
callFrameId: frame.callFrameId,
expression: `(() => { const seed = ${payload}; for (const [name, value] of seed.local) localStorage.setItem(name, value); for (const [name, value] of seed.session) sessionStorage.setItem(name, value); })()`,
returnByValue: true,
}),
},
);
if (response.exceptionDetails !== undefined)
throw new BrowserObservationError(
+13 -28
View File
@@ -1,17 +1,13 @@
import { z } from "zod";
import { AnalysisError } from "../../domain/analysisErrorBase.js";
import { BrowserObservationError } from "../../domain/browserObservationError.js";
import {
AnalysisCancelledError,
AnalysisOutputError,
} from "../../domain/analysisErrorCore.js";
import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js";
import type {
ObserveWebExecutionInput,
WebExecution,
WebExecutionObservation,
} from "../../domain/webExecution.js";
import { CdpExecutionCollection } from "./CdpExecutionCollection.js";
import { preciseCoverageSchema } from "./CdpRuntimeProtocol.js";
import type { CdpCommandResult } from "../protocol/CdpCommands.js";
import { normalizePreciseCoverage } from "./CdpPreciseCoverage.js";
import type { CdpRuntimeSources } from "./CdpRuntimeSources.js";
@@ -29,13 +25,11 @@ export const captureWebExecution = async (
await session.assertDocument();
sources.check();
session.preciseCoverageMayBeActive = true;
const started = z.object({ timestamp: z.number().min(0) }).parse(
await session.command("Profiler.startPreciseCoverage", {
callCount: true,
detailed: true,
allowTriggeredUpdates: false,
}),
);
const started = await session.command("Profiler.startPreciseCoverage", {
callCount: true,
detailed: true,
allowTriggeredUpdates: false,
});
sources.check();
window.check();
if (window.reason !== undefined)
@@ -69,29 +63,20 @@ export const captureWebExecution = async (
});
sources.check();
requests.check();
let raw: unknown;
let sampled: CdpCommandResult<"Profiler.takePreciseCoverage"> | undefined;
if (reason === "window_elapsed") {
await session.assertDocument();
raw = await session.command("Profiler.takePreciseCoverage");
sampled = await session.command("Profiler.takePreciseCoverage");
}
collection.freezeMetadata();
await stopPreciseCoverage(session, reason);
session.preciseCoverageMayBeActive = false;
const requestItems = requests.result();
const parsed =
raw === undefined ? undefined : preciseCoverageSchema.safeParse(raw);
if (parsed !== undefined && !parsed.success)
throw new AnalysisOutputError(
session.operation,
`Malformed precise coverage: ${parsed.error.message}`,
);
const mainScripts = [...sources.scripts.keys()].filter((id) =>
sources.belongsToDocument(id),
);
const reportedIds = new Set(
parsed?.success === true
? parsed.data.result.map((script) => script.scriptId)
: [],
sampled?.result.map((script) => script.scriptId) ?? [],
);
const ids = [
...mainScripts,
@@ -107,9 +92,9 @@ export const captureWebExecution = async (
sources.belongsToDocument(id),
);
const sample =
parsed?.success === true
? normalizePreciseCoverage(parsed.data, sources, retained)
: undefined;
sampled === undefined
? undefined
: normalizePreciseCoverage(sampled, sources, retained);
await session.options.progress?.report({
phase: "browser_execution",
completed: 2,
+2 -3
View File
@@ -1,13 +1,12 @@
import type { z } from "zod";
import { AnalysisOutputError } from "../../domain/analysisErrorCore.js";
import type { WebExecution } from "../../domain/webExecution.js";
import type { WebRuntimeSource } from "../../domain/webRuntime.js";
import type { preciseCoverageSchema } from "./CdpRuntimeProtocol.js";
import type { CdpCommandResult } from "../protocol/CdpCommands.js";
import type { CdpRuntimeSources } from "./CdpRuntimeSources.js";
/** Validate source-range bounds against exact retained UTF-16 text without flattening nested counts. */
export const normalizePreciseCoverage = (
parsed: z.infer<typeof preciseCoverageSchema>,
parsed: CdpCommandResult<"Profiler.takePreciseCoverage">,
sources: CdpRuntimeSources,
retained: readonly WebRuntimeSource[],
): {
@@ -2,28 +2,6 @@ import { z } from "zod";
import { cdpResourceColumnSchema } from "../../domain/cdpObservationSchemas.js";
const integer = z.int().min(0);
const range = z.object({
startOffset: integer,
endOffset: integer,
count: integer,
});
/** CDP precise coverage preserves every nested producer range. */
export const preciseCoverageSchema = z.object({
timestamp: z.number().min(0),
result: z.array(
z.object({
scriptId: z.string().min(1),
url: z.string(),
functions: z.array(
z.object({
functionName: z.string(),
isBlockCoverage: z.boolean(),
ranges: z.array(range),
}),
),
}),
),
});
/** The full reported synchronous and embedded asynchronous stack is retained. */
export const runtimeStackSchema = z.object({
callFrames: z.array(
+7 -11
View File
@@ -247,29 +247,25 @@ export class CdpRuntimeSources {
}
private async capture(scriptId: string): Promise<WebRuntimeSource["source"]> {
let raw: unknown;
let text: string;
try {
raw = await this.session.command("Debugger.getScriptSource", {
scriptId,
});
({ scriptSource: text } = await this.session.command(
"Debugger.getScriptSource",
{ scriptId },
));
} catch (cause: unknown) {
if (
this.session.options.signal?.aborted ||
!(cause instanceof AnalysisError)
)
throw cause;
// A failed or malformed reply limits only this script. The reason is the
// boundary's own failure text, which names a malformed reply's pointer.
return {
state: "unavailable",
reason: cause.userMessage ?? cause.message,
};
}
const source = z.object({ scriptSource: z.string() }).safeParse(raw);
if (!source.success)
throw new AnalysisOutputError(
this.session.operation,
"Debugger.getScriptSource returned malformed source text.",
);
const text = source.data.scriptSource;
this.#sourceBytes += Buffer.byteLength(text);
assertRuntimeRetention(
this.session.operation,
@@ -66,9 +66,9 @@ const untypedCommands = (
};
it("distinguishes replacement untyped commands even when the total is unchanged", () => {
const commands = protocolCommands();
expect(untypedCommands('send("Runtime.enable")', commands)).not.toEqual(
untypedCommands('send("Debugger.enable")', commands),
const commands = new Set(["Fixture.first", "Fixture.second"]);
expect(untypedCommands('send("Fixture.first")', commands)).not.toEqual(
untypedCommands('send("Fixture.second")', commands),
);
});
+60 -1
View File
@@ -1,5 +1,5 @@
import type { CDPSession } from "playwright-core";
import type { z } from "zod";
import { z } from "zod";
import { BrowserObservationError } from "../../domain/browserObservationError.js";
import type { BrowserObservationOperation } from "../../domain/browserObservationErrors.js";
@@ -17,13 +17,72 @@ declare const protocolSend: CDPSession["send"];
/** Every command name in the protocol version Playwright ships. */
export type CdpProtocolMethod = Parameters<CDPSession["send"]>[0];
/** A command whose reply REA does not read beyond its success. */
const acknowledgement = z.object({});
const coverageOffset = z.int().min(0);
/** Precise coverage keeps every nested producer range; counts are not flattened. */
const preciseCoverageReply = z.object({
timestamp: z.number().min(0),
result: z.array(
z.object({
scriptId: z.string().min(1),
url: z.string(),
functions: z.array(
z.object({
functionName: z.string(),
isBlockCoverage: z.boolean(),
ranges: z.array(
z.object({
startOffset: coverageOffset,
endOffset: coverageOffset,
count: coverageOffset,
}),
),
}),
),
}),
),
});
/**
* One result schema per command REA sends. Each schema is the only reader of
* that command's raw reply; consumers receive its parsed output.
*/
const CDP_COMMAND_REPLIES = {
"Debugger.enable": acknowledgement,
"Debugger.evaluateOnCallFrame": z.object({
result: z.looseObject({}),
exceptionDetails: z.unknown().optional(),
}),
"Debugger.getScriptSource": z.object({ scriptSource: z.string() }),
"Debugger.resume": acknowledgement,
"Debugger.setBreakpointByUrl": z.object({ breakpointId: z.string() }),
"Network.enable": acknowledgement,
"Page.addScriptToEvaluateOnNewDocument": z.object({
identifier: z.string(),
}),
"Page.enable": acknowledgement,
"Page.getFrameTree": cdpFrameTreeReplySchema,
"Page.getResourceTree": cdpResourceTreeReplySchema,
"Page.removeScriptToEvaluateOnNewDocument": acknowledgement,
"Page.setLifecycleEventsEnabled": acknowledgement,
"Page.stopLoading": acknowledgement,
"Performance.enable": acknowledgement,
"Profiler.startPreciseCoverage": z.object({ timestamp: z.number().min(0) }),
"Profiler.stopPreciseCoverage": acknowledgement,
"Profiler.takePreciseCoverage": preciseCoverageReply,
"Runtime.enable": acknowledgement,
"Runtime.releaseObjectGroup": acknowledgement,
"Runtime.runIfWaitingForDebugger": acknowledgement,
"Storage.getStorageKey": z.object({ storageKey: z.string() }),
"Target.attachToTarget": z.object({ sessionId: z.string().min(1) }),
"Target.detachFromTarget": acknowledgement,
"Target.getTargetInfo": z.object({
targetInfo: z.object({ targetId: z.string() }),
}),
"Target.setAutoAttach": acknowledgement,
} as const satisfies { readonly [M in CdpProtocolMethod]?: z.ZodType };
export type CdpCommand = keyof typeof CDP_COMMAND_REPLIES;
@@ -13,9 +13,6 @@
"CacheStorage.requestCachedResponse": 1
},
"src/browser/CdpElectronInspection.ts": {
"Page.enable": 1,
"Runtime.enable": 1,
"Debugger.enable": 1,
"DOMSnapshot.captureSnapshot": 1
},
"src/browser/CdpElectronWorkers.ts": {
@@ -26,45 +23,25 @@
"Target.getTargets": 1
},
"src/browser/CdpObservationSession.ts": {
"Page.enable": 1,
"Network.enable": 1,
"Performance.enable": 1,
"Performance.getMetrics": 1,
"Page.setLifecycleEventsEnabled": 1
"Performance.getMetrics": 1
},
"src/browser/CdpPageCapture.ts": {
"Network.getResponseBody": 2,
"Page.enable": 1,
"DOMSnapshot.captureSnapshot": 1,
"DOM.describeNode": 1,
"Runtime.enable": 1,
"Debugger.enable": 1,
"Network.enable": 1,
"Accessibility.getFullAXTree": 1
},
"src/browser/CdpPageCaptureWorkers.ts": {
"Target.getTargets": 1
},
"src/browser/CdpScreenshot.ts": {
"Page.enable": 1,
"Page.getLayoutMetrics": 1,
"Page.captureScreenshot": 1
},
"src/browser/CdpScriptSourceCapture.ts": {
"Debugger.getScriptSource": 1
},
"src/browser/CdpTargetSession.ts": {
"Target.attachToTarget": 1,
"Target.detachFromTarget": 1
},
"src/browser/CdpWebMcpDiscovery.ts": {
"Page.enable": 1,
"WebMCP.enable": 1
},
"src/browser/PlaywrightScenarioBrowser.ts": {
"Target.attachToTarget": 2,
"Target.getTargetInfo": 2,
"Target.detachFromTarget": 1,
"Emulation.setEmulatedMedia": 1,
"Emulation.setDeviceMetricsOverride": 2,
"Emulation.setLocaleOverride": 3,
@@ -73,30 +50,10 @@
"Emulation.clearDeviceMetricsOverride": 1
},
"src/browser/PlaywrightScenarioServiceWorkers.ts": {
"Page.enable": 1,
"Page.addScriptToEvaluateOnNewDocument": 1,
"Target.setAutoAttach": 1,
"Runtime.runIfWaitingForDebugger": 1,
"Runtime.evaluate": 1,
"Page.stopLoading": 1,
"Page.removeScriptToEvaluateOnNewDocument": 1,
"Target.detachFromTarget": 1
"Runtime.evaluate": 1
},
"src/browser/PlaywrightScenarioStorage.ts": {
"Target.attachToTarget": 1,
"Target.getTargetInfo": 2,
"Page.enable": 1,
"Runtime.enable": 1,
"Debugger.enable": 1,
"Debugger.setBreakpointByUrl": 1,
"Page.addScriptToEvaluateOnNewDocument": 1,
"Target.setAutoAttach": 1,
"Storage.getStorageKey": 1,
"Debugger.evaluateOnCallFrame": 1,
"Debugger.resume": 1,
"Target.sendMessageToTarget": 1,
"Runtime.runIfWaitingForDebugger": 1,
"Page.stopLoading": 1
"Target.sendMessageToTarget": 1
},
"src/browser/execution/CdpEventListeners.ts": {
"DOM.getDocument": 1,
@@ -104,24 +61,7 @@
"DOM.resolveNode": 1,
"DOMDebugger.getEventListeners": 1
},
"src/browser/execution/CdpExecutionCapture.ts": {
"Profiler.startPreciseCoverage": 1,
"Profiler.takePreciseCoverage": 1,
"Profiler.stopPreciseCoverage": 1
},
"src/browser/execution/CdpListenerSelector.ts": {
"DOM.querySelector": 2
},
"src/browser/execution/CdpRuntimeSession.ts": {
"Profiler.stopPreciseCoverage": 1,
"Runtime.releaseObjectGroup": 1,
"Target.detachFromTarget": 1
},
"src/browser/execution/CdpRuntimeSources.ts": {
"Debugger.getScriptSource": 1
},
"src/inspector/V8InspectorProvider.ts": {
"Runtime.enable": 1,
"Debugger.enable": 1
}
}
@@ -138,7 +138,9 @@ describe("CdpBrowserProvider: approved accessibility and source capture", () =>
error: {
operation: "inspect_web_page",
reason: "protocol_error",
userMessage: expect.stringContaining("scriptSource must be a string"),
userMessage: expect.stringContaining(
"Debugger.getScriptSource returned a malformed result at /scriptSource",
),
},
});
expect(browser.commands.map(({ method }) => method)).toContain(
+39 -7
View File
@@ -108,6 +108,31 @@ describe("CDP connection admission cancellation", () => {
});
describe("CDP connection response correlation", () => {
it("preserves the remote value of a debugger evaluation", async () => {
const browser = await startFakeCdpBrowser({
commandResult: (command) =>
command.method === "Debugger.evaluateOnCallFrame"
? { result: { type: "boolean", value: false } }
: undefined,
});
browsers.push(browser);
const connection = await CdpConnection.connect(
browser.browserWebSocketUrl,
"inspect_web_page",
);
try {
await expect(
connection.send("Debugger.evaluateOnCallFrame", {
callFrameId: "frame-1",
expression: "false",
returnByValue: true,
}),
).resolves.toEqual({ result: { type: "boolean", value: false } });
} finally {
await connection.close();
}
});
it("rejects one malformed registered reply without poisoning the connection", async () => {
const browser = await startFakeCdpBrowser({
commandResult: (command) =>
@@ -144,7 +169,9 @@ describe("CDP connection response correlation", () => {
});
it("correlates concurrent command responses over a real WebSocket", async () => {
const browser = await startFakeCdpBrowser({ hangOnMethod: "Page.enable" });
const browser = await startFakeCdpBrowser({
hangOnMethod: "Runtime.evaluate",
});
browsers.push(browser);
const connection = await CdpConnection.connect(
browser.browserWebSocketUrl,
@@ -159,8 +186,11 @@ describe("CDP connection response correlation", () => {
expect(frames.root.facts.frameId).toBe("frame-main");
browser.emitRawMessage(JSON.stringify({ id: 999 }));
await expect(connection.send("Runtime.enable")).resolves.toEqual({});
const first = connection.send("Page.enable", { selected: "first" });
const second = connection.send("Page.enable", { selected: "second" });
// Runtime.evaluate has no result schema yet, so raw replies return.
const first = connection.send("Runtime.evaluate", { selected: "first" });
const second = connection.send("Runtime.evaluate", {
selected: "second",
});
const replies = Promise.all([first, second]);
await waitFor(() => expect(browser.commands).toHaveLength(5));
const firstCommand = browser.commands[3];
@@ -395,14 +425,16 @@ describe("CDP transport failure precedence", () => {
describe("CDP connection command responses", () => {
it("rejects invalid UTF-8 replies without aliasing a literal replacement character", async () => {
const browser = await startFakeCdpBrowser({ hangOnMethod: "Page.enable" });
const browser = await startFakeCdpBrowser({
hangOnMethod: "Runtime.evaluate",
});
browsers.push(browser);
const connection = await CdpConnection.connect(
browser.browserWebSocketUrl,
"inspect_web_page",
);
try {
const control = connection.send("Page.enable");
const control = connection.send("Runtime.evaluate");
await waitFor(() => expect(browser.commands).toHaveLength(1));
browser.emitRawMessage(
Buffer.from(
@@ -413,7 +445,7 @@ describe("CDP connection command responses", () => {
),
);
await expect(control).resolves.toEqual({ value: "�" });
const pending = connection.send("Page.enable");
const pending = connection.send("Runtime.evaluate");
const rejected = expect(pending).rejects.toMatchObject({
reason: "protocol_error",
});
@@ -541,7 +573,7 @@ describe("CDP connection payload budgets", () => {
undefined,
{ maxPayloadBytes },
);
await expect(connection.send("Runtime.enable")).resolves.toEqual(
await expect(connection.send("Runtime.evaluate")).resolves.toEqual(
exactResult,
);
await connection.close();
@@ -828,7 +828,9 @@ describe("CdpElectronProvider malformed source replies", () => {
error: {
operation: "inspect_electron_page",
reason: "protocol_error",
userMessage: expect.stringContaining("scriptSource must be a string"),
userMessage: expect.stringContaining(
"Debugger.getScriptSource returned a malformed result at /scriptSource",
),
},
});
expect(browser.commands.map(({ method }) => method)).toContain(
+56 -1
View File
@@ -465,7 +465,13 @@ describe("runtime lifecycle and invalid producer data", () => {
});
const result = await provider.observeExecution(execution);
if (result.ok) throw new Error("Malformed producer coverage must fail");
expect(result.error._tag).toBe("AnalysisOutputError");
expect(result.error).toMatchObject({
_tag: "BrowserObservationError",
operation: "observe_web_execution",
reason: "protocol_error",
command: "Profiler.takePreciseCoverage",
pointer: "/result",
});
expect(
browser.commands.some(
(command) => command.method === "Profiler.stopPreciseCoverage",
@@ -473,6 +479,55 @@ describe("runtime lifecycle and invalid producer data", () => {
).toBe(true);
});
it("rejects a malformed instrumentation start at the protocol boundary", async () => {
const { browser, provider, execution } = await fixture({
commandResult: (command) =>
command.method === "Profiler.startPreciseCoverage"
? { timestamp: "now" }
: undefined,
});
const result = await provider.observeExecution(execution);
if (result.ok) throw new Error("Malformed instrumentation start must fail");
expect(result.error).toMatchObject({
_tag: "BrowserObservationError",
reason: "protocol_error",
command: "Profiler.startPreciseCoverage",
pointer: "/timestamp",
});
expect(
browser.commands.some(
(command) => command.method === "Profiler.stopPreciseCoverage",
),
).toBe(true);
});
it("limits one script whose source reply is malformed and keeps the others", async () => {
const { provider, execution } = await fixture({
commandResult: (command) =>
command.method === "Debugger.getScriptSource" &&
command.params.scriptId === "script-b"
? { scriptSource: 7 }
: undefined,
});
const result = await provider.observeExecution(execution);
if (!result.ok) throw result.error;
const source = (scriptId: string) =>
result.value.sources.find(({ script_id }) => script_id === scriptId)
?.source;
expect(source("script-a")).toMatchObject({ state: "captured" });
expect(source("script-b")).toEqual({
state: "unavailable",
reason: expect.stringContaining(
"Debugger.getScriptSource returned a malformed result at /scriptSource",
),
});
expect(
result.value.coverage.scripts
.find(({ script_id }) => script_id === "script-b")
?.functions[0]?.ranges.map(({ source_bounds }) => source_bounds),
).toEqual(["unknown"]);
});
it("rejects a source range measured as UTF-8 bytes instead of UTF-16 units", async () => {
const { provider, execution } = await fixture({
commandResult: (command, origin) =>
@@ -249,6 +249,7 @@ it.skipIf(process.platform === "win32")(
},
);
await waitForFile(parentExited);
await launcher.exits[0];
controller.abort(reason);
const cause = await pending.catch((error: unknown) => error);
const cancellation = kunaInstallationCancellation(cause);
@@ -48,6 +48,7 @@ export const makeLauncher = (
) => {
let launches = 0;
const groups: number[] = [];
const exits: Promise<void>[] = [];
const launcher = async (
options: OwnedProviderProcessSpawnOptions,
): Promise<SpawnedOwnedProviderProcess> => {
@@ -62,6 +63,7 @@ export const makeLauncher = (
detached: true,
stdio: ["ignore", "pipe", "pipe"],
});
exits.push(new Promise((resolve) => child.once("exit", () => resolve())));
await new Promise<void>((resolve, reject) => {
child.once("spawn", resolve);
child.once("error", reject);
@@ -84,6 +86,7 @@ export const makeLauncher = (
return {
launcher,
groups,
exits,
get launches() {
return launches;
},