1434 lines
47 KiB
TypeScript
1434 lines
47 KiB
TypeScript
import { afterEach, describe, expect, it } from "vitest";
|
|
|
|
import { CdpBrowserProvider } from "../src/browser/CdpBrowserProvider.js";
|
|
import {
|
|
inspectWebPageInputSchema,
|
|
listBrowserTargetsInputSchema,
|
|
webPageInspectionSchema,
|
|
} from "../src/domain/browserObservation.js";
|
|
import { analyzeWebBundleInputSchema } from "../src/domain/webBundleAnalysis.js";
|
|
import { observeWebSessionInputSchema } from "../src/domain/browserSession.js";
|
|
import { discoverWebMcpToolsInputSchema } from "../src/domain/webMcpDiscovery.js";
|
|
import { captureWebScreenshotInputSchema } from "../src/domain/webScreenshot.js";
|
|
import {
|
|
startFakeCdpBrowser,
|
|
type FakeCdpBrowser,
|
|
} from "./fixtures/fakeCdpBrowser.js";
|
|
|
|
describe("CdpBrowserProvider", () => {
|
|
const browsers: FakeCdpBrowser[] = [];
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(
|
|
browsers.splice(0).map(async (browser) => browser.close()),
|
|
);
|
|
});
|
|
|
|
it("lists only exact-origin pages and sanitizes URLs", async () => {
|
|
const browser = await startFakeCdpBrowser();
|
|
browsers.push(browser);
|
|
const provider = new CdpBrowserProvider();
|
|
const result = await provider.listTargets(
|
|
listBrowserTargetsInputSchema.parse({
|
|
cdp_endpoint: browser.endpoint,
|
|
allowed_origins: [browser.allowedOrigin],
|
|
approved: true,
|
|
}),
|
|
);
|
|
|
|
if (!result.ok) throw result.error;
|
|
expect(result.value.targets.items).toEqual([
|
|
expect.objectContaining({
|
|
target_id: "allowed-page",
|
|
origin: browser.allowedOrigin,
|
|
url: `${browser.allowedOrigin}/app?token=%5BREDACTED%5D`,
|
|
}),
|
|
]);
|
|
expect(result.value.excluded).toEqual({
|
|
disallowed_origin: 1,
|
|
unsupported_url: 1,
|
|
non_page: 1,
|
|
});
|
|
expect(JSON.stringify(result.value)).not.toContain("forbidden");
|
|
expect(JSON.stringify(result.value)).not.toContain("page-secret");
|
|
});
|
|
|
|
it("uses page-scoped discovery sockets as direct target transports", async () => {
|
|
const browser = await startFakeCdpBrowser({
|
|
pageScopedVersionWebSocket: true,
|
|
omitTargetWebSocket: true,
|
|
additionalPageWithWebSocket: true,
|
|
additionalPageWithoutWebSocket: true,
|
|
});
|
|
browsers.push(browser);
|
|
const provider = new CdpBrowserProvider();
|
|
const listed = await provider.listTargets(
|
|
listBrowserTargetsInputSchema.parse({
|
|
cdp_endpoint: browser.endpoint,
|
|
allowed_origins: [browser.allowedOrigin],
|
|
approved: true,
|
|
}),
|
|
);
|
|
if (!listed.ok) throw listed.error;
|
|
expect(
|
|
listed.value.targets.items.map(({ target_id }) => target_id),
|
|
).toEqual(["allowed-page", "allowed-page-with-socket"]);
|
|
expect(listed.value.limitations).toContain(
|
|
"1 otherwise allowed page target(s) lacked a validated direct CDP WebSocket and were excluded.",
|
|
);
|
|
|
|
const inspected = await provider.inspectPage(
|
|
inspectWebPageInputSchema.parse({
|
|
cdp_endpoint: browser.endpoint,
|
|
allowed_origins: [browser.allowedOrigin],
|
|
target_id: "allowed-page",
|
|
approved: true,
|
|
observation_ms: 0,
|
|
}),
|
|
);
|
|
if (!inspected.ok) throw inspected.error;
|
|
expect(inspected.value.target.target_id).toBe("allowed-page");
|
|
expect(inspected.value.network.requests).toHaveLength(1);
|
|
|
|
const second = await provider.inspectPage(
|
|
inspectWebPageInputSchema.parse({
|
|
cdp_endpoint: browser.endpoint,
|
|
allowed_origins: [browser.allowedOrigin],
|
|
target_id: "allowed-page-with-socket",
|
|
approved: true,
|
|
observation_ms: 0,
|
|
}),
|
|
);
|
|
if (!second.ok) throw second.error;
|
|
expect(second.value.target.target_id).toBe("allowed-page-with-socket");
|
|
const methods = browser.commands.map(({ method }) => method);
|
|
expect(methods).not.toContain("Target.attachToTarget");
|
|
expect(methods).not.toContain("Target.detachFromTarget");
|
|
expect(
|
|
browser.commands.every(({ sessionId }) => sessionId === undefined),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("projects direct-session cancellation onto the requested operation", async () => {
|
|
const browser = await startFakeCdpBrowser({
|
|
pageScopedVersionWebSocket: true,
|
|
omitTargetWebSocket: true,
|
|
});
|
|
browsers.push(browser);
|
|
const controller = new AbortController();
|
|
const result = await new CdpBrowserProvider().observeSession(
|
|
observeWebSessionInputSchema.parse({
|
|
cdp_endpoint: browser.endpoint,
|
|
allowed_origins: [browser.allowedOrigin],
|
|
target_id: "allowed-page",
|
|
approved: true,
|
|
observation_ms: 1_000,
|
|
}),
|
|
{
|
|
signal: controller.signal,
|
|
progress: {
|
|
report(update) {
|
|
if (update.completed === 1) controller.abort();
|
|
return Promise.resolve();
|
|
},
|
|
},
|
|
},
|
|
);
|
|
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
_tag: "AnalysisCancelledError",
|
|
operation: "observe_web_session",
|
|
},
|
|
});
|
|
const methods = browser.commands.map(({ method }) => method);
|
|
expect(methods).not.toContain("Target.attachToTarget");
|
|
expect(methods).not.toContain("Target.detachFromTarget");
|
|
expect(methods).not.toContain("Target.closeTarget");
|
|
expect(methods).not.toContain("Browser.close");
|
|
});
|
|
|
|
it("projects capture-window cancellation onto bundle analysis", async () => {
|
|
const browser = await startFakeCdpBrowser({
|
|
pageScopedVersionWebSocket: true,
|
|
omitTargetWebSocket: true,
|
|
});
|
|
browsers.push(browser);
|
|
const controller = new AbortController();
|
|
const result = await new CdpBrowserProvider().analyzeBundle(
|
|
analyzeWebBundleInputSchema.parse({
|
|
cdp_endpoint: browser.endpoint,
|
|
allowed_origins: [browser.allowedOrigin],
|
|
target_id: "allowed-page",
|
|
approved: true,
|
|
source_capture_approved: true,
|
|
observation_ms: 1_000,
|
|
}),
|
|
{
|
|
signal: controller.signal,
|
|
progress: {
|
|
report(update) {
|
|
if (update.completed === 2) controller.abort();
|
|
return Promise.resolve();
|
|
},
|
|
},
|
|
},
|
|
);
|
|
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
_tag: "AnalysisCancelledError",
|
|
operation: "analyze_web_bundle",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("rejects empty browser attachment session identifiers", async () => {
|
|
const browser = await startFakeCdpBrowser({ invalidAttachedSession: true });
|
|
browsers.push(browser);
|
|
const result = await new CdpBrowserProvider().inspectPage(
|
|
inspectWebPageInputSchema.parse({
|
|
cdp_endpoint: browser.endpoint,
|
|
allowed_origins: [browser.allowedOrigin],
|
|
target_id: "allowed-page",
|
|
approved: true,
|
|
observation_ms: 0,
|
|
}),
|
|
);
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "BrowserObservationError", reason: "protocol_error" },
|
|
});
|
|
expect(browser.commands.map(({ method }) => method)).toEqual([
|
|
"Target.attachToTarget",
|
|
]);
|
|
});
|
|
|
|
it("captures bounded passive evidence without retaining sensitive values", async () => {
|
|
const browser = await startFakeCdpBrowser({
|
|
binaryWebSocketEvent: true,
|
|
foreignSessionEvents: true,
|
|
unrelatedWorker: true,
|
|
});
|
|
browsers.push(browser);
|
|
const provider = new CdpBrowserProvider();
|
|
const result = await provider.inspectPage(
|
|
inspectWebPageInputSchema.parse({
|
|
cdp_endpoint: browser.endpoint,
|
|
allowed_origins: [browser.allowedOrigin],
|
|
target_id: "allowed-page",
|
|
approved: true,
|
|
observation_ms: 0,
|
|
include_storage_keys: true,
|
|
}),
|
|
);
|
|
|
|
if (!result.ok) throw result.error;
|
|
expect(() => webPageInspectionSchema.parse(result.value)).not.toThrow();
|
|
expect(result.value.frames).toHaveLength(1);
|
|
expect(result.value.dom.nodes).toHaveLength(2);
|
|
expect(result.value.dom.nodes[1]?.attribute_names).toEqual([
|
|
"token",
|
|
"href",
|
|
"rel",
|
|
]);
|
|
expect(result.value.accessibility).toMatchObject({
|
|
text_capture: {
|
|
status: "not_approved",
|
|
retained_bytes: 0,
|
|
},
|
|
nodes: [expect.objectContaining({ name: null, description: null })],
|
|
});
|
|
expect(result.value.scripts.items).toHaveLength(1);
|
|
expect(result.value.scripts.items[0]?.script_key).toMatch(
|
|
/^scr_[a-f0-9]{64}$/u,
|
|
);
|
|
expect(result.value.scripts.items[0]?.source_map_url).toBe(
|
|
`${browser.allowedOrigin}/app.js.map?token=%5BREDACTED%5D`,
|
|
);
|
|
expect(result.value.scripts.items[0]?.resource_reconciliation).toEqual({
|
|
status: "exact",
|
|
resource_key: result.value.resources[0]?.resource_key,
|
|
});
|
|
expect(result.value.scripts.items[0]?.source).toEqual({
|
|
included: false,
|
|
reason: "source capture was not approved",
|
|
});
|
|
expect(result.value.resources).toHaveLength(1);
|
|
expect(result.value.network.requests).toHaveLength(1);
|
|
expect(result.value.network.requests[0]?.initiator).toEqual({
|
|
type: "script",
|
|
url: `${browser.allowedOrigin}/app.js?caller=%5BREDACTED%5D`,
|
|
line: 3,
|
|
column: 5,
|
|
});
|
|
expect(result.value.network.websocket_events).toEqual([
|
|
{
|
|
request_id: "websocket-1",
|
|
direction: "sent",
|
|
opcode: 1,
|
|
payload_bytes: Buffer.byteLength("websocket-secret"),
|
|
payload_shape: null,
|
|
},
|
|
{
|
|
request_id: "websocket-1",
|
|
direction: "received",
|
|
opcode: 2,
|
|
payload_bytes: 3,
|
|
payload_shape: null,
|
|
},
|
|
]);
|
|
expect(result.value.console.events[0]?.argument_types).toEqual(["string"]);
|
|
expect(result.value.workers).toHaveLength(1);
|
|
expect(result.value.metadata).toMatchObject({
|
|
headers_allowlisted: true,
|
|
responses: [
|
|
{
|
|
content_length: 321,
|
|
content_encoding: "br",
|
|
csp: { nonce_count: 1, hash_count: 1 },
|
|
policies: {
|
|
coop: "same-origin",
|
|
coep: "require-corp",
|
|
permissions_policy_features: ["camera", "geolocation"],
|
|
},
|
|
},
|
|
],
|
|
dom_urls: [
|
|
{
|
|
attribute: "href",
|
|
url: `${browser.allowedOrigin}/agent?token=%5BREDACTED%5D`,
|
|
destination_scope: "approved",
|
|
},
|
|
],
|
|
agent_hints: expect.arrayContaining([
|
|
expect.objectContaining({
|
|
mechanism: "link_rel",
|
|
declaration: "mcp service-desc",
|
|
}),
|
|
expect.objectContaining({
|
|
mechanism: "dom_link_rel",
|
|
declaration: "mcp",
|
|
}),
|
|
expect.objectContaining({
|
|
mechanism: "response_header",
|
|
declaration: "x-model-context",
|
|
}),
|
|
]),
|
|
});
|
|
expect(result.value.storage).toEqual(
|
|
expect.objectContaining({
|
|
local_storage_keys: ["public-key"],
|
|
session_storage_keys: ["public-key"],
|
|
indexed_db_names: ["app-db"],
|
|
cache_names: ["assets-v1"],
|
|
values_redacted: true,
|
|
}),
|
|
);
|
|
const serialized = JSON.stringify(result.value);
|
|
for (const secret of [
|
|
"page-secret",
|
|
"frame-secret",
|
|
"dom-secret",
|
|
"forbidden",
|
|
"resource-secret",
|
|
"script-secret",
|
|
"inline-secret",
|
|
"map-secret",
|
|
"network-secret",
|
|
"request-secret",
|
|
"request-body-secret",
|
|
"response-secret",
|
|
"response-body-secret",
|
|
"websocket-secret",
|
|
"websocket-url-secret",
|
|
"console-secret",
|
|
"unknown-origin-console-secret",
|
|
"unknown-console-value-secret",
|
|
"storage-secret",
|
|
"secret-id",
|
|
"dom-url-secret",
|
|
"link-secret",
|
|
"csp-secret",
|
|
"hash-secret",
|
|
"header-secret",
|
|
])
|
|
expect(serialized).not.toContain(secret);
|
|
const methods = browser.commands.map((command) => command.method);
|
|
expect(methods).toContain("Target.detachFromTarget");
|
|
expect(methods).not.toContain("Browser.close");
|
|
expect(methods).not.toContain("Target.closeTarget");
|
|
expect(methods).not.toContain("Runtime.evaluate");
|
|
expect(methods).not.toContain("Network.getResponseBody");
|
|
});
|
|
|
|
it("captures only approved redacted console text and value-free payload shapes", async () => {
|
|
const browser = await startFakeCdpBrowser({
|
|
sensitiveShapes: true,
|
|
binaryWebSocketEvent: true,
|
|
});
|
|
browsers.push(browser);
|
|
const result = await new CdpBrowserProvider().inspectPage(
|
|
inspectWebPageInputSchema.parse({
|
|
cdp_endpoint: browser.endpoint,
|
|
allowed_origins: [browser.allowedOrigin],
|
|
target_id: "allowed-page",
|
|
approved: true,
|
|
observation_ms: 0,
|
|
include_console_text: true,
|
|
console_text_approved: true,
|
|
include_json_body_shapes: true,
|
|
json_body_schema_approved: true,
|
|
include_websocket_shapes: true,
|
|
websocket_shape_approved: true,
|
|
}),
|
|
);
|
|
|
|
if (!result.ok) throw result.error;
|
|
expect(result.value.console.events[0]?.text_capture).toEqual({
|
|
status: "included",
|
|
values: [
|
|
{ argument_index: 0, type: "string", text: "authorization=[REDACTED]" },
|
|
{ argument_index: 1, type: "number", text: "42" },
|
|
],
|
|
retained_bytes: 26,
|
|
truncated_values: 0,
|
|
});
|
|
expect(result.value.network.requests[0]?.body_shapes).toMatchObject({
|
|
status: "included",
|
|
request: {
|
|
root_type: "object",
|
|
properties: expect.arrayContaining([
|
|
expect.objectContaining({ path: "/token", types: ["string"] }),
|
|
expect.objectContaining({
|
|
path: "/filters/active",
|
|
types: ["boolean"],
|
|
}),
|
|
]),
|
|
},
|
|
response: {
|
|
root_type: "object",
|
|
properties: expect.arrayContaining([
|
|
expect.objectContaining({
|
|
path: "/result/token",
|
|
types: ["string"],
|
|
}),
|
|
expect.objectContaining({ path: "/items/*/id", types: ["number"] }),
|
|
]),
|
|
},
|
|
});
|
|
expect(result.value.network.websocket_events).toEqual([
|
|
expect.objectContaining({
|
|
opcode: 1,
|
|
payload_shape: expect.objectContaining({
|
|
format: "json",
|
|
json_shape: expect.objectContaining({
|
|
properties: expect.arrayContaining([
|
|
expect.objectContaining({ path: "/token", types: ["string"] }),
|
|
]),
|
|
}),
|
|
}),
|
|
}),
|
|
expect.objectContaining({
|
|
opcode: 2,
|
|
payload_shape: {
|
|
format: "binary",
|
|
json_shape: null,
|
|
truncated: false,
|
|
},
|
|
}),
|
|
]);
|
|
const serialized = JSON.stringify(result.value);
|
|
for (const secret of [
|
|
"request-body-secret",
|
|
"response-body-secret",
|
|
"websocket-secret",
|
|
"console-secret",
|
|
"object-secret",
|
|
])
|
|
expect(serialized).not.toContain(secret);
|
|
const methods = browser.commands.map(({ method }) => method);
|
|
expect(methods).toContain("Network.getResponseBody");
|
|
expect(methods).not.toContain("Runtime.getProperties");
|
|
expect(methods).not.toContain("Runtime.callFunctionOn");
|
|
});
|
|
|
|
it("reports independent sensitive-capture truncation at aggregate byte limits", async () => {
|
|
const browser = await startFakeCdpBrowser({ sensitiveShapes: true });
|
|
browsers.push(browser);
|
|
const result = await new CdpBrowserProvider().inspectPage(
|
|
inspectWebPageInputSchema.parse({
|
|
cdp_endpoint: browser.endpoint,
|
|
allowed_origins: [browser.allowedOrigin],
|
|
target_id: "allowed-page",
|
|
approved: true,
|
|
observation_ms: 0,
|
|
include_console_text: true,
|
|
console_text_approved: true,
|
|
include_json_body_shapes: true,
|
|
json_body_schema_approved: true,
|
|
include_websocket_shapes: true,
|
|
websocket_shape_approved: true,
|
|
limits: {
|
|
max_console_text_field_bytes: 5,
|
|
max_total_console_text_bytes: 5,
|
|
max_json_body_bytes: 10,
|
|
max_total_json_body_bytes: 10,
|
|
max_websocket_shape_bytes: 5,
|
|
max_total_websocket_shape_bytes: 5,
|
|
},
|
|
}),
|
|
);
|
|
|
|
if (!result.ok) throw result.error;
|
|
expect(result.value.console.events[0]?.text_capture).toEqual({
|
|
status: "truncated",
|
|
values: [{ argument_index: 0, type: "string", text: "autho" }],
|
|
retained_bytes: 5,
|
|
truncated_values: 2,
|
|
});
|
|
expect(result.value.network.requests[0]?.body_shapes.status).toBe(
|
|
"truncated",
|
|
);
|
|
expect(result.value.network.websocket_events[0]?.payload_shape).toEqual({
|
|
format: "text",
|
|
json_shape: null,
|
|
truncated: true,
|
|
});
|
|
expect(result.value.completeness.truncated_sections).toEqual(
|
|
expect.arrayContaining([
|
|
"console_text",
|
|
"json_body_shapes",
|
|
"websocket_shapes",
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("fails closed on malformed approved response and binary payload encodings", async () => {
|
|
const browser = await startFakeCdpBrowser({
|
|
invalidResponseBodyBase64: true,
|
|
binaryWebSocketEvent: true,
|
|
invalidBinaryWebSocketEvent: true,
|
|
});
|
|
browsers.push(browser);
|
|
const result = await new CdpBrowserProvider().inspectPage(
|
|
inspectWebPageInputSchema.parse({
|
|
cdp_endpoint: browser.endpoint,
|
|
allowed_origins: [browser.allowedOrigin],
|
|
target_id: "allowed-page",
|
|
approved: true,
|
|
observation_ms: 0,
|
|
include_json_body_shapes: true,
|
|
json_body_schema_approved: true,
|
|
include_websocket_shapes: true,
|
|
websocket_shape_approved: true,
|
|
}),
|
|
);
|
|
|
|
if (!result.ok) throw result.error;
|
|
expect(result.value.network.requests[0]?.body_shapes).toMatchObject({
|
|
status: "partial",
|
|
request: expect.any(Object),
|
|
response: null,
|
|
});
|
|
expect(result.value.network.websocket_events[1]).toMatchObject({
|
|
opcode: 2,
|
|
payload_bytes: 0,
|
|
payload_shape: { format: "binary", json_shape: null },
|
|
});
|
|
expect(result.value.completeness.unavailable_sections).toEqual(
|
|
expect.arrayContaining(["json_body_shapes", "websocket_frames"]),
|
|
);
|
|
});
|
|
|
|
it("captures bounded accessibility text only after independent approval", async () => {
|
|
const browser = await startFakeCdpBrowser();
|
|
browsers.push(browser);
|
|
const result = await new CdpBrowserProvider().inspectPage(
|
|
inspectWebPageInputSchema.parse({
|
|
cdp_endpoint: browser.endpoint,
|
|
allowed_origins: [browser.allowedOrigin],
|
|
target_id: "allowed-page",
|
|
approved: true,
|
|
observation_ms: 0,
|
|
include_accessibility_text: true,
|
|
limits: {
|
|
max_ax_text_field_bytes: 6,
|
|
max_total_ax_text_bytes: 6,
|
|
},
|
|
}),
|
|
);
|
|
|
|
if (!result.ok) throw result.error;
|
|
expect(result.value.accessibility).toMatchObject({
|
|
text_capture: {
|
|
status: "truncated",
|
|
retained_bytes: 6,
|
|
truncated_fields: 2,
|
|
},
|
|
nodes: [expect.objectContaining({ name: "Submit" })],
|
|
});
|
|
expect(result.value.completeness.truncated_sections).toContain(
|
|
"accessibility",
|
|
);
|
|
});
|
|
|
|
it("drops network evidence when a request redirects outside the approved origin", async () => {
|
|
const browser = await startFakeCdpBrowser({
|
|
redirectToDisallowedOrigin: true,
|
|
});
|
|
browsers.push(browser);
|
|
const result = await new CdpBrowserProvider().inspectPage(
|
|
inspectWebPageInputSchema.parse({
|
|
cdp_endpoint: browser.endpoint,
|
|
allowed_origins: [browser.allowedOrigin],
|
|
target_id: "allowed-page",
|
|
approved: true,
|
|
observation_ms: 0,
|
|
}),
|
|
);
|
|
|
|
if (!result.ok) throw result.error;
|
|
expect(result.value.network.requests).toEqual([]);
|
|
});
|
|
|
|
it("includes allowed script source only after explicit approval", async () => {
|
|
const browser = await startFakeCdpBrowser();
|
|
browsers.push(browser);
|
|
const provider = new CdpBrowserProvider();
|
|
const result = await provider.inspectPage(
|
|
inspectWebPageInputSchema.parse({
|
|
cdp_endpoint: browser.endpoint,
|
|
allowed_origins: [browser.allowedOrigin],
|
|
target_id: "allowed-page",
|
|
approved: true,
|
|
observation_ms: 0,
|
|
include_script_sources: true,
|
|
}),
|
|
);
|
|
|
|
if (!result.ok) throw result.error;
|
|
expect(result.value.scripts.items[0]?.source).toEqual(
|
|
expect.objectContaining({
|
|
included: true,
|
|
artifact: expect.objectContaining({
|
|
text: "export const observed = 'source-secret';",
|
|
bytes: 40,
|
|
uri: expect.stringMatching(/^rea:\/\/web-content\/sha256\//u),
|
|
}),
|
|
}),
|
|
);
|
|
expect(browser.commands.map((command) => command.method)).toContain(
|
|
"Debugger.getScriptSource",
|
|
);
|
|
});
|
|
|
|
it("fetches and validates source maps only after separate approval", async () => {
|
|
const browser = await startFakeCdpBrowser({
|
|
sourceMapBody: JSON.stringify({
|
|
version: 3,
|
|
names: [],
|
|
sources: ["src/main.ts"],
|
|
sourcesContent: ["export const original = true;"],
|
|
mappings: "AAAA",
|
|
}),
|
|
});
|
|
browsers.push(browser);
|
|
const result = await new CdpBrowserProvider().analyzeBundle(
|
|
analyzeWebBundleInputSchema.parse({
|
|
cdp_endpoint: browser.endpoint,
|
|
allowed_origins: [browser.allowedOrigin],
|
|
target_id: "allowed-page",
|
|
approved: true,
|
|
source_capture_approved: true,
|
|
observation_ms: 0,
|
|
fetch_source_maps: true,
|
|
source_map_fetch_approved: true,
|
|
}),
|
|
);
|
|
|
|
if (!result.ok) throw result.error;
|
|
expect(result.value.observations.source_maps).toMatchObject({
|
|
status: "included",
|
|
items: [
|
|
{
|
|
status: "included",
|
|
original_sources: [
|
|
expect.objectContaining({
|
|
source: `${browser.allowedOrigin}/src/main.ts`,
|
|
}),
|
|
],
|
|
mappings: [expect.any(Object)],
|
|
},
|
|
],
|
|
});
|
|
const sourceMapRequest = browser.httpRequests.find(({ url }) =>
|
|
url.startsWith("/app.js.map"),
|
|
);
|
|
expect(sourceMapRequest).toEqual({
|
|
url: "/app.js.map?token=map-secret",
|
|
authorization: undefined,
|
|
cookie: undefined,
|
|
referer: undefined,
|
|
});
|
|
});
|
|
|
|
it("observes user-driven reload, SPA, redirect, failure, and lifecycle events", async () => {
|
|
const browser = await startFakeCdpBrowser({
|
|
sessionTimeline: "same_origin",
|
|
});
|
|
browsers.push(browser);
|
|
const result = await new CdpBrowserProvider().observeSession(
|
|
observeWebSessionInputSchema.parse({
|
|
cdp_endpoint: browser.endpoint,
|
|
allowed_origins: [browser.allowedOrigin],
|
|
target_id: "allowed-page",
|
|
approved: true,
|
|
observation_ms: 20,
|
|
}),
|
|
);
|
|
|
|
if (!result.ok) throw result.error;
|
|
expect(result.value.window.end_reason).toBe("window_elapsed");
|
|
expect(result.value.timeline.map(({ type }) => type)).toEqual([
|
|
"navigation_requested",
|
|
"same_origin_reload",
|
|
"same_document_navigation",
|
|
"redirect",
|
|
"load_failed",
|
|
"lifecycle",
|
|
]);
|
|
expect(result.value.timeline).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
type: "same_document_navigation",
|
|
url: `${browser.allowedOrigin}/reloaded?token=%5BREDACTED%5D`,
|
|
}),
|
|
expect.objectContaining({
|
|
type: "load_failed",
|
|
detail: "net::ERR_CONNECTION_REFUSED",
|
|
}),
|
|
]),
|
|
);
|
|
expect(result.value.completeness.status).toBe("attach_limited");
|
|
expect(JSON.stringify(result.value)).not.toContain("session-secret");
|
|
expect(JSON.stringify(result.value)).not.toContain("redirect-secret");
|
|
expect(browser.commands.map(({ method }) => method)).toContain(
|
|
"Target.detachFromTarget",
|
|
);
|
|
});
|
|
|
|
it("ends a browser session when its flat target session detaches", async () => {
|
|
const browser = await startFakeCdpBrowser({
|
|
sessionTimeline: "target_detached",
|
|
});
|
|
browsers.push(browser);
|
|
const result = await new CdpBrowserProvider().observeSession(
|
|
observeWebSessionInputSchema.parse({
|
|
cdp_endpoint: browser.endpoint,
|
|
allowed_origins: [browser.allowedOrigin],
|
|
target_id: "allowed-page",
|
|
approved: true,
|
|
observation_ms: 1_000,
|
|
}),
|
|
);
|
|
|
|
if (!result.ok) throw result.error;
|
|
expect(result.value.window.end_reason).toBe("target_terminated");
|
|
expect(result.value.timeline.at(-1)).toMatchObject({
|
|
type: "target_terminated",
|
|
detail: "target_terminated",
|
|
});
|
|
});
|
|
|
|
it("ends a direct page session when its transport disconnects", async () => {
|
|
const browser = await startFakeCdpBrowser({
|
|
pageScopedVersionWebSocket: true,
|
|
omitTargetWebSocket: true,
|
|
closeAfterMethod: "Page.setLifecycleEventsEnabled",
|
|
});
|
|
browsers.push(browser);
|
|
const result = await new CdpBrowserProvider().observeSession(
|
|
observeWebSessionInputSchema.parse({
|
|
cdp_endpoint: browser.endpoint,
|
|
allowed_origins: [browser.allowedOrigin],
|
|
target_id: "allowed-page",
|
|
approved: true,
|
|
observation_ms: 1_000,
|
|
}),
|
|
);
|
|
|
|
if (!result.ok) throw result.error;
|
|
expect(result.value.window.end_reason).toBe("target_terminated");
|
|
expect(result.value.timeline.at(-1)).toMatchObject({
|
|
type: "target_terminated",
|
|
detail: "target_terminated",
|
|
});
|
|
const methods = browser.commands.map(({ method }) => method);
|
|
expect(methods).not.toContain("Target.attachToTarget");
|
|
expect(methods).not.toContain("Target.detachFromTarget");
|
|
expect(methods).not.toContain("Target.closeTarget");
|
|
});
|
|
|
|
it("discovers untrusted WebMCP declarations without registering or invoking them", async () => {
|
|
const browser = await startFakeCdpBrowser({ webMcpTools: true });
|
|
browsers.push(browser);
|
|
const result = await new CdpBrowserProvider().discoverWebMcpTools(
|
|
discoverWebMcpToolsInputSchema.parse({
|
|
cdp_endpoint: browser.endpoint,
|
|
allowed_origins: [browser.allowedOrigin],
|
|
target_id: "allowed-page",
|
|
approved: true,
|
|
observation_ms: 0,
|
|
}),
|
|
);
|
|
|
|
if (!result.ok) throw result.error;
|
|
expect(result.value).toMatchObject({
|
|
status: "available",
|
|
tools: {
|
|
total: 1,
|
|
items: [
|
|
{
|
|
name: "search_orders",
|
|
description: "Search orders; authorization=[REDACTED]",
|
|
declaration_kind: "declarative",
|
|
owner_origin: browser.allowedOrigin,
|
|
annotations: {
|
|
read_only: true,
|
|
untrusted_content: true,
|
|
autosubmit: false,
|
|
},
|
|
trust: "page-declared-untrusted",
|
|
registration_source: {
|
|
url: `${browser.allowedOrigin}/app.js?token=%5BREDACTED%5D`,
|
|
line: 12,
|
|
column: 4,
|
|
},
|
|
},
|
|
],
|
|
},
|
|
});
|
|
const serialized = JSON.stringify(result.value);
|
|
for (const secret of [
|
|
"tool-secret",
|
|
"schema-secret",
|
|
"tool-source-secret",
|
|
"private-tool-secret",
|
|
])
|
|
expect(serialized).not.toContain(secret);
|
|
const methods = browser.commands.map(({ method }) => method);
|
|
expect(methods).toContain("WebMCP.enable");
|
|
expect(methods).not.toContain("WebMCP.invokeTool");
|
|
expect(methods).not.toContain("Runtime.evaluate");
|
|
});
|
|
|
|
it("reports WebMCP unavailable when the experimental domain is absent", async () => {
|
|
const browser = await startFakeCdpBrowser({
|
|
unsupportedMethods: ["WebMCP.enable"],
|
|
});
|
|
browsers.push(browser);
|
|
const result = await new CdpBrowserProvider().discoverWebMcpTools(
|
|
discoverWebMcpToolsInputSchema.parse({
|
|
cdp_endpoint: browser.endpoint,
|
|
allowed_origins: [browser.allowedOrigin],
|
|
target_id: "allowed-page",
|
|
approved: true,
|
|
observation_ms: 0,
|
|
}),
|
|
);
|
|
|
|
if (!result.ok) throw result.error;
|
|
expect(result.value.status).toBe("unavailable");
|
|
expect(result.value.tools.items).toEqual([]);
|
|
expect(result.value.completeness.unavailable_sections).toContain(
|
|
"webmcp_tools",
|
|
);
|
|
});
|
|
|
|
it("rejects WebMCP evidence when the page leaves its approved origin", async () => {
|
|
const browser = await startFakeCdpBrowser({
|
|
webMcpTools: true,
|
|
frameUrlAfterFirstRead: "https://private.example.test/tools",
|
|
});
|
|
browsers.push(browser);
|
|
const result = await new CdpBrowserProvider().discoverWebMcpTools(
|
|
discoverWebMcpToolsInputSchema.parse({
|
|
cdp_endpoint: browser.endpoint,
|
|
allowed_origins: [browser.allowedOrigin],
|
|
target_id: "allowed-page",
|
|
approved: true,
|
|
observation_ms: 0,
|
|
}),
|
|
);
|
|
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
_tag: "BrowserObservationError",
|
|
operation: "discover_webmcp_tools",
|
|
reason: "target_not_allowed",
|
|
},
|
|
});
|
|
expect(browser.commands.map(({ method }) => method)).not.toContain(
|
|
"WebMCP.invokeTool",
|
|
);
|
|
});
|
|
|
|
it("bounds WebMCP registration replay and reports dropped declarations", async () => {
|
|
const browser = await startFakeCdpBrowser({
|
|
webMcpTools: true,
|
|
extraCollections: true,
|
|
});
|
|
browsers.push(browser);
|
|
const result = await new CdpBrowserProvider().discoverWebMcpTools(
|
|
discoverWebMcpToolsInputSchema.parse({
|
|
cdp_endpoint: browser.endpoint,
|
|
allowed_origins: [browser.allowedOrigin],
|
|
target_id: "allowed-page",
|
|
approved: true,
|
|
observation_ms: 0,
|
|
max_tools: 1,
|
|
}),
|
|
);
|
|
|
|
if (!result.ok) throw result.error;
|
|
expect(result.value.tools).toMatchObject({
|
|
total: 2,
|
|
items: [expect.any(Object)],
|
|
});
|
|
expect(result.value.completeness).toMatchObject({
|
|
status: "truncated",
|
|
dropped_events: { webmcp_tools: 1 },
|
|
});
|
|
});
|
|
|
|
it("removes WebMCP declarations after their child frame leaves scope", async () => {
|
|
const browser = await startFakeCdpBrowser({
|
|
webMcpTools: true,
|
|
extraCollections: true,
|
|
webMcpChildLeavesScope: true,
|
|
});
|
|
browsers.push(browser);
|
|
const result = await new CdpBrowserProvider().discoverWebMcpTools(
|
|
discoverWebMcpToolsInputSchema.parse({
|
|
cdp_endpoint: browser.endpoint,
|
|
allowed_origins: [browser.allowedOrigin],
|
|
target_id: "allowed-page",
|
|
approved: true,
|
|
observation_ms: 0,
|
|
}),
|
|
);
|
|
|
|
if (!result.ok) throw result.error;
|
|
const names = result.value.tools.items.map(({ name }) => name);
|
|
expect(names).toEqual(
|
|
expect.arrayContaining(["search_orders", "update_order"]),
|
|
);
|
|
expect(names).not.toEqual(
|
|
expect.arrayContaining(["child_tool", "escaped_child_tool"]),
|
|
);
|
|
expect(result.value.completeness.policy_filtered_sections).toContain(
|
|
"webmcp_tools",
|
|
);
|
|
expect(JSON.stringify(result.value)).not.toContain(
|
|
"cross-origin-child-secret",
|
|
);
|
|
});
|
|
|
|
it("captures an explicitly approved content-addressed viewport screenshot", async () => {
|
|
const browser = await startFakeCdpBrowser();
|
|
browsers.push(browser);
|
|
const result = await new CdpBrowserProvider().captureScreenshot(
|
|
captureWebScreenshotInputSchema.parse({
|
|
cdp_endpoint: browser.endpoint,
|
|
allowed_origins: [browser.allowedOrigin],
|
|
target_id: "allowed-page",
|
|
approved: true,
|
|
screenshot_approved: true,
|
|
}),
|
|
);
|
|
|
|
if (!result.ok) throw result.error;
|
|
expect(result.value).toMatchObject({
|
|
viewport: { width: 1, height: 1 },
|
|
artifact: {
|
|
uri: expect.stringMatching(/^rea:\/\/web-screenshot\/sha256\//u),
|
|
bytes: 70,
|
|
media_type: "image/png",
|
|
},
|
|
});
|
|
expect(browser.commands.map(({ method }) => method)).toContain(
|
|
"Page.captureScreenshot",
|
|
);
|
|
});
|
|
|
|
it("discards screenshot pixels when the main frame navigates during capture", async () => {
|
|
const browser = await startFakeCdpBrowser({
|
|
navigateDuringScreenshotUrl: "https://private.example.test/screenshot",
|
|
});
|
|
browsers.push(browser);
|
|
const result = await new CdpBrowserProvider().captureScreenshot(
|
|
captureWebScreenshotInputSchema.parse({
|
|
cdp_endpoint: browser.endpoint,
|
|
allowed_origins: [browser.allowedOrigin],
|
|
target_id: "allowed-page",
|
|
approved: true,
|
|
screenshot_approved: true,
|
|
}),
|
|
);
|
|
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
_tag: "BrowserObservationError",
|
|
operation: "capture_web_screenshot",
|
|
reason: "target_not_allowed",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("ends immediately when a redirect leaves policy without exposing its URL", async () => {
|
|
const browser = await startFakeCdpBrowser({
|
|
sessionTimeline: "outside_policy",
|
|
});
|
|
browsers.push(browser);
|
|
const result = await new CdpBrowserProvider().observeSession(
|
|
observeWebSessionInputSchema.parse({
|
|
cdp_endpoint: browser.endpoint,
|
|
allowed_origins: [browser.allowedOrigin],
|
|
target_id: "allowed-page",
|
|
approved: true,
|
|
observation_ms: 1_000,
|
|
}),
|
|
);
|
|
|
|
if (!result.ok) throw result.error;
|
|
expect(result.value.window.end_reason).toBe("target_left_scope");
|
|
expect(result.value.timeline.at(-1)).toMatchObject({
|
|
type: "redirect",
|
|
url: null,
|
|
destination_scope: "outside_policy",
|
|
});
|
|
expect(result.value.completeness).toMatchObject({
|
|
status: "policy_filtered",
|
|
policy_filtered_sections: ["timeline"],
|
|
excluded: [
|
|
{
|
|
section: "timeline",
|
|
reason: "out_of_target_scope",
|
|
count: 1,
|
|
},
|
|
],
|
|
});
|
|
expect(JSON.stringify(result.value)).not.toContain("private.example.test");
|
|
});
|
|
|
|
it("fails closed when the final session frame leaves policy without an event", async () => {
|
|
const browser = await startFakeCdpBrowser({
|
|
frameUrlAfterFirstRead: "https://private.example.test/final",
|
|
});
|
|
browsers.push(browser);
|
|
const result = await new CdpBrowserProvider().observeSession(
|
|
observeWebSessionInputSchema.parse({
|
|
cdp_endpoint: browser.endpoint,
|
|
allowed_origins: [browser.allowedOrigin],
|
|
target_id: "allowed-page",
|
|
approved: true,
|
|
observation_ms: 1,
|
|
}),
|
|
);
|
|
|
|
if (!result.ok) throw result.error;
|
|
expect(result.value.window.end_reason).toBe("target_left_scope");
|
|
expect(result.value.target.final_url).toBeNull();
|
|
expect(result.value.completeness).toMatchObject({
|
|
status: "policy_filtered",
|
|
policy_filtered_sections: ["timeline"],
|
|
});
|
|
expect(JSON.stringify(result.value)).not.toContain("private.example.test");
|
|
});
|
|
|
|
it("rejects missing and disallowed page targets before attaching", async () => {
|
|
const browser = await startFakeCdpBrowser();
|
|
browsers.push(browser);
|
|
const provider = new CdpBrowserProvider();
|
|
for (const targetId of ["missing", "disallowed-page"]) {
|
|
const result = await provider.inspectPage(
|
|
inspectWebPageInputSchema.parse({
|
|
cdp_endpoint: browser.endpoint,
|
|
allowed_origins: [browser.allowedOrigin],
|
|
target_id: targetId,
|
|
approved: true,
|
|
observation_ms: 0,
|
|
}),
|
|
);
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok)
|
|
expect(result.error).toMatchObject({
|
|
_tag: "BrowserObservationError",
|
|
reason:
|
|
targetId === "missing" ? "target_not_found" : "target_not_allowed",
|
|
});
|
|
}
|
|
expect(browser.commands).toHaveLength(0);
|
|
});
|
|
|
|
it("returns typed errors for malformed discovery and disconnects", async () => {
|
|
const malformed = await startFakeCdpBrowser({ malformedDiscovery: true });
|
|
browsers.push(malformed);
|
|
const malformedResult = await new CdpBrowserProvider().listTargets(
|
|
listBrowserTargetsInputSchema.parse({
|
|
cdp_endpoint: malformed.endpoint,
|
|
allowed_origins: [malformed.allowedOrigin],
|
|
approved: true,
|
|
}),
|
|
);
|
|
expect(malformedResult).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
_tag: "BrowserObservationError",
|
|
reason: "invalid_endpoint_response",
|
|
},
|
|
});
|
|
|
|
const disconnecting = await startFakeCdpBrowser({
|
|
closeOnMethod: "Page.getFrameTree",
|
|
});
|
|
browsers.push(disconnecting);
|
|
const disconnectedResult = await new CdpBrowserProvider().inspectPage(
|
|
inspectWebPageInputSchema.parse({
|
|
cdp_endpoint: disconnecting.endpoint,
|
|
allowed_origins: [disconnecting.allowedOrigin],
|
|
target_id: "allowed-page",
|
|
approved: true,
|
|
observation_ms: 0,
|
|
}),
|
|
);
|
|
expect(disconnectedResult).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "BrowserObservationError", reason: "disconnected" },
|
|
});
|
|
});
|
|
|
|
it("reports configured truncation without over-reading DOM or script source", async () => {
|
|
const browser = await startFakeCdpBrowser();
|
|
browsers.push(browser);
|
|
const result = await new CdpBrowserProvider().inspectPage(
|
|
inspectWebPageInputSchema.parse({
|
|
cdp_endpoint: browser.endpoint,
|
|
allowed_origins: [browser.allowedOrigin],
|
|
target_id: "allowed-page",
|
|
approved: true,
|
|
observation_ms: 0,
|
|
include_script_sources: true,
|
|
limits: {
|
|
max_frames: 1,
|
|
max_dom_nodes: 1,
|
|
max_ax_nodes: 1,
|
|
max_scripts: 1,
|
|
max_resources: 1,
|
|
max_workers: 1,
|
|
max_storage_keys: 1,
|
|
max_script_source_bytes: 10,
|
|
max_total_script_source_bytes: 10,
|
|
max_network_events: 1,
|
|
max_console_events: 1,
|
|
max_websocket_events: 1,
|
|
},
|
|
}),
|
|
);
|
|
if (!result.ok) throw result.error;
|
|
expect(result.value.completeness).toMatchObject({
|
|
status: "truncated",
|
|
truncated_sections: ["dom", "script_sources"],
|
|
});
|
|
expect(result.value.dom).toMatchObject({ total_nodes: 2 });
|
|
expect(result.value.dom.nodes).toHaveLength(1);
|
|
expect(result.value.scripts.items[0]?.source).toMatchObject({
|
|
included: false,
|
|
reason: "declared script length exceeds per-script limit",
|
|
});
|
|
expect(browser.commands.map(({ method }) => method)).not.toContain(
|
|
"Debugger.getScriptSource",
|
|
);
|
|
});
|
|
|
|
it("bounds frames, resources, workers, accessibility, and storage inventories", async () => {
|
|
const browser = await startFakeCdpBrowser({ extraCollections: true });
|
|
browsers.push(browser);
|
|
const result = await new CdpBrowserProvider().inspectPage(
|
|
inspectWebPageInputSchema.parse({
|
|
cdp_endpoint: browser.endpoint,
|
|
allowed_origins: [browser.allowedOrigin],
|
|
target_id: "allowed-page",
|
|
approved: true,
|
|
observation_ms: 0,
|
|
include_storage_keys: true,
|
|
limits: {
|
|
max_frames: 1,
|
|
max_dom_nodes: 2,
|
|
max_ax_nodes: 1,
|
|
max_scripts: 1,
|
|
max_resources: 1,
|
|
max_workers: 1,
|
|
max_storage_keys: 1,
|
|
max_script_source_bytes: 10,
|
|
max_total_script_source_bytes: 10,
|
|
max_network_events: 1,
|
|
max_console_events: 1,
|
|
max_websocket_events: 1,
|
|
},
|
|
}),
|
|
);
|
|
if (!result.ok) throw result.error;
|
|
expect(result.value.completeness).toMatchObject({
|
|
status: "truncated",
|
|
truncated_sections: [
|
|
"accessibility",
|
|
"frames",
|
|
"resources",
|
|
"storage_keys",
|
|
"workers",
|
|
],
|
|
});
|
|
expect(result.value.frames).toHaveLength(1);
|
|
expect(result.value.resources).toHaveLength(1);
|
|
expect(result.value.workers).toHaveLength(1);
|
|
expect(result.value.accessibility).toMatchObject({ total_nodes: 2 });
|
|
expect(result.value.accessibility.nodes).toHaveLength(1);
|
|
expect(result.value.storage.local_storage_keys).toHaveLength(1);
|
|
expect(result.value.storage.session_storage_keys).toHaveLength(1);
|
|
expect(result.value.storage.indexed_db_names).toHaveLength(1);
|
|
expect(result.value.storage.cache_names).toHaveLength(1);
|
|
});
|
|
|
|
it("degrades optional domains but propagates protocol and payload failures", async () => {
|
|
const optional = await startFakeCdpBrowser({
|
|
unsupportedMethods: ["Accessibility.getFullAXTree"],
|
|
});
|
|
browsers.push(optional);
|
|
const degraded = await new CdpBrowserProvider().inspectPage(
|
|
inspectWebPageInputSchema.parse({
|
|
cdp_endpoint: optional.endpoint,
|
|
allowed_origins: [optional.allowedOrigin],
|
|
target_id: "allowed-page",
|
|
approved: true,
|
|
observation_ms: 0,
|
|
}),
|
|
);
|
|
if (!degraded.ok) throw degraded.error;
|
|
expect(degraded.value.accessibility.nodes).toEqual([]);
|
|
expect(degraded.value.accessibility.text_capture.status).toBe(
|
|
"unavailable",
|
|
);
|
|
expect(degraded.value.completeness.unavailable_sections).toContain(
|
|
"accessibility",
|
|
);
|
|
expect(degraded.value.limitations).toContain(
|
|
"Accessibility.getFullAXTree was unavailable from this browser target.",
|
|
);
|
|
|
|
for (const [options, reason] of [
|
|
[{ oversizedDiscovery: true }, "payload_limit"],
|
|
[{ invalidBrowserWebSocket: true }, "invalid_endpoint_response"],
|
|
] as const) {
|
|
const failed = await startFakeCdpBrowser(options);
|
|
browsers.push(failed);
|
|
const result = await new CdpBrowserProvider().listTargets(
|
|
listBrowserTargetsInputSchema.parse({
|
|
cdp_endpoint: failed.endpoint,
|
|
allowed_origins: [failed.allowedOrigin],
|
|
approved: true,
|
|
}),
|
|
);
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok)
|
|
expect(result.error).toMatchObject({
|
|
_tag: "BrowserObservationError",
|
|
reason,
|
|
});
|
|
}
|
|
|
|
const malformed = await startFakeCdpBrowser({
|
|
malformedMessageOnMethod: "Page.getFrameTree",
|
|
});
|
|
browsers.push(malformed);
|
|
const protocol = await new CdpBrowserProvider().inspectPage(
|
|
inspectWebPageInputSchema.parse({
|
|
cdp_endpoint: malformed.endpoint,
|
|
allowed_origins: [malformed.allowedOrigin],
|
|
target_id: "allowed-page",
|
|
approved: true,
|
|
observation_ms: 0,
|
|
}),
|
|
);
|
|
expect(protocol).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "BrowserObservationError", reason: "protocol_error" },
|
|
});
|
|
|
|
const malformedEvent = await startFakeCdpBrowser({
|
|
malformedEventOnMethod: "Debugger.enable",
|
|
});
|
|
browsers.push(malformedEvent);
|
|
const eventProtocol = await new CdpBrowserProvider().inspectPage(
|
|
inspectWebPageInputSchema.parse({
|
|
cdp_endpoint: malformedEvent.endpoint,
|
|
allowed_origins: [malformedEvent.allowedOrigin],
|
|
target_id: "allowed-page",
|
|
approved: true,
|
|
observation_ms: 0,
|
|
}),
|
|
);
|
|
expect(eventProtocol).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "BrowserObservationError", reason: "protocol_error" },
|
|
});
|
|
|
|
const malformedEventShape = await startFakeCdpBrowser({
|
|
malformedEventShapeOnMethod: "Debugger.enable",
|
|
});
|
|
browsers.push(malformedEventShape);
|
|
const eventShapeProtocol = await new CdpBrowserProvider().inspectPage(
|
|
inspectWebPageInputSchema.parse({
|
|
cdp_endpoint: malformedEventShape.endpoint,
|
|
allowed_origins: [malformedEventShape.allowedOrigin],
|
|
target_id: "allowed-page",
|
|
approved: true,
|
|
observation_ms: 0,
|
|
}),
|
|
);
|
|
expect(eventShapeProtocol).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "BrowserObservationError", reason: "protocol_error" },
|
|
});
|
|
});
|
|
|
|
it("cancels observation and still detaches without closing the page", async () => {
|
|
const browser = await startFakeCdpBrowser();
|
|
browsers.push(browser);
|
|
const controller = new AbortController();
|
|
const pending = new CdpBrowserProvider().inspectPage(
|
|
inspectWebPageInputSchema.parse({
|
|
cdp_endpoint: browser.endpoint,
|
|
allowed_origins: [browser.allowedOrigin],
|
|
target_id: "allowed-page",
|
|
approved: true,
|
|
observation_ms: 1_000,
|
|
}),
|
|
{ signal: controller.signal },
|
|
);
|
|
await waitForCommand(browser, "Target.attachToTarget");
|
|
controller.abort();
|
|
const result = await pending;
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
_tag: "AnalysisCancelledError",
|
|
operation: "inspect_web_page",
|
|
},
|
|
});
|
|
const methods = browser.commands.map(({ method }) => method);
|
|
expect(methods).toContain("Target.detachFromTarget");
|
|
expect(methods).not.toContain("Target.closeTarget");
|
|
expect(methods).not.toContain("Browser.close");
|
|
});
|
|
|
|
it("waits for navigation commit and rechecks the attached main-frame origin", async () => {
|
|
const transitioning = await startFakeCdpBrowser({
|
|
transitionalFrameReads: 2,
|
|
});
|
|
browsers.push(transitioning);
|
|
const input = (browser: FakeCdpBrowser) =>
|
|
inspectWebPageInputSchema.parse({
|
|
cdp_endpoint: browser.endpoint,
|
|
allowed_origins: [browser.allowedOrigin],
|
|
target_id: "allowed-page",
|
|
approved: true,
|
|
observation_ms: 0,
|
|
});
|
|
const committed = await new CdpBrowserProvider().inspectPage(
|
|
input(transitioning),
|
|
);
|
|
if (!committed.ok) throw committed.error;
|
|
expect(committed.value.target.origin).toBe(transitioning.allowedOrigin);
|
|
expect(
|
|
transitioning.commands.filter(
|
|
({ method }) => method === "Page.getFrameTree",
|
|
),
|
|
).toHaveLength(5);
|
|
|
|
const navigated = await startFakeCdpBrowser({
|
|
attachedFrameUrl: "https://unapproved.example.test/after-attach",
|
|
});
|
|
browsers.push(navigated);
|
|
const denied = await new CdpBrowserProvider().inspectPage(input(navigated));
|
|
expect(denied).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "BrowserObservationError", reason: "target_not_allowed" },
|
|
});
|
|
const methods = navigated.commands.map(({ method }) => method);
|
|
expect(methods).toContain("Target.detachFromTarget");
|
|
expect(methods).not.toContain("DOMSnapshot.captureSnapshot");
|
|
expect(methods).not.toContain("Storage.getUsageAndQuota");
|
|
|
|
const duringObservation = await startFakeCdpBrowser({
|
|
navigateDuringObservationUrl:
|
|
"https://unapproved.example.test/during-observation",
|
|
});
|
|
browsers.push(duringObservation);
|
|
const interrupted = await new CdpBrowserProvider().inspectPage(
|
|
input(duringObservation),
|
|
);
|
|
expect(interrupted).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "BrowserObservationError", reason: "target_not_allowed" },
|
|
});
|
|
expect(
|
|
duringObservation.commands.map(({ method }) => method),
|
|
).not.toContain("Accessibility.getFullAXTree");
|
|
|
|
const captureOptions: { navigateDuringCaptureUrl?: string } = {};
|
|
const duringCapture = await startFakeCdpBrowser(captureOptions);
|
|
captureOptions.navigateDuringCaptureUrl = `${duringCapture.allowedOrigin}/changed`;
|
|
browsers.push(duringCapture);
|
|
const discarded = await new CdpBrowserProvider().inspectPage(
|
|
input(duringCapture),
|
|
);
|
|
expect(discarded).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "BrowserObservationError", reason: "target_changed" },
|
|
});
|
|
expect(duringCapture.commands.map(({ method }) => method)).toContain(
|
|
"Target.detachFromTarget",
|
|
);
|
|
|
|
const crossOriginCapture = await startFakeCdpBrowser({
|
|
navigateDuringCaptureUrl:
|
|
"https://unapproved.example.test/during-capture",
|
|
});
|
|
browsers.push(crossOriginCapture);
|
|
const rejectedCapture = await new CdpBrowserProvider().inspectPage(
|
|
input(crossOriginCapture),
|
|
);
|
|
expect(rejectedCapture).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "BrowserObservationError", reason: "target_not_allowed" },
|
|
});
|
|
});
|
|
});
|
|
|
|
const waitForCommand = async (
|
|
browser: FakeCdpBrowser,
|
|
method: string,
|
|
): Promise<void> => {
|
|
for (let attempt = 0; attempt < 200; attempt += 1) {
|
|
if (browser.commands.some((command) => command.method === method)) return;
|
|
await new Promise<void>((resolve) => setTimeout(resolve, 5));
|
|
}
|
|
throw new Error(`Timed out waiting for fake CDP command ${method}`);
|
|
};
|