From e8374f4e58fb11c563bce3a1ee09110947229dd3 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 24 Oct 2026 03:05:35 +0800 Subject: [PATCH] fix(browser): retain excluded source-map declaration coverage --- docs/browser-observation.md | 7 ++ src/browser/CdpBrowserProvider.ts | 24 ++-- src/domain/browserCompleteness.ts | 2 +- src/domain/webBundleAnalysis.ts | 11 +- src/domain/webBundleAnalyzerResult.ts | 2 +- src/domain/webSourceMapCoverage.ts | 36 ++++++ .../sourceMapCaptureExclusions.test.ts | 108 ++++++++++++++++++ 7 files changed, 177 insertions(+), 13 deletions(-) create mode 100644 src/domain/webSourceMapCoverage.ts create mode 100644 tests/boundary/browser/sourceMapCaptureExclusions.test.ts diff --git a/docs/browser-observation.md b/docs/browser-observation.md index 099801c6fb..ba3496cb85 100644 --- a/docs/browser-observation.md +++ b/docs/browser-observation.md @@ -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; diff --git a/src/browser/CdpBrowserProvider.ts b/src/browser/CdpBrowserProvider.ts index 19de49dd10..4a1de07038 100644 --- a/src/browser/CdpBrowserProvider.ts +++ b/src/browser/CdpBrowserProvider.ts @@ -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( diff --git a/src/domain/browserCompleteness.ts b/src/domain/browserCompleteness.ts index 21dda74be1..8b4222b9cb 100644 --- a/src/domain/browserCompleteness.ts +++ b/src/domain/browserCompleteness.ts @@ -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(), diff --git a/src/domain/webBundleAnalysis.ts b/src/domain/webBundleAnalysis.ts index a48a3bad62..d59109773f 100644 --- a/src/domain/webBundleAnalysis.ts +++ b/src/domain/webBundleAnalysis.ts @@ -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"] diff --git a/src/domain/webBundleAnalyzerResult.ts b/src/domain/webBundleAnalyzerResult.ts index 9d7c403c7c..c1656e282c 100644 --- a/src/domain/webBundleAnalyzerResult.ts +++ b/src/domain/webBundleAnalyzerResult.ts @@ -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 = diff --git a/src/domain/webSourceMapCoverage.ts b/src/domain/webSourceMapCoverage.ts new file mode 100644 index 0000000000..e17d0fd115 --- /dev/null +++ b/src/domain/webSourceMapCoverage.ts @@ -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(" "), + }); +}; diff --git a/tests/boundary/browser/sourceMapCaptureExclusions.test.ts b/tests/boundary/browser/sourceMapCaptureExclusions.test.ts new file mode 100644 index 0000000000..22a74a7301 --- /dev/null +++ b/tests/boundary/browser/sourceMapCaptureExclusions.test.ts @@ -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(); + } + }, +);