diff --git a/docs/browser-observation.md b/docs/browser-observation.md index 22265bd53f..c2830a91a0 100644 --- a/docs/browser-observation.md +++ b/docs/browser-observation.md @@ -500,7 +500,7 @@ REA retains local observation data and removes only URL userinfo credentials, ex connection/frame inventories; matching direction/opcode sequences alone do not prove alignment after drops. Redirect resource types use only shared known metadata: an omitted type leaves equality unknown rather than proving a modification. -- Console observations with a stack source retain call type, argument types, the CDP `Runtime.Timestamp` in milliseconds since Unix epoch, and source location. An absent or invalid timestamp is `null` and makes `console_events` incomplete; an observed zero remains zero. Argument types are listed by CDP argument position. An absent or non-array argument inventory is `null`, and a malformed argument keeps its position as `unknown`; either makes `console_events` incomplete, while an observed empty inventory remains `[]`. A malformed stack or top stack frame is not replaced by its caller: the console event is excluded, and a request initiator location or WebMCP `registration_source` is `null`; each is reported as `invalid_protocol_value`. This epoch-millisecond clock is separate from the monotonic-second timestamps in `observe-web-session`. When console text capture is selected, already-delivered primitive values are retained verbatim with their original `argument_index`; objects, getters, and remote properties are never expanded. Text capture is `unavailable` when the argument inventory is unknown. +- Console observations with a stack source retain call type, argument types, the CDP `Runtime.Timestamp` in milliseconds since Unix epoch, and source location. An absent or invalid timestamp is `null` and makes `console_events` incomplete; an observed zero remains zero. Argument types are listed by CDP argument position. An absent or non-array argument inventory is `null`, and a malformed argument keeps its position as `unknown`; either makes `console_events` incomplete, while an observed empty inventory remains `[]`. A malformed stack or top stack frame is not replaced by its caller: the console event is excluded, and a request initiator location or WebMCP `registration_source` is `null`; each is reported as `invalid_protocol_value`. Source locations are CDP's zero-based lines and UTF-16 columns. A negative column, which Chrome reports on the first line of some inline scripts, is kept as reported and does not locate a column in the source URL; an invalid line or a non-integer column is `null` (WebMCP `registration_source` is `null`) and is reported as `invalid_protocol_value`. This epoch-millisecond clock is separate from the monotonic-second timestamps in `observe-web-session`. When console text capture is selected, already-delivered primitive values are retained verbatim with their original `argument_index`; objects, getters, and remote properties are never expanded. Text capture is `unavailable` when the argument inventory is unknown. - WebSocket observations retain direction, opcode, and payload byte length. A missing or malformed CDP opcode is returned as `null`, with opcode-dependent payload byte length and shape also `null`, and marks `websocket_frames` incomplete. Missing or wrong-typed payload data is not treated as empty; its byte length is `null`, and a text payload shape is unknown. Numeric opcode `0` and an observed empty payload remain distinct from missing data. Opcode is nullable in the public inspection schema, so clients must handle unknown protocol values. When shape capture is selected, text messages are classified as text or complete value-free JSON shape; binary bytes, hashes, prefixes, and raw payloads are never retained. - Storage observations always redact values. Key names, IndexedDB names, and cache names require `include_storage_keys`; stable content fingerprints require the additional `include_storage_fingerprints` selection. Cookie fingerprints cover cookies applicable to the current main-frame URL, including its path, rather than every cookie path on that origin. Other storage remains scoped to the origin. Cache fingerprints hash all decoded response bytes, including bodies above 64 KiB, subject to the [64 MiB per-message CDP limit](#request-boundary). Missing or undecodable cache bodies and partial IndexedDB remote objects make fingerprint coverage incomplete, so identical observations remain `unknown`. - Unavailable or malformed storage inventories mark `storage_keys` unavailable, even without fingerprints. Comparisons suppress inferred key additions and removals while preserving modifications proven by complete fingerprints on both sides. Previously exported captures are not retroactively corrected; recapture them to obtain these coverage markers. diff --git a/docs/web-runtime.md b/docs/web-runtime.md index abccf9dc55..189d6fc439 100644 --- a/docs/web-runtime.md +++ b/docs/web-runtime.md @@ -22,7 +22,7 @@ Results retain: - Main-document script inventory, exact session script IDs, resource start coordinates, sourceURL/source-map declarations, producer hash and independently computed UTF-8 text digest. - Armed-window requests from the selected main frame and selected origins, complete reported initiator objects (including stack hierarchy/descriptions), derived callsites, exact script-ID associations and unresolved async parent IDs. Source association does not establish UI causality. Callsite and listener `line_number`/`column_number` values are CDP's zero-based lines and UTF-16 columns in the resource named by `url`. With a `script_id`, that is the script's enclosing resource: the script file for an external script, or the HTML document for an inline script. `resource_start` is where the script text starts in that resource. A callsite with an empty `script_id` comes from the request's reported initiator rather than a script call frame, such as markup that the parser read in a document or a module import in a script file. It still locates a position in `url`, but has no session-script association, so it cannot be joined to captured source or `resource_start`. `trace_web_source_location` takes one-based, script-relative lines and zero-based UTF-16 columns. -For an external script, where `url` is the script file, use `line = line_number + 1` and keep `column = column_number`; this also applies to an empty-`script_id` initiator position in a script file. For an inline script, which requires a `script_id` and its `resource_start`, use `line = line_number - resource_start.line_number + 1`. Subtract `resource_start.column_number` from `column_number` only when `line_number === resource_start.line_number`; keep the reported column on later lines. Columns remain zero-based. This conversion requires known source-start and column coordinates; preserve unavailable coordinates as unknown. +For an external script, where `url` is the script file, use `line = line_number + 1` and keep `column = column_number`; this also applies to an empty-`script_id` initiator position in a script file. For an inline script, which requires a `script_id` and its `resource_start`, use `line = line_number - resource_start.line_number + 1`. Subtract `resource_start.column_number` from `column_number` only when `line_number === resource_start.line_number`; keep the reported column on later lines. Columns remain zero-based. This conversion requires known source-start and column coordinates; preserve unavailable coordinates as unknown. Chrome reports a negative start column for some inline scripts, such as one in a helper frame, and positions on that script's first line include it. REA keeps those columns as reported rather than failing the observation; a negative column does not locate a column in `url`, but the subtraction above still gives the script-relative column. The coverage counter interval starts at backend acceptance before the armed receipt and ends at the resetting sample after the local timer. Counts may include execution during these command intervals; request events are restricted to the locally armed window. The result retains both backend timestamps and the distinct local clock. diff --git a/src/browser/CdpCaptureEventHelpers.ts b/src/browser/CdpCaptureEventHelpers.ts index e3e6621e41..f328862c7a 100644 --- a/src/browser/CdpCaptureEventHelpers.ts +++ b/src/browser/CdpCaptureEventHelpers.ts @@ -1,5 +1,6 @@ import { err, ok, type Result } from "../domain/result.js"; import { mediaTypeEssence } from "../domain/analysisView/recordedMediaType.js"; +import { cdpResourceColumnSchema } from "../domain/cdpObservationSchemas.js"; import { admitRecords, isHttpUrl, @@ -57,18 +58,22 @@ export const exclusionReasonForUrl = ( ? "disallowed_origin" : "unsupported_url"; -/** Preserve omitted and invalid protocol offsets as unknown, never invented coordinates. */ +/** + * Preserve omitted and invalid protocol offsets as unknown, never invented + * coordinates. Columns follow the shared CDP resource-column policy, which + * keeps a negative column Chrome reports. + */ export const cdpSourceCoordinates = (frame: UnknownRecord | undefined) => { - const valid = (value: unknown): value is number => + const validLine = (value: unknown): value is number => typeof value === "number" && Number.isSafeInteger(value) && value >= 0; const line = frame?.lineNumber; - const column = frame?.columnNumber; + const column = cdpResourceColumnSchema.safeParse(frame?.columnNumber); return { - line: valid(line) ? line : null, - column: valid(column) ? column : null, + line: validLine(line) ? line : null, + column: column.success ? column.data : null, invalid: - (line !== undefined && !valid(line)) || - (column !== undefined && !valid(column)), + (line !== undefined && !validLine(line)) || + (frame?.columnNumber !== undefined && !column.success), }; }; diff --git a/src/browser/CdpNetworkEventFacts.ts b/src/browser/CdpNetworkEventFacts.ts index 54f3f26632..ae7fa548d3 100644 --- a/src/browser/CdpNetworkEventFacts.ts +++ b/src/browser/CdpNetworkEventFacts.ts @@ -11,6 +11,7 @@ import { recordValue, type UnknownRecord, } from "./CdpCaptureValues.js"; +import { cdpResourceColumnSchema } from "../domain/cdpObservationSchemas.js"; const integer = z.int().min(0); /** Active runtime admission remains stricter than passive request observations. */ @@ -25,7 +26,7 @@ export const runtimeRequestSchema = z.object({ stack: z.unknown().optional(), url: z.string().optional(), lineNumber: integer.optional(), - columnNumber: integer.optional(), + columnNumber: cdpResourceColumnSchema.optional(), }) .passthrough(), }); diff --git a/src/browser/CdpScriptContextFacts.ts b/src/browser/CdpScriptContextFacts.ts index 0ba1ae0cf9..a95d1db86a 100644 --- a/src/browser/CdpScriptContextFacts.ts +++ b/src/browser/CdpScriptContextFacts.ts @@ -4,6 +4,7 @@ import { numberValue, recordValue, } from "./CdpCaptureValues.js"; +import { cdpResourceColumnSchema } from "../domain/cdpObservationSchemas.js"; const integer = z.int().min(0); const contextAuxDataSchema = z @@ -31,9 +32,9 @@ export const runtimeScriptParsedSchema = scriptDeclaration.extend({ scriptId: z.string().min(1), executionContextId: integer, startLine: integer, - startColumn: integer, + startColumn: cdpResourceColumnSchema, endLine: integer, - endColumn: integer, + endColumn: cdpResourceColumnSchema, hash: z.string().optional(), sourceMapURL: z.string().optional(), hasSourceURL: z.boolean().optional(), diff --git a/src/browser/execution/CdpRuntimeProtocol.ts b/src/browser/execution/CdpRuntimeProtocol.ts index a9fdb5bff2..2b90e09d8c 100644 --- a/src/browser/execution/CdpRuntimeProtocol.ts +++ b/src/browser/execution/CdpRuntimeProtocol.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { cdpResourceColumnSchema } from "../../domain/cdpObservationSchemas.js"; const integer = z.int().min(0); const range = z.object({ @@ -30,7 +31,7 @@ export const runtimeStackSchema = z.object({ scriptId: z.string(), url: z.string(), lineNumber: integer, - columnNumber: integer, + columnNumber: cdpResourceColumnSchema, functionName: z.string(), }), ), @@ -49,7 +50,7 @@ export const runtimeListenersSchema = z.object({ once: z.boolean(), scriptId: z.string(), lineNumber: integer, - columnNumber: integer, + columnNumber: cdpResourceColumnSchema, }), ), }); diff --git a/src/domain/browserObservationSchemas.ts b/src/domain/browserObservationSchemas.ts index bafa37e644..b020db89db 100644 --- a/src/domain/browserObservationSchemas.ts +++ b/src/domain/browserObservationSchemas.ts @@ -4,6 +4,7 @@ import { utcDateTimeSchema } from "./offsetDateTime.js"; import { browserCompletenessSchema } from "./browserCompleteness.js"; import { cdpDomSnapshotSchema, + cdpResourceColumnSchema, cdpScriptSourceSchema, cdpScriptMetadataSchema, } from "./cdpObservationSchemas.js"; @@ -125,7 +126,7 @@ const browserNetworkRequestSchema = z.object({ type: z.string(), url: z.string().nullable(), line: z.int().min(0).nullable(), - column: z.int().min(0).nullable(), + column: cdpResourceColumnSchema.nullable(), }), body_shapes: z.object({ status: z.enum(["not_approved", "included", "partial", "unavailable"]), @@ -149,7 +150,7 @@ const browserConsoleEventSchema = z.object({ ), url: z.string().nullable(), line: z.int().min(0).nullable(), - column: z.int().min(0).nullable(), + column: cdpResourceColumnSchema.nullable(), text_capture: z.object({ status: z.enum(["not_approved", "included", "unavailable"]), values: z.array( diff --git a/src/domain/cdpObservationSchemas.ts b/src/domain/cdpObservationSchemas.ts index 8af603df05..6e705d1192 100644 --- a/src/domain/cdpObservationSchemas.ts +++ b/src/domain/cdpObservationSchemas.ts @@ -1,6 +1,15 @@ import { z } from "zod"; import { webTextArtifactSchema } from "./webContentArtifact.js"; +/** + * Zero-based UTF-16 column in a resource, as CDP reports it. CDP declares + * columns as plain integers. Chrome reports a negative start column for some + * inline scripts, and positions on a script's first line include that start + * column, so a negative column is kept as reported. It does not locate a + * column in the resource. + */ +export const cdpResourceColumnSchema = z.int(); + /** Structural DOM facts shared by passive web and Electron observations. */ export const cdpDomNodeSchema = z.object({ index: z.number().int().min(0), diff --git a/src/domain/webMcpDiscovery.ts b/src/domain/webMcpDiscovery.ts index a44dae347f..bbcbce5fa2 100644 --- a/src/domain/webMcpDiscovery.ts +++ b/src/domain/webMcpDiscovery.ts @@ -7,6 +7,7 @@ import { browserObservationEndpointSchema, } from "./browserObservation.js"; import { browserVersionSchema } from "./browserObservationSchemas.js"; +import { cdpResourceColumnSchema } from "./cdpObservationSchemas.js"; import { jsonValueSchema } from "./jsonValue.js"; import { digestSchema, prefixedDigestSchema } from "./../domain/digests.js"; @@ -44,7 +45,7 @@ const webMcpToolSchema = z.object({ .object({ url: z.string(), line: z.int().min(0).nullable(), - column: z.int().min(0).nullable(), + column: cdpResourceColumnSchema.nullable(), }) .nullable(), trust: z.literal("page-declared-untrusted"), diff --git a/src/domain/webRuntime.ts b/src/domain/webRuntime.ts index b707cb3a22..d565750215 100644 --- a/src/domain/webRuntime.ts +++ b/src/domain/webRuntime.ts @@ -9,6 +9,7 @@ import { webRuntimeCommandTimeoutSchema, } from "./browserDeadline.js"; import { jsonObjectSchema } from "./jsonValue.js"; +import { cdpResourceColumnSchema } from "./cdpObservationSchemas.js"; // CDP positions are zero-based and relative to the resource named by url. // With a script ID that is the script's enclosing resource: the script file, @@ -18,17 +19,18 @@ import { jsonObjectSchema } from "./jsonValue.js"; // association to join captured source or resource_start. // trace_web_source_location takes one-based, script-relative lines, so the // advertised units keep callers from passing a position off by one or by an -// inline script's offset. +// inline script's offset. Columns are kept as reported even when Chrome reports +// a negative one, so subtracting resource_start's column stays consistent. const SCRIPT_ID = "CDP session script ID; empty for a position a request initiator reported, which has no session-script association for joining captured source or resource_start."; const RESOURCE_LINE = "Zero-based line in the resource named by url, as reported by CDP: the script file for an external script (including a module-import initiator), or the HTML document for an inline script or parser markup."; const RESOURCE_COLUMN = - "Zero-based UTF-16 column on that line of the resource named by url, as reported by CDP."; + "Zero-based UTF-16 column on that line of the resource named by url, as reported by CDP. Chrome can report a negative column, for example on the first line of a script whose resource_start column is negative; it then does not locate a column in url."; const START_LINE = "Zero-based line where the script text starts in its enclosing resource (the HTML document for an inline script)."; const START_COLUMN = - "Zero-based UTF-16 column where the script text starts on that line of its enclosing resource."; + "Zero-based UTF-16 column where the script text starts on that line of its enclosing resource. Chrome reports a negative column for some inline scripts; it is kept as reported and does not locate a column in that resource."; /** Scope for one externally owned page; omitted origins select its current origin. */ export const webRuntimeScopeSchema = z.strictObject({ @@ -53,7 +55,7 @@ export const webRuntimeSourceSchema = z.object({ resource_start: z .object({ line_number: z.int().min(0).describe(START_LINE), - column_number: z.int().min(0).describe(START_COLUMN), + column_number: cdpResourceColumnSchema.describe(START_COLUMN), }) .nullable(), source: z.discriminatedUnion("state", [ @@ -77,7 +79,7 @@ export const webRuntimeLocationSchema = z.object({ script_id: z.string().describe(SCRIPT_ID), url: z.string().nullable(), line_number: z.int().min(0).describe(RESOURCE_LINE), - column_number: z.int().min(0).nullable().describe(RESOURCE_COLUMN), + column_number: cdpResourceColumnSchema.nullable().describe(RESOURCE_COLUMN), function_name: z.string().nullable(), source_association: z.enum(["script_id", "unknown"]), }); diff --git a/tests/boundary/browser/cdpWebRuntimeReview.test.ts b/tests/boundary/browser/cdpWebRuntimeReview.test.ts index f8243d36e4..f6338ee9d2 100644 --- a/tests/boundary/browser/cdpWebRuntimeReview.test.ts +++ b/tests/boundary/browser/cdpWebRuntimeReview.test.ts @@ -2,7 +2,12 @@ import { describe, expect, it, onTestFinished } from "vitest"; import { CdpWebRuntimeProvider } from "../../../src/browser/execution/CdpWebRuntimeProvider.js"; import { observeWebExecutionInputSchema } from "../../../src/domain/webExecution.js"; import { inspectWebEventListenersInputSchema } from "../../../src/domain/webEventListeners.js"; -import { startRuntimeBrowser } from "../../fixtures/webRuntime.js"; +import { + negativeColumnScript, + negativeColumnScriptUrl, + startNegativeColumnBrowser, + startRuntimeBrowser, +} from "../../fixtures/webRuntime.js"; describe("runtime document identity across same-document URL changes", () => { it.each(["execution", "listeners"] as const)( @@ -217,3 +222,90 @@ describe("runtime collector failure and uncertain source metadata", () => { ).toBe(false); }); }); + +it("keeps negative columns Chrome reports for an inline script and its first-line callsites", async () => { + const browser = await startNegativeColumnBrowser(); + onTestFinished(() => browser.close()); + const scriptUrl = negativeColumnScriptUrl(browser.endpoint); + const result = await new CdpWebRuntimeProvider().observeExecution( + observeWebExecutionInputSchema.parse({ + cdp_endpoint: browser.endpoint, + target_id: "allowed-page", + observation_ms: 30, + }), + { + progress: { + report: async (update) => { + if (update.completed !== 1) return; + for (const [requestId, initiator] of [ + [ + "script-request", + { + type: "script", + stack: { + callFrames: [ + { + scriptId: negativeColumnScript.scriptId, + url: scriptUrl, + functionName: "", + lineNumber: 21, + columnNumber: -40, + }, + ], + }, + }, + ], + [ + "parser-request", + { + type: "parser", + url: scriptUrl, + lineNumber: 21, + columnNumber: -30, + }, + ], + ] as const) + browser.emitEvent({ + method: "Network.requestWillBeSent", + sessionId: "session-1", + params: { + requestId, + frameId: "runtime-main", + timestamp: 12, + request: { + url: `${browser.endpoint}/${requestId}`, + method: "GET", + }, + initiator, + }, + }); + }, + }, + }, + ); + if (!result.ok) throw result.error; + expect( + result.value.requests.map(({ callsites }) => + callsites.map(({ script_id, line_number, column_number }) => ({ + script_id, + line_number, + column_number, + })), + ), + ).toEqual([ + [ + { + script_id: negativeColumnScript.scriptId, + line_number: 21, + column_number: -40, + }, + ], + [{ script_id: "", line_number: 21, column_number: -30 }], + ]); + expect(result.value.sources).toContainEqual( + expect.objectContaining({ + script_id: negativeColumnScript.scriptId, + resource_start: { line_number: 21, column_number: -60 }, + }), + ); +}); diff --git a/tests/boundary/mcp/browserSourceCoordinates.test.ts b/tests/boundary/mcp/browserSourceCoordinates.test.ts index 3724412a2f..c26384bedd 100644 --- a/tests/boundary/mcp/browserSourceCoordinates.test.ts +++ b/tests/boundary/mcp/browserSourceCoordinates.test.ts @@ -1,22 +1,21 @@ +import { Ajv2020 } from "ajv/dist/2020.js"; import { expect, it, onTestFinished } from "vitest"; import { CdpBrowserProvider } from "../../../src/browser/CdpBrowserProvider.js"; import { createApplicationMcpHarness } from "../../fixtures/applicationMcpHarness.js"; import { startFakeCdpBrowser } from "../../fixtures/fakeCdpBrowser.js"; -it("reports malformed source coordinates as unknown through MCP", async () => { +/** Report one call frame for a console event, a request initiator and a WebMCP registration. */ +const inspectSourceCoordinates = async (position: { + readonly lineNumber: number; + readonly columnNumber: number; +}) => { const browser = await startFakeCdpBrowser({ webMcpTools: true, webMcpStackTrace: (origin) => ({ - callFrames: [ - { url: `${origin}/app.js`, lineNumber: -3, columnNumber: 7.5 }, - ], + callFrames: [{ url: `${origin}/app.js`, ...position }], }), commandEvents(command, origin) { - const frame = { - url: `${origin}/app.js`, - lineNumber: -3, - columnNumber: 7.5, - }; + const frame = { url: `${origin}/app.js`, ...position }; const session = command.sessionId === undefined ? {} : { sessionId: command.sessionId }; if (command.method === "Runtime.enable") @@ -38,7 +37,7 @@ it("reports malformed source coordinates as unknown through MCP", async () => { ...session, method: "Network.requestWillBeSent", params: { - requestId: "bad-coordinates", + requestId: "coordinate-request", timestamp: 1, type: "Script", request: { url: `${origin}/app.js`, method: "GET" }, @@ -71,6 +70,18 @@ it("reports malformed source coordinates as unknown through MCP", async () => { name: "inspect_web_page", arguments: args, }); + const tools = await client.callTool({ + name: "discover_webmcp_tools", + arguments: args, + }); + return { client, page, tools }; +}; + +it("reports malformed source coordinates as unknown through MCP", async () => { + const { page, tools } = await inspectSourceCoordinates({ + lineNumber: -3, + columnNumber: 7.5, + }); expect(page.isError).not.toBe(true); expect(page.structuredContent).toMatchObject({ normalized_result: { @@ -80,7 +91,7 @@ it("reports malformed source coordinates as unknown through MCP", async () => { network: { requests: expect.arrayContaining([ expect.objectContaining({ - request_id: "bad-coordinates", + request_id: "coordinate-request", initiator: expect.objectContaining({ line: null, column: null }), }), ]), @@ -99,10 +110,6 @@ it("reports malformed source coordinates as unknown through MCP", async () => { }, }, }); - const tools = await client.callTool({ - name: "discover_webmcp_tools", - arguments: args, - }); expect(tools.isError).not.toBe(true); expect(tools.structuredContent).toMatchObject({ normalized_result: { @@ -120,3 +127,69 @@ it("reports malformed source coordinates as unknown through MCP", async () => { }, }); }, 20_000); + +it("keeps a negative column Chrome reports in passive source locations", async () => { + const { client, page, tools } = await inspectSourceCoordinates({ + lineNumber: 21, + columnNumber: -40, + }); + const advertised = (await client.listTools()).tools; + const ajv = new Ajv2020({ strict: false, validateFormats: false }); + for (const [name, response] of [ + ["inspect_web_page", page], + ["discover_webmcp_tools", tools], + ] as const) { + expect(response.isError).not.toBe(true); + const outputSchema: Record | undefined = advertised.find( + (tool) => tool.name === name, + )?.outputSchema; + if (outputSchema === undefined) + throw new Error(`Missing advertised output schema: ${name}`); + expect( + ajv.validate(outputSchema, response.structuredContent), + JSON.stringify(ajv.errors), + ).toBe(true); + } + const location = { line: 21, column: -40 }; + expect(page.structuredContent).toMatchObject({ + normalized_result: { + console: { events: [expect.objectContaining(location)] }, + network: { + requests: expect.arrayContaining([ + expect.objectContaining({ + request_id: "coordinate-request", + initiator: expect.objectContaining(location), + }), + ]), + }, + }, + }); + expect(tools.structuredContent).toMatchObject({ + normalized_result: { + tools: { + items: [ + expect.objectContaining({ + registration_source: expect.objectContaining(location), + }), + ], + }, + }, + }); + for (const [response, section] of [ + [page, "console_events"], + [page, "network_initiators"], + [tools, "webmcp_tools"], + ] as const) + expect(response.structuredContent).toMatchObject({ + normalized_result: { + completeness: { + excluded: expect.not.arrayContaining([ + expect.objectContaining({ + section, + reason: "invalid_protocol_value", + }), + ]), + }, + }, + }); +}, 20_000); diff --git a/tests/boundary/mcp/webRuntimeMcp.test.ts b/tests/boundary/mcp/webRuntimeMcp.test.ts index 915214b2ce..68ea80bdd2 100644 --- a/tests/boundary/mcp/webRuntimeMcp.test.ts +++ b/tests/boundary/mcp/webRuntimeMcp.test.ts @@ -11,8 +11,10 @@ import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; import { cliTest } from "../../support/cli/cliFixture.js"; import { createTestBinarySession } from "../../fixtures/binarySession.js"; import { + negativeColumnScript, runtimeSourceA, runtimeSourceB, + startNegativeColumnBrowser, startRuntimeBrowser, } from "../../fixtures/webRuntime.js"; @@ -179,6 +181,94 @@ cliTest( 20_000, ); +cliTest( + "delivers negative columns Chrome reports through MCP and the compiled CLI", + async ({ cli }) => { + const browser = await startNegativeColumnBrowser(); + const provider = new CdpWebRuntimeProvider(); + const session = createTestBinarySession(() => { + throw new Error("Binary provider must not start"); + }); + const server = createServer( + { kind: "session", session }, + { webRuntime: new WebRuntimeService(provider) }, + ); + const client = new Client({ + name: "runtime-negative-columns", + version: "1", + }); + onTestFinished(async () => { + await client.close(); + await server.close(); + await provider.close(); + await session.close(); + await browser.close(); + }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + const advertised = (await client.listTools()).tools.find( + ({ name }) => name === "inspect_web_event_listeners", + )?.outputSchema; + if (advertised === undefined) + throw new Error("Missing advertised listener output schema"); + const outputSchema: Record = advertised; + const response = await client.callTool({ + name: "inspect_web_event_listeners", + arguments: { + cdp_endpoint: browser.endpoint, + target_id: "allowed-page", + allowed_origins: [browser.allowedOrigin], + selector: "#selected", + }, + }); + expect(response.isError).not.toBe(true); + const ajv = new Ajv2020({ strict: false, validateFormats: false }); + expect( + ajv.validate(outputSchema, response.structuredContent), + JSON.stringify(ajv.errors), + ).toBe(true); + const cliResult = await cli.run({ + arguments: [ + "inspect-web-event-listeners", + browser.endpoint, + "allowed-page", + "#selected", + "--allowed-origins", + browser.allowedOrigin, + "--json", + ], + environment: process.env, + }); + expect(cliResult.exitCode).toBe(0); + for (const evidence of [ + toolContract("inspect_web_event_listeners").outputSchema.parse( + response.structuredContent, + ), + cliResult.json, + ]) + expect(parseEvidence(evidence).normalized_result).toMatchObject({ + listeners: [ + { + location: { + script_id: negativeColumnScript.scriptId, + line_number: 21, + column_number: -50, + }, + }, + ], + sources: [ + { + script_id: negativeColumnScript.scriptId, + resource_start: { line_number: 21, column_number: -60 }, + }, + ], + }); + }, + 20_000, +); + it.each([ { name: "observe_web_execution" as const, diff --git a/tests/fixtures/webRuntime.ts b/tests/fixtures/webRuntime.ts index 5b93bccd6d..9d7dcc1db0 100644 --- a/tests/fixtures/webRuntime.ts +++ b/tests/fixtures/webRuntime.ts @@ -162,3 +162,55 @@ const runtimeEvents = (command: FakeCdpCommand, origin: string) => { }, })); }; + +/** Positions Chrome 156 reported for an inline script in a helper frame. */ +export const negativeColumnScript = { + scriptId: "helper-inline", + executionContextId: 7, + startLine: 21, + startColumn: -60, + endLine: 21, + endColumn: -15, + scriptLanguage: "JavaScript", +} as const; +export const negativeColumnScriptUrl = (origin: string) => + `${origin}/empty.html?cross-origin-isolated=1`; + +/** + * Replays that script and a listener on its first line. Positions on a + * script's first line include its start column, so the listener's is negative. + */ +export const startNegativeColumnBrowser = () => + startRuntimeBrowser({ + commandEvents: (command, origin) => + command.method === "Debugger.enable" + ? [ + { + ...(command.sessionId === undefined + ? {} + : { sessionId: command.sessionId }), + method: "Debugger.scriptParsed", + params: { + ...negativeColumnScript, + url: negativeColumnScriptUrl(origin), + }, + }, + ] + : undefined, + commandResult: (command) => + command.method === "DOMDebugger.getEventListeners" + ? { + listeners: [ + { + type: "message", + useCapture: false, + passive: false, + once: false, + scriptId: negativeColumnScript.scriptId, + lineNumber: 21, + columnNumber: -50, + }, + ], + } + : undefined, + });