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; },