* fix(browser)!: keep malformed console argument inventories unknown An omitted, null, or non-array Runtime.consoleAPICalled args inventory now reports argument_types as null, text capture as unavailable, and console_events as invalid_protocol_value instead of a complete empty inventory. Malformed argument elements keep their CDP position as "unknown", so retained text values keep their original argument_index. Call-site attribution now uses the frame at position zero: a malformed top stack frame is no longer replaced by its caller. Console events are excluded and request initiator locations are null, both reported as invalid_protocol_value. BREAKING CHANGE: console event argument_types may now be null, and console text_capture.status may now be "unavailable", when the CDP producer omits or malforms the argument inventory. Fixes #5934 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(browser): report malformed WebMCP registration stacks as incomplete topCallFrame now owns the omitted-versus-malformed decision for the whole stack value: an omitted stack has no call site, while a non-object stack, non-array callFrames, or a malformed top frame is malformed. WebMCP discovery keeps the tool with a null registration_source but records webmcp_tools invalid_protocol_value, so capture comparisons stay unknown instead of confirming a changed source. Console and initiator stacks use the same rule. Refs #5934 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
279 lines
8.3 KiB
TypeScript
279 lines
8.3 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import { CdpBrowserProvider } from "../../../src/browser/CdpBrowserProvider.js";
|
|
import { inspectWebPageInputSchema } from "../../../src/domain/browserObservation.js";
|
|
import { webPageInspectionSchema } from "../../../src/domain/browserObservationSchemas.js";
|
|
import { discoverWebMcpToolsInputSchema } from "../../../src/domain/webMcpDiscovery.js";
|
|
import type { JsonValue } from "../../../src/domain/jsonValue.js";
|
|
import {
|
|
startFakeCdpBrowser,
|
|
type FakeOptions,
|
|
} from "../../fixtures/fakeCdpBrowser.js";
|
|
import { trackBrowser } from "./cdpBrowserProvider.support.js";
|
|
|
|
type CdpCommand = Parameters<NonNullable<FakeOptions["commandEvents"]>>[0];
|
|
|
|
const sessionEvent = (
|
|
command: CdpCommand,
|
|
method: string,
|
|
params: Record<string, unknown>,
|
|
) => ({
|
|
...(command.sessionId === undefined ? {} : { sessionId: command.sessionId }),
|
|
method,
|
|
params,
|
|
});
|
|
|
|
const inspect = async (
|
|
commandEvents: NonNullable<FakeOptions["commandEvents"]>,
|
|
) => {
|
|
const browser = await startFakeCdpBrowser({ commandEvents });
|
|
trackBrowser(browser);
|
|
const result = await new CdpBrowserProvider().inspectPage(
|
|
inspectWebPageInputSchema.parse({
|
|
cdp_endpoint: browser.endpoint,
|
|
allowed_origins: [browser.allowedOrigin],
|
|
target_id: "allowed-page",
|
|
observation_ms: 1,
|
|
include_console_text: true,
|
|
}),
|
|
);
|
|
if (!result.ok) throw result.error;
|
|
expect(() => webPageInspectionSchema.parse(result.value)).not.toThrow();
|
|
return { browser, inspection: result.value };
|
|
};
|
|
|
|
const inspectConsole = async (
|
|
fields: Record<string, unknown>,
|
|
stackTrace?: (origin: string) => unknown,
|
|
) =>
|
|
inspect((command, origin) =>
|
|
command.method === "Runtime.enable"
|
|
? [
|
|
sessionEvent(command, "Runtime.consoleAPICalled", {
|
|
type: "log",
|
|
timestamp: 1,
|
|
...fields,
|
|
stackTrace: stackTrace?.(origin) ?? {
|
|
callFrames: [
|
|
{ url: `${origin}/app.js`, lineNumber: 0, columnNumber: 0 },
|
|
],
|
|
},
|
|
}),
|
|
]
|
|
: undefined,
|
|
);
|
|
|
|
const invalidConsoleValue = {
|
|
section: "console_events",
|
|
reason: "invalid_protocol_value",
|
|
count: 1,
|
|
};
|
|
|
|
describe("CdpBrowserProvider: console argument inventory", () => {
|
|
it.each([
|
|
["omitted", {}],
|
|
["null", { args: null }],
|
|
["non-array", { args: "invalid" }],
|
|
])(
|
|
"keeps an %s argument inventory unknown rather than empty",
|
|
async (_label, fields) => {
|
|
const { inspection } = await inspectConsole(fields);
|
|
|
|
expect(inspection.console.events).toHaveLength(1);
|
|
expect(inspection.console.events[0]?.argument_types).toBeNull();
|
|
expect(inspection.console.events[0]?.text_capture).toEqual({
|
|
status: "unavailable",
|
|
values: [],
|
|
retained_bytes: 0,
|
|
});
|
|
expect(inspection.completeness.excluded).toContainEqual(
|
|
invalidConsoleValue,
|
|
);
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
["a non-object element", null],
|
|
["an element without a type", { value: "first" }],
|
|
])(
|
|
"retains original argument positions around %s",
|
|
async (_label, malformed) => {
|
|
const { inspection } = await inspectConsole({
|
|
args: [malformed, { type: "string", value: "second" }],
|
|
});
|
|
|
|
expect(inspection.console.events[0]?.argument_types).toEqual([
|
|
"unknown",
|
|
"string",
|
|
]);
|
|
expect(inspection.console.events[0]?.text_capture).toEqual({
|
|
status: "included",
|
|
values: [{ argument_index: 1, type: "string", text: "second" }],
|
|
retained_bytes: Buffer.byteLength("second"),
|
|
});
|
|
expect(inspection.completeness.excluded).toContainEqual(
|
|
invalidConsoleValue,
|
|
);
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
["an empty", [], []],
|
|
["a non-primitive", [{ type: "object", subtype: "array" }], ["object"]],
|
|
])(
|
|
"reports %s argument inventory as observed",
|
|
async (_label, args, argumentTypes) => {
|
|
const { inspection } = await inspectConsole({ args });
|
|
|
|
expect(inspection.console.events[0]?.argument_types).toEqual(
|
|
argumentTypes,
|
|
);
|
|
expect(inspection.console.events[0]?.text_capture).toEqual({
|
|
status: "included",
|
|
values: [],
|
|
retained_bytes: 0,
|
|
});
|
|
expect(inspection.completeness.excluded).not.toContainEqual(
|
|
expect.objectContaining({
|
|
section: "console_events",
|
|
reason: "invalid_protocol_value",
|
|
}),
|
|
);
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
[
|
|
"a malformed top frame",
|
|
(origin: string) => ({
|
|
callFrames: [
|
|
null,
|
|
{ url: `${origin}/app.js`, lineNumber: 4, columnNumber: 2 },
|
|
],
|
|
}),
|
|
],
|
|
["a non-array frame inventory", () => ({ callFrames: "invalid" })],
|
|
["a non-object stack", () => "invalid"],
|
|
])(
|
|
"does not attribute a console call through %s",
|
|
async (_label, stackTrace) => {
|
|
const { inspection } = await inspectConsole({ args: [] }, stackTrace);
|
|
|
|
expect(inspection.console.events).toEqual([]);
|
|
expect(inspection.completeness.excluded).toContainEqual(
|
|
invalidConsoleValue,
|
|
);
|
|
},
|
|
);
|
|
|
|
it("does not attribute a request initiator to a frame below a malformed top frame", async () => {
|
|
const { browser, inspection } = await inspect((command, origin) =>
|
|
command.method === "Network.enable"
|
|
? [
|
|
sessionEvent(command, "Network.requestWillBeSent", {
|
|
requestId: "initiated-request",
|
|
type: "Fetch",
|
|
request: { url: `${origin}/data.json`, method: "GET" },
|
|
initiator: {
|
|
type: "script",
|
|
stack: {
|
|
callFrames: [
|
|
null,
|
|
{ url: `${origin}/app.js`, lineNumber: 3, columnNumber: 5 },
|
|
],
|
|
},
|
|
},
|
|
timestamp: 1,
|
|
}),
|
|
]
|
|
: undefined,
|
|
);
|
|
|
|
const request = inspection.network.requests.find(
|
|
(item) => item.request_id === "initiated-request",
|
|
);
|
|
expect(request?.url).toBe(`${browser.allowedOrigin}/data.json`);
|
|
expect(request?.initiator).toEqual({
|
|
type: "script",
|
|
url: null,
|
|
line: null,
|
|
column: null,
|
|
});
|
|
expect(inspection.completeness.excluded).toContainEqual({
|
|
section: "network_initiators",
|
|
reason: "invalid_protocol_value",
|
|
count: 1,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("CdpBrowserProvider: WebMCP registration stacks", () => {
|
|
const discover = async (
|
|
stackTrace: (origin: string) => JsonValue | undefined,
|
|
) => {
|
|
const browser = await startFakeCdpBrowser({
|
|
webMcpTools: true,
|
|
webMcpStackTrace: stackTrace,
|
|
});
|
|
trackBrowser(browser);
|
|
const result = await new CdpBrowserProvider().discoverWebMcpTools(
|
|
discoverWebMcpToolsInputSchema.parse({
|
|
cdp_endpoint: browser.endpoint,
|
|
allowed_origins: [browser.allowedOrigin],
|
|
target_id: "allowed-page",
|
|
observation_ms: 0,
|
|
}),
|
|
);
|
|
if (!result.ok) throw result.error;
|
|
return { discovery: result.value };
|
|
};
|
|
const invalidToolValue = expect.objectContaining({
|
|
section: "webmcp_tools",
|
|
reason: "invalid_protocol_value",
|
|
});
|
|
|
|
it.each<[string, (origin: string) => JsonValue]>([
|
|
[
|
|
"a malformed top frame",
|
|
(origin) => ({
|
|
callFrames: [
|
|
null,
|
|
{ url: `${origin}/app.js`, lineNumber: 12, columnNumber: 4 },
|
|
],
|
|
}),
|
|
],
|
|
["a non-array frame inventory", () => ({ callFrames: "invalid" })],
|
|
["a non-object stack", () => "invalid"],
|
|
])(
|
|
"keeps the tool but reports %s as incomplete",
|
|
async (_label, stackTrace) => {
|
|
const { discovery } = await discover(stackTrace);
|
|
|
|
expect(discovery.tools.items).toEqual([
|
|
expect.objectContaining({
|
|
name: "search_orders",
|
|
registration_source: null,
|
|
}),
|
|
]);
|
|
expect(discovery.completeness.excluded).toContainEqual(invalidToolValue);
|
|
},
|
|
);
|
|
|
|
it.each<[string, JsonValue | undefined]>([
|
|
["an omitted", undefined],
|
|
["an empty", { callFrames: [] }],
|
|
])("reports %s registration stack as having no source", async (_l, stack) => {
|
|
const { discovery } = await discover(() => stack);
|
|
|
|
expect(discovery.tools.items).toEqual([
|
|
expect.objectContaining({
|
|
name: "search_orders",
|
|
registration_source: null,
|
|
}),
|
|
]);
|
|
expect(discovery.completeness.excluded).not.toContainEqual(
|
|
invalidToolValue,
|
|
);
|
|
});
|
|
});
|