fix(browser): retain excluded source-map declaration coverage

This commit is contained in:
morluto
2026-10-24 03:05:35 +08:00
parent d56a25e64f
commit e8374f4e58
7 changed files with 177 additions and 13 deletions
+7
View File
@@ -558,6 +558,13 @@ Network and console coverage starts only after REA attaches and enables the rele
Source maps are not fetched by inspection. Bundle analysis fetches them only when `fetch_source_maps` is selected, with exact-origin redirect checks and no credentials, cookies, or referrer. Each fetch operation uses `source_map_fetch_timeout_ms` (default 30000) and a 64 MiB aggregate streamed-response budget across redirects. The byte budget has no runtime override. A fetch overrun is an item limitation, not a thrown analysis timeout. A deadline or byte-limit failure remains explicitly unavailable and makes bundle completeness partial. Source-map decoding also bounds raw resolved identities to 64 MiB and aggregate inline evidence to 32 MiB. Failed maps preserve their failure context when it fits; if another context cannot fit, collection stops with the full requested count, the returned processed count, and an explicit unknown-results limitation. Retained mappings and original sources are returned inline. See [source-location limits](web-source-location.md) for the structural decoder bounds. Original source artifacts with `.ts`, `.tsx`, `.mts`, or `.cts` suffixes carry `text/typescript`; other source names retain `text/javascript`.
When fetching is selected, source-map `excluded` facts preserve the capture
ledger's reasons and counts for declarations rejected before download. No
disallowed URL details are retained or fetched. `requested` and `processed`
count the admitted fetch requests; exclusions are counted separately. Excluded
declarations make the collection unavailable, or partial when maps were retained.
An empty collection with no declarations remains `not_requested`.
Source-map items retain the script's `declared_url` separately from the observed
`effective_url`. Successful maps use the latter as their source-resolution base.
Failed fetches retain the last response URL observed within the approved origins;
+14 -10
View File
@@ -13,6 +13,7 @@ import {
type WebPageInspection,
} from "../domain/browserObservationSchemas.js";
import { IsolatedWebBundleAnalyzer } from "./IsolatedWebBundleAnalyzer.js";
import { sourceMapsWithCaptureExclusions } from "../domain/webSourceMapCoverage.js";
import type {
AnalyzeWebBundleInput,
WebBundleAnalysis,
@@ -195,16 +196,19 @@ export class CdpBrowserProvider implements BrowserObservationPort {
try {
captured = await this.#capture(input, options, "analyze_web_bundle");
const sourceMaps = input.fetch_source_maps
? await fetchWebSourceMaps(
captured.sourceMapRequests,
{
...input,
allowed_origins:
input.allowed_origins.length > 0
? input.allowed_origins
: [captured.inspection.target.origin],
},
options.signal,
? sourceMapsWithCaptureExclusions(
await fetchWebSourceMaps(
captured.sourceMapRequests,
{
...input,
allowed_origins:
input.allowed_origins.length > 0
? input.allowed_origins
: [captured.inspection.target.origin],
},
options.signal,
),
captured.inspection.completeness.excluded,
)
: undefined;
return await this.#bundleAnalyzer.analyze(
+1 -1
View File
@@ -45,7 +45,7 @@ export type BrowserExclusionReason = z.infer<
typeof browserExclusionReasonSchema
>;
const browserExclusionSchema = z.object({
export const browserExclusionSchema = z.object({
section: browserSectionSchema,
reason: browserExclusionReasonSchema,
count: z.int().min(0).nullable(),
+10 -1
View File
@@ -1,4 +1,5 @@
import { z } from "zod";
import { browserExclusionSchema } from "./browserCompleteness.js";
import { javaScriptRouteInventorySchema } from "./javascript/javascriptRouteInventory.js";
import {
@@ -315,6 +316,12 @@ export const webSourceMapsSchema = z
requested: z.int().min(0),
processed: z.int().min(0),
items: z.array(sourceMapSchema),
/** Capture exclusions retain counts/reasons without disallowed URL details. */
excluded: z
.array(
browserExclusionSchema.extend({ section: z.literal("source_maps") }),
)
.optional(),
limitation: z.string().min(1).optional(),
})
.superRefine((sourceMaps, context) => {
@@ -341,7 +348,9 @@ export const webSourceMapsSchema = z
(status) => status === "included" || status === "partial",
).length;
const allowedStatuses =
sourceMaps.processed < sourceMaps.requested
sourceMaps.processed < sourceMaps.requested ||
sourceMaps.excluded?.some(({ count }) => count === null || count > 0) ===
true
? retained === 0
? ["unavailable"]
: ["partial"]
+1 -1
View File
@@ -88,7 +88,7 @@ export const buildCapturedWebBundleAnalysis = (
"script_sources",
);
const omittedSourceMaps =
sourceMaps.status === "not_requested"
sourceMaps.status === "not_requested" || sourceMaps.excluded !== undefined
? undefined
: captureBudgetOmission(inspection, "source_maps");
const sourceMapIncomplete =
+36
View File
@@ -0,0 +1,36 @@
import type { BrowserExclusion } from "./browserCompleteness.js";
import {
webSourceMapsSchema,
type WebSourceMaps,
} from "./webBundleAnalysis.js";
/** Preserve capture admission facts when map fetching was selected. */
export const sourceMapsWithCaptureExclusions = (
sourceMaps: WebSourceMaps,
exclusions: readonly BrowserExclusion[],
): WebSourceMaps => {
const excluded = exclusions.flatMap((exclusion) =>
exclusion.section === "source_maps" && exclusion.count !== 0
? [{ ...exclusion, section: "source_maps" as const }]
: [],
);
if (excluded.length === 0) return sourceMaps;
const summary = excluded
.map(
({ reason, count }) =>
`${count === null ? "An unknown number of" : String(count)} source-map declaration(s) excluded during capture: ${reason}.`,
)
.join(" ");
return webSourceMapsSchema.parse({
...sourceMaps,
status: sourceMaps.items.some(
({ status }) => status === "included" || status === "partial",
)
? "partial"
: "unavailable",
excluded,
limitation: [sourceMaps.limitation, summary]
.filter((value) => value !== undefined)
.join(" "),
});
};
@@ -0,0 +1,108 @@
import { expect, it } from "vitest";
import { CdpBrowserProvider } from "../../../src/browser/CdpBrowserProvider.js";
import { analyzeWebBundleInputSchema } from "../../../src/domain/webBundleAnalysis.js";
import { startFakeCdpBrowser } from "../../fixtures/fakeCdpBrowser.js";
import { trackBrowser } from "./cdpBrowserProvider.support.js";
it.each(["none", "excluded", "mixed", "redirect", "not-selected"] as const)(
"preserves captured source-map exclusion coverage: %s",
async (mode) => {
const source = "export const observed = true;";
const browser = await startFakeCdpBrowser({
sourceMapBody: JSON.stringify({
version: 3,
names: [],
sources: ["main.js"],
sourcesContent: [source],
mappings: "AAAA",
}),
...(mode === "redirect"
? { sourceMapRedirect: "https://outside.example.test/main.js.map" }
: {}),
commandResult: ({ method }) =>
method === "Debugger.getScriptSource"
? { scriptSource: source }
: undefined,
commandEvents: (command, origin) => {
if (command.method !== "Debugger.enable") return undefined;
const script = (id: string, sourceMapURL?: string) => ({
method: "Debugger.scriptParsed",
...(command.sessionId === undefined
? {}
: { sessionId: command.sessionId }),
params: {
scriptId: id,
url: `${origin}/${id}.js`,
hash: id,
length: Buffer.byteLength(source),
isModule: true,
executionContextId: 1,
scriptLanguage: "JavaScript",
...(sourceMapURL === undefined ? {} : { sourceMapURL }),
},
});
return [
script(
"main",
mode === "none"
? undefined
: mode === "redirect"
? "/app.js.map"
: "https://outside.example.test/main.js.map",
),
...(mode === "mixed" ? [script("second", "/app.js.map")] : []),
];
},
});
trackBrowser(browser);
const provider = new CdpBrowserProvider();
try {
const result = await provider.analyzeBundle(
analyzeWebBundleInputSchema.parse({
cdp_endpoint: browser.endpoint,
allowed_origins: [browser.allowedOrigin],
target_id: "allowed-page",
observation_ms: 0,
fetch_source_maps: mode !== "not-selected",
}),
);
if (!result.ok) throw result.error;
const maps = result.value.observations.source_maps;
if (mode === "none" || mode === "not-selected") {
expect(maps).toEqual({
status: "not_requested",
requested: 0,
processed: 0,
items: [],
});
expect(result.value.completeness.status).toBe("complete");
expect(result.value.unknowns).toEqual([]);
} else {
expect(maps.status).toBe(mode === "mixed" ? "partial" : "unavailable");
expect(result.value.completeness.status).toBe("partial");
expect(result.value.unknowns).toContainEqual(
expect.objectContaining({ dimension: "source_maps" }),
);
if (mode === "redirect") {
expect(maps.excluded).toBeUndefined();
expect(maps.items[0]?.status).toBe("policy_filtered");
} else {
expect(maps.excluded).toEqual([
{ section: "source_maps", reason: "disallowed_origin", count: 1 },
]);
expect(maps.requested).toBe(mode === "mixed" ? 1 : 0);
expect(maps.processed).toBe(mode === "mixed" ? 1 : 0);
if (mode === "mixed") expect(maps.items[0]?.status).toBe("included");
}
}
expect(JSON.stringify(result.value)).not.toContain(
"outside.example.test",
);
expect(
browser.httpRequests.filter(({ url }) => url.startsWith("/app.js.map")),
).toHaveLength(mode === "mixed" || mode === "redirect" ? 1 : 0);
} finally {
await provider.close();
}
},
);