mirror of
https://github.com/morluto/rea.git
synced 2026-11-15 11:13:21 +00:00
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:
1 parent
f3b697da5a
commit
3e9400873e
20 files changed
+239
-223
No files matched your search
@@ -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,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);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 {};
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user