457 lines
14 KiB
TypeScript
457 lines
14 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import { webPageInspectionSchema } from "./browserObservation.js";
|
|
import { analyzeCapturedWebBundle } from "./webBundleAnalyzer.js";
|
|
import { webBundleAnalysisSchema } from "./webBundleAnalysis.js";
|
|
import { createWebTextArtifact } from "./webContentArtifact.js";
|
|
|
|
const origin = "https://app.example.test";
|
|
|
|
describe("web bundle analyzer", () => {
|
|
it("extracts graph, route, endpoint, vendor, and WebMCP evidence", () => {
|
|
const source = `
|
|
import { createApp } from "./chunk.js?token=secret";
|
|
const lazy = import("./lazy.js");
|
|
const routes = [{ path: "/users/:id?token=secret" }];
|
|
fetch("/api/users?authorization=secret");
|
|
document.modelContext.registerTool({
|
|
name: "lookup-user",
|
|
description: "Untrusted page declaration",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
userId: { type: "string" },
|
|
verbose: { type: "boolean" }
|
|
}
|
|
}
|
|
});
|
|
const __webpack_require__ = () => createApp(routes, lazy);
|
|
`;
|
|
const result = analyzeCapturedWebBundle(inspection(source));
|
|
|
|
expect(result.observations.chunks.edges).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
kind: "static_import",
|
|
specifier: "./chunk.js?token=secret",
|
|
resolved_url: `${origin}/assets/chunk.js?token=secret`,
|
|
}),
|
|
expect.objectContaining({
|
|
kind: "dynamic_import",
|
|
specifier: "./lazy.js",
|
|
}),
|
|
]),
|
|
);
|
|
expect(result.observations.routes).toEqual([
|
|
expect.objectContaining({ value: "/users/:id?token=secret" }),
|
|
]);
|
|
expect(result.observations.endpoints).toEqual([
|
|
expect.objectContaining({
|
|
value: "/api/users?authorization=secret",
|
|
}),
|
|
]);
|
|
expect(result.observations.webmcp_declarations).toEqual([
|
|
expect.objectContaining({
|
|
name: "lookup-user",
|
|
trust: "page-declared-untrusted",
|
|
schema_property_names: ["userId", "verbose"],
|
|
}),
|
|
]);
|
|
expect(result.inferences).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ value: "webpack", confidence: "high" }),
|
|
expect.objectContaining({ value: "Vue" }),
|
|
]),
|
|
);
|
|
expect(JSON.stringify(result)).toContain("authorization=secret");
|
|
expect(result.completeness.status).toBe("complete");
|
|
});
|
|
|
|
it("reports parser gaps without imposing a finding cap", () => {
|
|
const malformed = inspection("function broken( {");
|
|
const failed = analyzeCapturedWebBundle(malformed);
|
|
expect(failed.completeness).toMatchObject({
|
|
status: "partial",
|
|
parse_failures: 1,
|
|
});
|
|
expect(failed.unknowns[0]?.dimension).toBe("javascript_ast");
|
|
|
|
const analyzed = analyzeCapturedWebBundle(
|
|
inspection("fetch('/one'); fetch('/two'); fetch('/three');"),
|
|
);
|
|
expect(analyzed.observations.endpoints).toHaveLength(3);
|
|
expect(analyzed.completeness.status).toBe("complete");
|
|
});
|
|
|
|
it("analyzes AST nodes after the former fixed node ceiling", () => {
|
|
const source = `${";".repeat(250_001)}fetch('/last');`;
|
|
const result = analyzeCapturedWebBundle(inspection(source));
|
|
|
|
expect(result.observations.endpoints).toContainEqual(
|
|
expect.objectContaining({ value: "/last" }),
|
|
);
|
|
expect(result.completeness.visited_ast_nodes).toBeGreaterThan(250_000);
|
|
expect(result.completeness.status).toBe("complete");
|
|
});
|
|
|
|
it("retains complete long chunk and WebMCP declaration text", () => {
|
|
const specifier = `./${"chunk".repeat(1_000)}.js`;
|
|
const name = "tool".repeat(100);
|
|
const description = "untrusted declaration ".repeat(150);
|
|
const propertyName = "property".repeat(100);
|
|
const result = analyzeCapturedWebBundle(
|
|
inspection(`
|
|
import "${specifier}";
|
|
document.modelContext.registerTool({
|
|
name: "${name}",
|
|
description: "${description}",
|
|
inputSchema: { properties: { "${propertyName}": { type: "string" } } }
|
|
});
|
|
`),
|
|
);
|
|
|
|
expect(result.observations.chunks.edges[0]?.specifier).toBe(specifier);
|
|
expect(result.observations.webmcp_declarations[0]).toMatchObject({
|
|
name,
|
|
description,
|
|
schema_property_names: [propertyName],
|
|
trust: "page-declared-untrusted",
|
|
});
|
|
});
|
|
|
|
it("reports unavailable source maps as partial without dropping requested maps", () => {
|
|
const analysis = analyzeCapturedWebBundle(inspection("export {};"), {
|
|
status: "unavailable",
|
|
requested: 1,
|
|
processed: 1,
|
|
items: [
|
|
{
|
|
script_key: `scr_${"1".repeat(64)}`,
|
|
declared_url: "https://app.example.test/app.js.map",
|
|
status: "fetch_failed",
|
|
artifact: null,
|
|
original_sources: [],
|
|
original_module_edges: [],
|
|
mappings: [],
|
|
limitation: "Source-map fetch failed.",
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(analysis.completeness.status).toBe("partial");
|
|
expect(analysis.unknowns).toContainEqual({
|
|
dimension: "source_maps",
|
|
reason:
|
|
"One or more requested source maps were unavailable or incomplete",
|
|
affected_script_keys: [`scr_${"1".repeat(64)}`],
|
|
});
|
|
});
|
|
|
|
it("rejects inconsistent source-map coverage counts", () => {
|
|
const result = analyzeCapturedWebBundle(inspection("export {};"));
|
|
expect(
|
|
webBundleAnalysisSchema.safeParse({
|
|
...result,
|
|
observations: {
|
|
...result.observations,
|
|
source_maps: {
|
|
...result.observations.source_maps,
|
|
requested: 1,
|
|
},
|
|
},
|
|
}).success,
|
|
).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("web bundle static-analysis parity", () => {
|
|
it("recognizes endpoints through nested dynamic receivers like static analysis", () => {
|
|
const result = analyzeCapturedWebBundle(
|
|
inspection('window[recv].fetch("/nested-fetch");'),
|
|
);
|
|
expect(result.observations.endpoints).toContainEqual(
|
|
expect.objectContaining({ value: "/nested-fetch" }),
|
|
);
|
|
});
|
|
|
|
it("reads open endpoints only from XMLHttpRequest method and URL pairs", () => {
|
|
const result = analyzeCapturedWebBundle(
|
|
inspection(`
|
|
xhr.open("GET", "/xhr-get");
|
|
xhr.open("post", "https://xhr.example.test/submit");
|
|
dav.open("PROPFIND", "/dav/");
|
|
fs.open("/tmp/data.txt", "r", done);
|
|
window.open("https://help.example.test/", "_blank");
|
|
xhr.open(method, "/dynamic-method");
|
|
fs.open(path, "r", done);
|
|
window.open(url, "_blank");
|
|
`),
|
|
);
|
|
expect(
|
|
result.observations.endpoints.map(({ value }) => value).sort(),
|
|
).toEqual([
|
|
"/dav/",
|
|
"/dynamic-method",
|
|
"/xhr-get",
|
|
"https://xhr.example.test/submit",
|
|
]);
|
|
});
|
|
|
|
it("recognizes loadURL endpoints like static analysis", () => {
|
|
const result = analyzeCapturedWebBundle(
|
|
inspection('window.win.loadURL("https://embedded.test/app");'),
|
|
);
|
|
expect(result.observations.endpoints).toContainEqual(
|
|
expect.objectContaining({ value: "https://embedded.test/app" }),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("web bundle artifact metadata", () => {
|
|
it("preserves long provider media types in artifact summaries", () => {
|
|
const result = analyzeCapturedWebBundle(inspection("export {};"));
|
|
const mediaType = `application/${"x".repeat(300)}`;
|
|
const artifact = result.capture.source_artifacts[0] ?? {
|
|
sha256: "0".repeat(64),
|
|
bytes: 0,
|
|
media_type: "application/javascript",
|
|
charset: "utf-8" as const,
|
|
text_available: true as const,
|
|
};
|
|
|
|
expect(
|
|
webBundleAnalysisSchema.parse({
|
|
...result,
|
|
capture: {
|
|
...result.capture,
|
|
source_artifacts: [{ ...artifact, media_type: mediaType }],
|
|
},
|
|
}).capture.source_artifacts[0]?.media_type,
|
|
).toBe(mediaType);
|
|
});
|
|
});
|
|
|
|
const inspection = (source: string) =>
|
|
webPageInspectionSchema.parse({
|
|
browser: {
|
|
product: "Fake Chrome",
|
|
protocol_version: "1.3",
|
|
revision: "1",
|
|
user_agent: "fake",
|
|
js_version: "1",
|
|
},
|
|
target: {
|
|
target_id: "page-1",
|
|
type: "page",
|
|
title: "App",
|
|
url: `${origin}/app`,
|
|
origin,
|
|
attached: false,
|
|
},
|
|
capture_window: {
|
|
started_at: "2026-07-14T00:00:00.000Z",
|
|
ended_at: "2026-07-14T00:00:01.000Z",
|
|
observation_ms: 1_000,
|
|
},
|
|
completeness: {
|
|
status: "attach_limited",
|
|
conditions: ["attach_limited"],
|
|
policy_filtered_sections: [],
|
|
attach_limited_sections: ["network_requests"],
|
|
truncated_sections: [],
|
|
unavailable_sections: [],
|
|
excluded: [],
|
|
dropped_events: {
|
|
scripts: 0,
|
|
network_requests: 0,
|
|
console_events: 0,
|
|
websocket_connections: 0,
|
|
websocket_frames: 0,
|
|
webmcp_tools: 0,
|
|
timeline_events: 0,
|
|
total: 0,
|
|
},
|
|
},
|
|
frames: [],
|
|
dom: { total_nodes: 0, nodes: [] },
|
|
accessibility: {
|
|
total_nodes: 0,
|
|
text_capture: {
|
|
status: "not_approved",
|
|
retained_bytes: 0,
|
|
excluded_fields: 0,
|
|
},
|
|
nodes: [],
|
|
},
|
|
scripts: {
|
|
total: 1,
|
|
items: [
|
|
{
|
|
script_key: `scr_${"1".repeat(64)}`,
|
|
url: `${origin}/assets/app.js`,
|
|
origin,
|
|
cdp_hash: "hash",
|
|
length: Buffer.byteLength(source),
|
|
is_module: true,
|
|
language: "JavaScript",
|
|
source_map_url: null,
|
|
resource_reconciliation: {
|
|
status: "unmatched",
|
|
reason: "no_exact_sanitized_url",
|
|
},
|
|
source: {
|
|
included: true,
|
|
artifact: createWebTextArtifact(source, "text/javascript"),
|
|
},
|
|
},
|
|
],
|
|
},
|
|
resources: [],
|
|
network: {
|
|
requests: [],
|
|
websocket_events: [],
|
|
coverage_started_at: "2026-07-14T00:00:00.000Z",
|
|
prior_activity_available: false,
|
|
},
|
|
console: {
|
|
events: [],
|
|
coverage_started_at: "2026-07-14T00:00:00.000Z",
|
|
prior_activity_available: false,
|
|
},
|
|
workers: [],
|
|
metadata: {
|
|
responses: [],
|
|
dom_urls: [],
|
|
agent_hints: [],
|
|
excluded_dom_urls: 0,
|
|
headers_allowlisted: true,
|
|
},
|
|
storage: {
|
|
origin,
|
|
usage_bytes: null,
|
|
quota_bytes: null,
|
|
local_storage_keys: [],
|
|
session_storage_keys: [],
|
|
indexed_db_names: [],
|
|
cache_names: [],
|
|
values_redacted: true,
|
|
},
|
|
limitations: [],
|
|
});
|
|
|
|
it("keeps bare module locations unresolved without inventing URL-relative paths", () => {
|
|
const result = analyzeCapturedWebBundle(
|
|
inspection(`
|
|
import "package-name";
|
|
import("@scope/package");
|
|
require("package-name");
|
|
import "./actual.js";
|
|
importScripts("worker.js");
|
|
`),
|
|
);
|
|
for (const specifier of ["package-name", "@scope/package"])
|
|
expect(
|
|
result.observations.chunks.edges
|
|
.filter((edge) => edge.specifier === specifier)
|
|
.every((edge) => edge.resolved_url === null),
|
|
).toBe(true);
|
|
expect(
|
|
result.observations.chunks.edges.find(
|
|
(edge) => edge.specifier === "./actual.js",
|
|
)?.resolved_url,
|
|
).toBe(`${origin}/assets/actual.js`);
|
|
expect(
|
|
result.observations.chunks.edges.find(
|
|
(edge) => edge.specifier === "worker.js",
|
|
)?.resolved_url,
|
|
).toBe(`${origin}/assets/worker.js`);
|
|
});
|
|
|
|
it("retains each literal importScripts argument in source order", () => {
|
|
const result = analyzeCapturedWebBundle(
|
|
inspection(
|
|
'importScripts("first.js", dynamicValue, "second.js", "first.js");',
|
|
),
|
|
);
|
|
expect(
|
|
result.observations.chunks.edges
|
|
.filter(({ kind }) => kind === "worker_import")
|
|
.map(({ specifier }) => specifier),
|
|
).toEqual(["first.js", "second.js"]);
|
|
});
|
|
|
|
describe("web metadata key semantics", () => {
|
|
it("keeps computed metadata keys and overwrite order conservative", () => {
|
|
const result = analyzeCapturedWebBundle(
|
|
inspection(`
|
|
const path = "description";
|
|
const routes = [{ [path]: "/not-a-route" }, { ["path"]: "/real" }];
|
|
document.modelContext.registerTool({
|
|
name: "old", name: "new",
|
|
inputSchema: { properties: { [name]: {}, literal: {} } }
|
|
});
|
|
document.modelContext.registerTool({ name: "old", ...dynamic });
|
|
document.modelContext.registerTool({ name: "old", [name]: "dynamic" });
|
|
document.modelContext.registerTool({ name: "old", get name() {} });
|
|
document.modelContext.registerTool({ ...dynamic, ["name"]: "restored" });
|
|
document.modelContext.registerTool({ name: "valid", [""]: 0 });
|
|
`),
|
|
);
|
|
|
|
expect(result.observations.routes.map(({ value }) => value)).toEqual([
|
|
"/real",
|
|
]);
|
|
expect(result.observations.webmcp_declarations).toEqual([
|
|
expect.objectContaining({
|
|
name: "new",
|
|
schema_property_names: ["literal"],
|
|
}),
|
|
expect.objectContaining({ name: null }),
|
|
expect.objectContaining({ name: "restored" }),
|
|
expect.objectContaining({ name: "valid" }),
|
|
]);
|
|
});
|
|
});
|
|
|
|
it("does not infer call names from dynamic computed identifiers", () => {
|
|
const result = analyzeCapturedWebBundle(
|
|
inspection(`
|
|
const fetch = "render";
|
|
const require = "render";
|
|
const registerTool = "render";
|
|
window[fetch]("/render-only");
|
|
window[require]("./not-a-module.js");
|
|
document.modelContext[registerTool]({ name: "not-a-tool" });
|
|
`),
|
|
);
|
|
|
|
expect(result.observations.endpoints).toEqual([]);
|
|
expect(result.observations.chunks.edges).toEqual([]);
|
|
expect(result.observations.webmcp_declarations).toEqual([]);
|
|
});
|
|
|
|
it("retains direct and literal computed call names", () => {
|
|
const result = analyzeCapturedWebBundle(
|
|
inspection(`
|
|
window.fetch("/direct");
|
|
window["fetch"]("/literal");
|
|
window[receiver].fetch("/nested-receiver");
|
|
window.require("./direct.js");
|
|
window["require"]("./literal.js");
|
|
document.modelContext["registerTool"]({ name: "literal-tool" });
|
|
`),
|
|
);
|
|
|
|
expect(result.observations.endpoints.map(({ value }) => value)).toEqual([
|
|
"/direct",
|
|
"/literal",
|
|
"/nested-receiver",
|
|
]);
|
|
expect(
|
|
result.observations.chunks.edges.map(({ specifier }) => specifier),
|
|
).toEqual(["./direct.js", "./literal.js"]);
|
|
expect(result.observations.webmcp_declarations).toEqual([
|
|
expect.objectContaining({ name: "literal-tool" }),
|
|
]);
|
|
});
|