From 3e9400873eb9d66d9b41248e52128595d0dd7cd3 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 15 Nov 2026 07:40:44 +0800 Subject: [PATCH] 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. --- src/browser/CdpCaptureValues.ts | 14 ---- src/browser/CdpScriptSourceCapture.ts | 9 +-- src/browser/CdpTargetSession.ts | 20 +----- src/browser/PlaywrightScenarioBrowser.ts | 13 ++-- .../PlaywrightScenarioServiceWorkers.ts | 18 ++--- src/browser/PlaywrightScenarioStorage.test.ts | 3 +- src/browser/PlaywrightScenarioStorage.ts | 51 ++++++-------- src/browser/execution/CdpExecutionCapture.ts | 41 ++++-------- src/browser/execution/CdpPreciseCoverage.ts | 5 +- src/browser/execution/CdpRuntimeProtocol.ts | 22 ------- src/browser/execution/CdpRuntimeSources.ts | 18 ++--- .../protocol/CdpCommands.guard.test.ts | 6 +- src/browser/protocol/CdpCommands.ts | 61 ++++++++++++++++- .../CdpCommands.untyped-baseline.json | 66 +------------------ ...cdpBrowserProvider.document_script.test.ts | 4 +- tests/boundary/browser/cdpConnection.test.ts | 46 +++++++++++-- .../browser/cdpElectronProvider.test.ts | 4 +- tests/boundary/browser/cdpWebRuntime.test.ts | 57 +++++++++++++++- .../kuna/kunaVersionProbeOwnership.test.ts | 1 + .../providers/kuna/kunaVersionProbeProcess.ts | 3 + 20 files changed, 239 insertions(+), 223 deletions(-) diff --git a/src/browser/CdpCaptureValues.ts b/src/browser/CdpCaptureValues.ts index 3cd7116167..07469f6e8d 100644 --- a/src/browser/CdpCaptureValues.ts +++ b/src/browser/CdpCaptureValues.ts @@ -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 { diff --git a/src/browser/CdpScriptSourceCapture.ts b/src/browser/CdpScriptSourceCapture.ts index ab4698d08d..12c80dd370 100644 --- a/src/browser/CdpScriptSourceCapture.ts +++ b/src/browser/CdpScriptSourceCapture.ts @@ -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); diff --git a/src/browser/CdpTargetSession.ts b/src/browser/CdpTargetSession.ts index 65072faf7e..d291203239 100644 --- a/src/browser/CdpTargetSession.ts +++ b/src/browser/CdpTargetSession.ts @@ -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 => { 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; -}; diff --git a/src/browser/PlaywrightScenarioBrowser.ts b/src/browser/PlaywrightScenarioBrowser.ts index 34201ad353..08f226d804 100644 --- a/src/browser/PlaywrightScenarioBrowser.ts +++ b/src/browser/PlaywrightScenarioBrowser.ts @@ -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; diff --git a/src/browser/PlaywrightScenarioServiceWorkers.ts b/src/browser/PlaywrightScenarioServiceWorkers.ts index 9c01e3290d..5aba446a93 100644 --- a/src/browser/PlaywrightScenarioServiceWorkers.ts +++ b/src/browser/PlaywrightScenarioServiceWorkers.ts @@ -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( id: string, - method: string, - params: Readonly> = {}, - ): Promise { + method: M, + params: CdpSendParams = {} as CdpSendParams, + ): Promise> { if (this.#session === undefined) throw new BrowserObservationError(OPERATION, "disconnected"); return this.#session.connection.send(method, params, id); diff --git a/src/browser/PlaywrightScenarioStorage.test.ts b/src/browser/PlaywrightScenarioStorage.test.ts index 0f46e38449..1d235c8ff7 100644 --- a/src/browser/PlaywrightScenarioStorage.test.ts +++ b/src/browser/PlaywrightScenarioStorage.test.ts @@ -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 {}; } diff --git a/src/browser/PlaywrightScenarioStorage.ts b/src/browser/PlaywrightScenarioStorage.ts index 0c7e12b2e8..5486cb99e8 100644 --- a/src/browser/PlaywrightScenarioStorage.ts +++ b/src/browser/PlaywrightScenarioStorage.ts @@ -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 { - 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( diff --git a/src/browser/execution/CdpExecutionCapture.ts b/src/browser/execution/CdpExecutionCapture.ts index 9d8bd43abf..6ab7f1c564 100644 --- a/src/browser/execution/CdpExecutionCapture.ts +++ b/src/browser/execution/CdpExecutionCapture.ts @@ -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, diff --git a/src/browser/execution/CdpPreciseCoverage.ts b/src/browser/execution/CdpPreciseCoverage.ts index 95e2cdc0d2..f9bbf0aaea 100644 --- a/src/browser/execution/CdpPreciseCoverage.ts +++ b/src/browser/execution/CdpPreciseCoverage.ts @@ -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, + parsed: CdpCommandResult<"Profiler.takePreciseCoverage">, sources: CdpRuntimeSources, retained: readonly WebRuntimeSource[], ): { diff --git a/src/browser/execution/CdpRuntimeProtocol.ts b/src/browser/execution/CdpRuntimeProtocol.ts index 40d0de16ba..0b8ebea5fa 100644 --- a/src/browser/execution/CdpRuntimeProtocol.ts +++ b/src/browser/execution/CdpRuntimeProtocol.ts @@ -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( diff --git a/src/browser/execution/CdpRuntimeSources.ts b/src/browser/execution/CdpRuntimeSources.ts index 6c7f61e209..1b57740494 100644 --- a/src/browser/execution/CdpRuntimeSources.ts +++ b/src/browser/execution/CdpRuntimeSources.ts @@ -247,29 +247,25 @@ export class CdpRuntimeSources { } private async capture(scriptId: string): Promise { - 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, diff --git a/src/browser/protocol/CdpCommands.guard.test.ts b/src/browser/protocol/CdpCommands.guard.test.ts index 19f325952d..abeeabd8ab 100644 --- a/src/browser/protocol/CdpCommands.guard.test.ts +++ b/src/browser/protocol/CdpCommands.guard.test.ts @@ -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), ); }); diff --git a/src/browser/protocol/CdpCommands.ts b/src/browser/protocol/CdpCommands.ts index d9f61c5a00..2cb08f7401 100644 --- a/src/browser/protocol/CdpCommands.ts +++ b/src/browser/protocol/CdpCommands.ts @@ -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[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; diff --git a/src/browser/protocol/CdpCommands.untyped-baseline.json b/src/browser/protocol/CdpCommands.untyped-baseline.json index c1e1417ab9..7a97126d77 100644 --- a/src/browser/protocol/CdpCommands.untyped-baseline.json +++ b/src/browser/protocol/CdpCommands.untyped-baseline.json @@ -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 } } diff --git a/tests/boundary/browser/cdpBrowserProvider.document_script.test.ts b/tests/boundary/browser/cdpBrowserProvider.document_script.test.ts index 348998d215..2d445c8455 100644 --- a/tests/boundary/browser/cdpBrowserProvider.document_script.test.ts +++ b/tests/boundary/browser/cdpBrowserProvider.document_script.test.ts @@ -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( diff --git a/tests/boundary/browser/cdpConnection.test.ts b/tests/boundary/browser/cdpConnection.test.ts index 308f07a185..18b861c5b9 100644 --- a/tests/boundary/browser/cdpConnection.test.ts +++ b/tests/boundary/browser/cdpConnection.test.ts @@ -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(); diff --git a/tests/boundary/browser/cdpElectronProvider.test.ts b/tests/boundary/browser/cdpElectronProvider.test.ts index 3831df1f5c..f6c7fe9eaa 100644 --- a/tests/boundary/browser/cdpElectronProvider.test.ts +++ b/tests/boundary/browser/cdpElectronProvider.test.ts @@ -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( diff --git a/tests/boundary/browser/cdpWebRuntime.test.ts b/tests/boundary/browser/cdpWebRuntime.test.ts index da64bd09dc..1ba2d5079a 100644 --- a/tests/boundary/browser/cdpWebRuntime.test.ts +++ b/tests/boundary/browser/cdpWebRuntime.test.ts @@ -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) => diff --git a/tests/boundary/providers/kuna/kunaVersionProbeOwnership.test.ts b/tests/boundary/providers/kuna/kunaVersionProbeOwnership.test.ts index bc8ee2eed3..ac517be3f7 100644 --- a/tests/boundary/providers/kuna/kunaVersionProbeOwnership.test.ts +++ b/tests/boundary/providers/kuna/kunaVersionProbeOwnership.test.ts @@ -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); diff --git a/tests/boundary/providers/kuna/kunaVersionProbeProcess.ts b/tests/boundary/providers/kuna/kunaVersionProbeProcess.ts index fbba6abb3a..97ca041d1c 100644 --- a/tests/boundary/providers/kuna/kunaVersionProbeProcess.ts +++ b/tests/boundary/providers/kuna/kunaVersionProbeProcess.ts @@ -48,6 +48,7 @@ export const makeLauncher = ( ) => { let launches = 0; const groups: number[] = []; + const exits: Promise[] = []; const launcher = async ( options: OwnedProviderProcessSpawnOptions, ): Promise => { @@ -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((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; },