fix(browser): preserve completed source-map observations

Return typed fetch outcomes with completed map coverage when acquisition is
cancelled or a response resource fails. Compose those facts with the captured
inspection as partial bundle Evidence, preserving exclusions, exact abort causes,
and cleanup priority without claiming unperformed script analysis.

Keep complete response-body SHA-256 and byte counts when map interpretation
fails, while preserving compact failure rows and capacity for later valid maps.
Reuse the read owner digest for successful text artifacts and preflight their
JSON representation before parsing. Historical failed rows remain importable.

Exercise controlled HTTP/stream boundaries, CLI SIGINT, and SDK cancellation
with later MCP Evidence retrieval. Share the public cancellation fixture and
use one Result API across consumers. Real Chromium and Windows CLI cancellation
were not verified.
This commit is contained in:
morluto committed 2026-10-25 05:09:26 +08:00
1 parent 38719a6fa9
commit 10b83a9b62
22 files changed
+1201 -204

No files matched your search

+27
View File
@@ -598,6 +598,22 @@ 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`.
A fully read response that fails UTF-8, JSON, mapping, or structural validation
retains `artifact: null`, empty original-source, module-edge, and mapping
arrays, and an explicit failure limitation. Invalid content is `invalid`;
decoder or structural capacity refusal is `fetch_failed`. Its optional
`response_identity` records the complete body's SHA-256 digest, byte count,
and `fetch-response-body-bytes` representation without retaining malformed
text or charging that text to the 32 MiB output budget. These are bytes
delivered by Fetch after HTTP content decoding and before UTF-8 decoding, not
HTTP wire bytes. The identity is absent when the body was not fully read, and
older observations may omit it. A response whose text cannot fit the existing
output budget is likewise `fetch_failed` with text omitted and completed-body
identity; JSON parsing and source-map decoding are not attempted. Successful and partial maps keep their
full text artifacts. Their original text, including an initial BOM or XSSI
line, remains distinct from the parsing view that removes that framing.
Collection reads the body once and does not raise its response or output limits.
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`
@@ -605,6 +621,17 @@ 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`.
If the caller cancels while later source maps are being fetched, the failure's
partial Evidence retains the captured page and every completed map item. Its
`normalized_result.kind` is `web-bundle-acquisition` and
`analysis_status` is `not_performed`: no script-analysis result is claimed.
`source_maps.requested` remains the admitted request count,
`source_maps.processed` counts completed items, and any unprocessed requests
have an explicit unknown-results limitation. Capture exclusions remain in the
same collection. The cancellation error keeps the caller's exact reason;
an already observed response resource or cleanup failure keeps its own error
category and cleanup owner.
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;
+15
View File
@@ -111,6 +111,10 @@ are made.
## Verification
Browser bundle collection uses absolute section offsets for nested inline maps.
If caller cancellation stops browser map fetching after some items complete,
the failed bundle request retains those items with the captured page in partial
Evidence and marks bundle analysis `not_performed`. This is distinct from the
local source-map decoder's failure semantics above.
An enclosing column offset contributes only when the nested section starts on
that enclosing section's first generated line. Parent boundaries still clip
preceding browser mappings when a nested section starts with a gap or has no
@@ -136,6 +140,17 @@ requests are left unprocessed and reported as unknown through the top-level
items.
Malformed UTF-8 map responses are reported as invalid rather than repaired with
replacement characters.
For browser fetching, an `invalid` map or structural or decoder capacity
failure has `artifact: null`, empty decoded arrays, and an explicit limitation.
When its bounded response body was read in full, optional `response_identity`
records that body's SHA-256 digest and byte count after HTTP content decoding
and before UTF-8 decoding. It is not a digest of partial reads or a claim that
the map was valid; historical items may omit it. Malformed text is omitted so
it does not consume the 32 MiB inline budget needed by later valid maps. A
response whose text cannot fit that budget is refused before JSON parsing and
source-map decoding, and retains the same
completed-body identity. Successful and partial maps keep their full original
text, including BOM and XSSI framing; only their parsing view removes it.
Focused tests cover duplicate mappings, indexed offsets/overlap/local indexes,
32-bit VLQ overflow, nullable content, UTF-16/CRLF positions, immutable artifact
+19 -6
View File
@@ -15,6 +15,7 @@ import type {
} from "../domain/browserObservationSchemas.js";
import type {
AnalyzeWebBundleInput,
WebBundleAcquisitionPartialObservation,
WebBundleAnalysis,
} from "../domain/webBundleAnalysis.js";
import type {
@@ -50,6 +51,7 @@ type BrowserEvidenceResult =
| BrowserTargetList
| WebPageInspection
| WebBundleAnalysis
| WebBundleAcquisitionPartialObservation
| WebObservationSession
| WebMcpDiscovery
| BrowserCaptureComparison
@@ -65,14 +67,21 @@ type BrowserEvidenceOperation =
| "capture_web_screenshot"
| "compare_web_screenshots";
const isBundleAcquisition = (
result: BrowserEvidenceResult,
): result is WebBundleAcquisitionPartialObservation =>
"kind" in result && result.kind === "web-bundle-acquisition";
/** Create Evidence for one request-scoped external browser observation. */
export const createBrowserEvidence = (
operation: BrowserEvidenceOperation,
input: BrowserEvidenceInput,
result: BrowserEvidenceResult,
provider: ProviderIdentity,
): Evidence =>
createEvidence(undefined, provider, {
): Evidence => {
const acquisition = isBundleAcquisition(result);
const observed = acquisition ? result.inspection : result;
return createEvidence(undefined, provider, {
predicateType: browserPredicate(operation),
operation,
parameters: browserParameters(input, result),
@@ -80,21 +89,25 @@ export const createBrowserEvidence = (
confidence: "observed",
authority: "external-service",
environment:
"browser" in result
"browser" in observed
? {
id: `${result.browser.product}@${result.browser.revision}`,
id: `${observed.browser.product}@${observed.browser.revision}`,
platform: "unknown",
architecture: "unknown",
isolation: "none",
}
: null,
limitations: [
...result.limitations,
...("browser" in result
...observed.limitations,
...(acquisition && result.source_maps.limitation !== undefined
? [result.source_maps.limitation]
: []),
...("browser" in observed
? ["Browser host platform and architecture were not observed."]
: []),
],
});
};
const browserParameters = (
input: BrowserEvidenceInput,
@@ -163,6 +163,47 @@ it("retains captured page Evidence when bundle analysis fails after capture", as
}
});
it("projects completed source-map acquisition without claiming bundle analysis", async () => {
const inspection = passiveNetworkCapture();
const partial = {
kind: "web-bundle-acquisition" as const,
inspection,
source_maps: {
status: "unavailable" as const,
requested: 1,
processed: 0,
items: [],
limitation: "One requested source map was not processed.",
},
analysis_status: "not_performed" as const,
};
const reason = { requested: "stop after capture" };
const failure = new AnalysisCancelledError("analyze_web_bundle", {
cause: reason,
partialObservation: partial,
});
const browser: BrowserObservationPort = {
...provider(async () => ok(report)),
analyzeBundle: async () => err(failure),
};
const selected = analyzeWebBundleInputSchema.parse({
...input,
cdp_endpoint: input.cdp_endpoint.recorded,
target_id: "page",
fetch_source_maps: true,
});
const result = await analyzeWebBundle(browser, selected);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error).toBe(failure);
expect(result.error.cause).toBe(reason);
expect(parseEvidence(result.error.partialObservation)).toMatchObject({
operation: "analyze_web_bundle",
parameters: expect.objectContaining({ fetch_source_maps: true }),
normalized_result: partial,
});
});
it("retains completed bundle facts as bundle Evidence after cancellation", async () => {
const { AnalysisCancelledError } =
await import("../domain/analysisErrorCore.js");
@@ -4,6 +4,7 @@ import type {
ListBrowserTargetsInput,
} from "../domain/browserObservation.js";
import {
webBundleAcquisitionPartialObservationSchema,
webBundleAnalysisSchema,
type AnalyzeWebBundleInput,
} from "../domain/webBundleAnalysis.js";
@@ -288,6 +289,24 @@ const retainBrowserObservation = (
errorOperation: "inspect_web_page" | "analyze_web_bundle",
): Result<Evidence, AnalysisError> => {
const partial = error.partialObservation;
if (
errorOperation === "analyze_web_bundle" &&
partial !== undefined &&
"kind" in partial &&
partial.kind === "web-bundle-acquisition"
) {
const acquisition =
webBundleAcquisitionPartialObservationSchema.safeParse(partial);
if (acquisition.success) {
error.partialObservation = createBrowserEvidence(
"analyze_web_bundle",
input,
acquisition.data,
provider.identity(),
);
return err(error);
}
}
if (
errorOperation === "analyze_web_bundle" &&
partial !== undefined &&
+38 -17
View File
@@ -17,6 +17,7 @@ import { sourceMapsWithCaptureExclusions } from "../domain/webSourceMapCoverage.
import type {
AnalyzeWebBundleInput,
WebBundleAnalysis,
WebSourceMaps,
} from "../domain/webBundleAnalysis.js";
import {
webObservationSessionSchema,
@@ -195,22 +196,43 @@ export class CdpBrowserProvider implements BrowserObservationPort {
let captured: CapturedPage | undefined;
try {
captured = await this.#capture(input, options, "analyze_web_bundle");
const sourceMaps = input.fetch_source_maps
? 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,
let sourceMaps: WebSourceMaps | undefined;
if (input.fetch_source_maps) {
const fetched = await fetchWebSourceMaps(
captured.sourceMapRequests,
{
...input,
allowed_origins:
input.allowed_origins.length > 0
? input.allowed_origins
: [captured.inspection.target.origin],
},
options.signal,
);
if (!fetched.ok) {
const partial = fetched.error.partialObservation;
if (
partial !== undefined &&
"kind" in partial &&
partial.kind === "web-source-map-fetch"
)
: undefined;
fetched.error.partialObservation = {
kind: "web-bundle-acquisition",
inspection: captured.inspection,
source_maps: sourceMapsWithCaptureExclusions(
partial.source_maps,
captured.inspection.completeness.excluded,
),
analysis_status: "not_performed",
};
else fetched.error.retainPartialObservation(captured.inspection);
return err(fetched.error);
}
sourceMaps = sourceMapsWithCaptureExclusions(
fetched.value,
captured.inspection.completeness.excluded,
);
}
return await this.#bundleAnalyzer.analyze(
captured.inspection,
sourceMaps ?? {
@@ -223,8 +245,7 @@ export class CdpBrowserProvider implements BrowserObservationPort {
options.signal,
);
} catch (cause: unknown) {
// fetchWebSourceMaps rethrows the exact abort reason. Preserve cancellation
// even when a caller supplied an AnalysisError as that reason.
// Capture and analyzer work can still throw the exact caller reason.
if (options.signal?.aborted === true && cause === options.signal.reason) {
return err(
new AnalysisCancelledError("analyze_web_bundle", {
+12 -7
View File
@@ -4,6 +4,7 @@ import { expect, it } from "vitest";
import { fetchWebSourceMaps } from "./WebSourceMapFetcher.js";
import { analyzeWebBundleInputSchema } from "../domain/webBundleAnalysis.js";
import { fetchWebSourceMapsOk } from "../../tests/fixtures/fetchWebSourceMapsOk.js";
const input = analyzeWebBundleInputSchema.parse({
cdp_endpoint: "http://127.0.0.1:9222",
@@ -18,7 +19,7 @@ const request = {
};
it("reports an empty selected request set without starting fetch work", async () => {
const result = await fetchWebSourceMaps([], input, undefined, {
const result = await fetchWebSourceMapsOk([], input, undefined, {
fetch: async () => {
throw new Error("No source-map request should be made");
},
@@ -37,9 +38,9 @@ it("honors cancellation before returning an empty request set", async () => {
const reason = new Error("cancelled before fetching");
controller.abort(reason);
await expect(fetchWebSourceMaps([], input, controller.signal)).rejects.toBe(
reason,
);
const result = await fetchWebSourceMaps([], input, controller.signal);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error.cause).toBe(reason);
});
it("cancels a response body that arrives after its fetch host aborts synchronously", async () => {
@@ -73,7 +74,9 @@ it("cancels a response body that arrives after its fetch host aborts synchronous
},
});
await expect(pending).rejects.toBe(reason);
const result = await pending;
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error.cause).toBe(reason);
resolveFetch(new Response(body));
await cancelled;
expect(cancellationReason).toBe(reason);
@@ -106,7 +109,9 @@ it("observes a fetch rejection that arrives after its host aborts synchronously"
process.on("unhandledRejection", onUnhandled);
try {
await expect(pending).rejects.toBe(reason);
const result = await pending;
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error.cause).toBe(reason);
rejectFetch(lateFailure);
await new Promise<void>((resolve) => setImmediate(resolve));
expect(unhandled).toBeUndefined();
@@ -149,7 +154,7 @@ it.each([
declaredUrl: "https://app.example.test/maps/app.js.map",
fetchUrl: "https://app.example.test/maps/app.js.map",
};
const result = await fetchWebSourceMaps(
const result = await fetchWebSourceMapsOk(
[selectedRequest],
fetchInput,
undefined,
+196 -73
View File
@@ -1,9 +1,17 @@
import * as t from "@babel/types";
import { createHash } from "node:crypto";
import { decodeValidatedSourceMapLeaves } from "../javascript/sourceMaps/DecodedSourceMap.js";
import { sanitizeBrowserUrl } from "../domain/browserObservation.js";
import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js";
import type { AnalysisErrorOptions } from "../domain/analysisErrorBase.js";
import {
AnalysisCancelledError,
AnalysisResourceConstraintError,
} from "../domain/analysisErrorCore.js";
import {
AnalysisError,
type AnalysisErrorOptions,
} from "../domain/analysisErrorBase.js";
import { err, ok, type Result } from "../domain/result.js";
import { isUrlLikeModuleSpecifier } from "../domain/webBundleAnalyzerAst.js";
import { analyzeParsedJavaScriptReferences } from "../domain/javascript/javascriptSemanticAnalysis.js";
import { traverseJavaScriptAst } from "../domain/javascript/javascriptSemanticTraversal.js";
@@ -20,7 +28,10 @@ import type {
WebSourceMaps,
} from "../domain/webBundleAnalysis.js";
import { webSourceMapsSchema } from "../domain/webBundleAnalysis.js";
import { createWebTextArtifact } from "../domain/webContentArtifact.js";
import {
createWebTextArtifact,
type WebTextArtifact,
} from "../domain/webContentArtifact.js";
import { safeParseJson } from "../domain/safeJson.js";
import { sourceMapJsonText } from "../domain/sourceMapEnvelope.js";
import { resolveJavaScriptSourceMapUrl } from "../domain/javascript/javascriptSourceMapPaths.js";
@@ -68,6 +79,12 @@ type ParsedSourceMapItem = Extract<
WebSourceMapItem,
{ status: "included" | "partial" }
>;
type ResponseIdentity = {
readonly sha256: string;
readonly bytes: number;
readonly representation: "fetch-response-body-bytes";
};
type FailedSourceMapStatus = "fetch_failed" | "invalid" | "policy_filtered";
interface SourceMapDecodeContext {
readonly signal: AbortSignal | undefined;
readonly deadlineAt: number;
@@ -85,15 +102,22 @@ export const fetchWebSourceMaps = async (
input: AnalyzeWebBundleInput,
signal?: AbortSignal,
host: SourceMapFetchHost = { fetch: globalThis.fetch },
): Promise<WebSourceMaps> => {
throwIfAborted(signal);
): Promise<Result<WebSourceMaps, AnalysisError>> => {
if (signalIsAborted(signal))
return err(
new AnalysisCancelledError("analyze_web_bundle", {
cause: signal?.reason,
}),
);
if (requests.length === 0)
return webSourceMapsSchema.parse({
status: "not_requested",
requested: 0,
processed: 0,
items: [],
});
return ok(
webSourceMapsSchema.parse({
status: "not_requested",
requested: 0,
processed: 0,
items: [],
}),
);
const items: WebSourceMapItem[] = [];
const operationController = new AbortController();
const fetchTimeoutMs =
@@ -126,6 +150,33 @@ export const fetchWebSourceMaps = async (
},
};
let limitation: string | undefined;
const completedMaps = (): WebSourceMaps => {
const unprocessed = requests.length - items.length;
const stoppedLimitation =
unprocessed === 0
? undefined
: `Source-map acquisition stopped with ${String(unprocessed)} requested map${unprocessed === 1 ? "" : "s"} unprocessed; their results are unknown.`;
const retainedLimitation = [limitation, stoppedLimitation]
.filter((value) => value !== undefined)
.join(" ");
return webSourceMapsSchema.parse({
status: sourceMapsStatus(
items,
unprocessed > 0 || limitation !== undefined,
),
requested: requests.length,
processed: items.length,
items,
...(retainedLimitation.length === 0
? {}
: { limitation: retainedLimitation }),
});
};
const retainCompleted = (error: AnalysisError): AnalysisError =>
error.retainPartialObservation({
kind: "web-source-map-fetch",
source_maps: completedMaps(),
});
try {
for (const request of requests) {
if (signal?.aborted === true) throw signal.reason;
@@ -152,14 +203,8 @@ export const fetchWebSourceMaps = async (
host,
budget,
);
if (item.artifact === null) {
const failureBytes =
sourceMapFailureReservation(
request,
item.effective_url ?? null,
item.limitation,
item.status,
) + 1;
if (item.status !== "included" && item.status !== "partial") {
const failureBytes = jsonByteLength(item) + 1;
if (failureBytes > SOURCE_MAP_OUTPUT_BYTES - expandedBytesBeforeItem) {
limitation = sourceMapOutputLimitation(
requests.length - items.length,
@@ -172,7 +217,23 @@ export const fetchWebSourceMaps = async (
items.push(item);
}
} catch (cause: unknown) {
if (signal?.aborted === true) throw cause;
if (signal?.aborted === true && cause === signal.reason)
return err(
retainCompleted(
new AnalysisCancelledError("analyze_web_bundle", {
cause: signal.reason,
}),
),
);
if (cause instanceof AnalysisError) return err(retainCompleted(cause));
if (signal?.aborted === true)
return err(
retainCompleted(
new AnalysisCancelledError("analyze_web_bundle", {
cause: signal.reason,
}),
),
);
if (cause instanceof SourceMapFormatFailure && cause.reason === "limit")
limitation = sourceMapOutputLimitation(requests.length - items.length);
else throw cause;
@@ -180,18 +241,23 @@ export const fetchWebSourceMaps = async (
clearTimeout(timeout);
signal?.removeEventListener("abort", abortFromCaller);
}
if (signal?.aborted === true) throw signal.reason;
return webSourceMapsSchema.parse({
status: sourceMapsStatus(items, limitation !== undefined),
requested: requests.length,
processed: items.length,
items,
...(limitation === undefined ? {} : { limitation }),
});
};
const throwIfAborted = (signal?: AbortSignal): void => {
if (signal?.aborted === true) throw signal.reason;
if (signal?.aborted === true)
return err(
retainCompleted(
new AnalysisCancelledError("analyze_web_bundle", {
cause: signal.reason,
}),
),
);
return ok(
webSourceMapsSchema.parse({
status: sourceMapsStatus(items, limitation !== undefined),
requested: requests.length,
processed: items.length,
items,
...(limitation === undefined ? {} : { limitation }),
}),
);
};
const sourceMapOutputLimitation = (unprocessed: number): string =>
@@ -229,7 +295,7 @@ class SourceMapSizeLimitError extends Error {
}
class SourceMapEncodingError extends Error {
constructor() {
constructor(readonly responseIdentity: ResponseIdentity) {
super("Source-map response is not valid UTF-8.");
this.name = "SourceMapEncodingError";
}
@@ -267,6 +333,7 @@ const fetchOne = async (
"Declared source-map URL is outside the approved exact origins.",
);
let effectiveUrl: string | null = null;
let completedResponseIdentity: ResponseIdentity | undefined;
try {
const fetched = await fetchFollowingApprovedRedirects(
request.fetchUrl,
@@ -326,14 +393,17 @@ const fetchOne = async (
budget.deadlineAt,
budget.deadlineLimitation,
);
const body = await readBoundedText(
response,
budget,
host.maxResponseBytes ?? SOURCE_MAP_RESPONSE_BYTES,
signal,
);
completedResponseIdentity = body.responseIdentity;
return normalizeSourceMap(
request,
await readBoundedText(
response,
budget,
host.maxResponseBytes ?? SOURCE_MAP_RESPONSE_BYTES,
signal,
),
body.text,
body.responseIdentity,
fetchedUrl,
{
signal,
@@ -357,6 +427,7 @@ const fetchOne = async (
"invalid",
cause.message,
effectiveUrl,
cause.responseIdentity,
);
return emptySourceMapItem(
request,
@@ -367,6 +438,7 @@ const fetchOne = async (
? cause.message
: "Source-map fetch or validation failed.",
effectiveUrl,
completedResponseIdentity,
);
}
};
@@ -438,8 +510,16 @@ const readBoundedText = async (
budget: { retainedBytes: number },
maxBytes: number,
signal: AbortSignal | undefined,
): Promise<string> => {
if (response.body === null) return "";
): Promise<{ text: string; responseIdentity: ResponseIdentity }> => {
if (response.body === null)
return {
text: "",
responseIdentity: {
sha256: createHash("sha256").digest("hex"),
bytes: 0,
representation: "fetch-response-body-bytes",
},
};
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let bytes = 0;
@@ -468,12 +548,20 @@ const readBoundedText = async (
joined.set(chunk, offset);
offset += chunk.byteLength;
}
const responseIdentity: ResponseIdentity = {
sha256: createHash("sha256").update(joined).digest("hex"),
bytes,
representation: "fetch-response-body-bytes",
};
try {
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(
joined,
);
return {
text: new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(
joined,
),
responseIdentity,
};
} catch {
throw new SourceMapEncodingError();
throw new SourceMapEncodingError(responseIdentity);
}
};
@@ -559,9 +647,28 @@ const promiseWithAbort = (
const normalizeSourceMap = (
request: WebSourceMapRequest,
text: string,
responseIdentity: ResponseIdentity,
fetchedUrl: string,
context: SourceMapDecodeContext,
): WebSourceMapItem => {
const retainedBytesBeforeMap = context.budget.expandedBytes;
const textBytes =
jsonByteLength(text) +
artifactMetadataBytes("application/source-map+json", text) -
4;
const successBytes =
retainedBytesBeforeMap -
SOURCE_MAP_FAILURE_ENVELOPE_BYTES +
SOURCE_MAP_SUCCESS_ENVELOPE_BYTES +
textBytes;
if (successBytes > SOURCE_MAP_OUTPUT_BYTES)
return emptySourceMapItem(
request,
"fetch_failed",
"Source-map response text exceeds the 32 MiB source-map output budget; JSON parsing and source-map decoding were not attempted.",
fetchedUrl,
responseIdentity,
);
const parsedJson = safeParseJson(sourceMapJsonText(text));
if (!parsedJson.ok)
return emptySourceMapItem(
@@ -569,8 +676,9 @@ const normalizeSourceMap = (
"invalid",
"Source-map JSON is not a version 3 map.",
fetchedUrl,
responseIdentity,
);
const retainedBytesBeforeMap = context.budget.expandedBytes;
context.budget.expandedBytes = successBytes;
try {
const check = (): void =>
checkOperation(
@@ -584,16 +692,6 @@ const normalizeSourceMap = (
budget: context.budget,
check,
});
context.budget.expandedBytes =
retainedBytesBeforeMap -
SOURCE_MAP_FAILURE_ENVELOPE_BYTES +
SOURCE_MAP_SUCCESS_ENVELOPE_BYTES;
consumeExpandedBytes(
context.budget,
jsonByteLength(text) +
artifactMetadataBytes("application/source-map+json", text) -
4,
);
const sourcesByRaw = new Map<string, string>();
const resolvedByRoot = prepareResolvedSources(
inspected.leaves,
@@ -710,7 +808,7 @@ const normalizeSourceMap = (
);
const parsed = {
...sourceMapContext(request, fetchedUrl),
artifact: createWebTextArtifact(text, "application/source-map+json"),
artifact: sourceMapTextArtifact(text, responseIdentity),
original_sources: originalSources,
original_module_edges: modules.edges,
mappings,
@@ -724,16 +822,18 @@ const normalizeSourceMap = (
limitation: `Module edges are incomplete: ${modules.incomplete.length} of ${originalSources.filter(({ artifact }) => artifact !== null).length} original sources could not be parsed in full (${modules.incomplete.join(", ")}).`,
};
} catch (cause: unknown) {
// Failed maps publish no artifact or mapping rows. Release their output
// reservation while keeping cumulative fetch and decoder-work accounting.
// Failed interpretations retain completed body identity without consuming
// the collection budget needed for later valid maps.
context.budget.expandedBytes = retainedBytesBeforeMap;
if (cause instanceof SourceMapFormatFailure && cause.reason === "limit")
if (cause instanceof SourceMapFormatFailure && cause.reason === "limit") {
return emptySourceMapItem(
request,
"fetch_failed",
cause.message,
fetchedUrl,
responseIdentity,
);
}
if (
context.signal?.aborted === true ||
cause instanceof SourceMapDeadlineError
@@ -747,6 +847,7 @@ const normalizeSourceMap = (
? cause.message
: "Source-map JSON mappings could not be decoded safely.",
fetchedUrl,
responseIdentity,
);
}
};
@@ -887,17 +988,42 @@ const sourceMapContext = (
const emptySourceMapItem = (
request: WebSourceMapRequest,
status: Extract<WebSourceMapItem, { readonly artifact: null }>["status"],
status: FailedSourceMapStatus,
limitation: string,
effectiveUrl: string | null = null,
): Extract<WebSourceMapItem, { readonly artifact: null }> => ({
...sourceMapContext(request, effectiveUrl),
status,
artifact: null,
original_sources: [],
original_module_edges: [],
mappings: [],
limitation,
responseIdentity?: ResponseIdentity,
): WebSourceMapItem => {
const base = {
...sourceMapContext(request, effectiveUrl),
artifact: null,
original_sources: [],
original_module_edges: [],
mappings: [],
limitation:
responseIdentity === undefined
? limitation
: `${limitation} Response text was not retained; completed response body identity is available.`,
};
return status === "policy_filtered"
? { ...base, status }
: {
...base,
status,
...(responseIdentity === undefined
? {}
: { response_identity: responseIdentity }),
};
};
const sourceMapTextArtifact = (
text: string,
responseIdentity: ResponseIdentity,
): WebTextArtifact => ({
sha256: responseIdentity.sha256,
bytes: responseIdentity.bytes,
media_type: "application/source-map+json",
charset: "utf-8",
text,
});
const sanitizeSource = (value: string): string => {
@@ -1022,10 +1148,7 @@ const sourceMapFailureReservation = (
request: WebSourceMapRequest,
effectiveUrl: string | null = null,
limitation?: string,
status: Extract<
WebSourceMapItem,
{ readonly artifact: null }
>["status"] = "fetch_failed",
status: FailedSourceMapStatus = "fetch_failed",
): number =>
SOURCE_MAP_FAILURE_ENVELOPE_BYTES +
jsonByteLength(request.declaredUrl) +
+7 -1
View File
@@ -268,7 +268,11 @@ import type {
RecordedCrashPartialObservation,
} from "./native/recordedCrash.js";
import type { WebPageInspection } from "./browserObservationSchemas.js";
import type { WebBundleAnalysis } from "./webBundleAnalysis.js";
import type {
WebBundleAcquisitionPartialObservation,
WebBundleAnalysis,
WebSourceMapFetchPartialObservation,
} from "./webBundleAnalysis.js";
import type { WebExecutionObservation } from "./webExecution.js";
import type { WebEventListenersObservation } from "./webEventListeners.js";
@@ -298,6 +302,8 @@ export type AnalysisPartialObservation =
| BrowserScenarioPartialObservation
| WebPageInspection
| WebBundleAnalysis
| WebBundleAcquisitionPartialObservation
| WebSourceMapFetchPartialObservation
| WebExecutionObservation
| WebEventListenersObservation
| JavaScriptRuntimeObservation
+38 -1
View File
@@ -1,5 +1,6 @@
import { z } from "zod";
import { browserExclusionSchema } from "./browserCompleteness.js";
import { webPageInspectionSchema } from "./browserObservationSchemas.js";
import { javaScriptRouteInventorySchema } from "./javascript/javascriptRouteInventory.js";
import {
@@ -280,6 +281,11 @@ const parsedSourceMapShape = {
original_module_edges: z.array(originalModuleEdgeSchema),
mappings: z.array(sourceMapMappingSchema),
};
const responseIdentitySchema = z.object({
sha256: z.string().regex(/^[a-f0-9]{64}$/u),
bytes: z.int().min(0),
representation: z.literal("fetch-response-body-bytes"),
});
const sourceMapSchema = z.union([
z.object({
@@ -297,7 +303,18 @@ const sourceMapSchema = z.union([
z.object({
...sourceMapContextShape,
...parsedSourceMapShape,
status: z.enum(["fetch_failed", "invalid", "policy_filtered"]),
status: z.enum(["fetch_failed", "invalid"]),
artifact: z.null(),
/** Older observations omit this; completed bytes need it only when text is absent. */
response_identity: responseIdentitySchema.exactOptional(),
original_sources: emptyArraySchema,
original_module_edges: emptyArraySchema,
mappings: emptyArraySchema,
limitation: z.string(),
}),
z.object({
...sourceMapContextShape,
status: z.literal("policy_filtered"),
artifact: z.null(),
original_sources: emptyArraySchema,
original_module_edges: emptyArraySchema,
@@ -373,6 +390,26 @@ export const webSourceMapsSchema = z
export type WebSourceMapItem = z.infer<typeof sourceMapSchema>;
export type WebSourceMaps = z.infer<typeof webSourceMapsSchema>;
/** Completed fetch facts retained when source-map acquisition stops early. */
export const webSourceMapFetchPartialObservationSchema = z.object({
kind: z.literal("web-source-map-fetch"),
source_maps: webSourceMapsSchema,
});
export type WebSourceMapFetchPartialObservation = z.infer<
typeof webSourceMapFetchPartialObservationSchema
>;
/** Captured page and completed maps before bundle analysis has begun. */
export const webBundleAcquisitionPartialObservationSchema = z.object({
kind: z.literal("web-bundle-acquisition"),
inspection: webPageInspectionSchema,
source_maps: webSourceMapsSchema,
analysis_status: z.literal("not_performed"),
});
export type WebBundleAcquisitionPartialObservation = z.infer<
typeof webBundleAcquisitionPartialObservationSchema
>;
/** Provider-neutral result of JavaScript bundle reverse engineering. */
export const webBundleAnalysisSchema = z.object({
route_inventory: javaScriptRouteInventorySchema.exactOptional(),
@@ -0,0 +1,112 @@
import { createHash } from "node:crypto";
import { resolve } from "node:path";
import { expect } from "vitest";
import { analysisErrorProjectionSchema } from "../../../src/contracts/errorSchemas.js";
import { parseEvidence } from "../../../src/domain/evidence.js";
import { webBundleAcquisitionPartialObservationSchema } from "../../../src/domain/webBundleAnalysis.js";
import { startSourceMapCancellationBrowser } from "../../fixtures/browserSourceMapCancellation.js";
import { processTest } from "../../support/process/processFixture.js";
// The fake CDP endpoint and HTTP responses exercise the compiled public CLI.
// No Chromium process or real external source-map server is involved.
processTest.skipIf(process.platform === "win32")(
"retains a completed source map and capture exclusions when the next fetch is cancelled",
async ({ processes }) => {
const { browser, mapText, completedMapPath, secondRequest, close } =
await startSourceMapCancellationBrowser();
const child = processes.spawn(
process.execPath,
[
resolve("scripts/rea.mjs"),
"analyze-web-bundle",
browser.endpoint,
"allowed-page",
"--allowed-origins",
browser.allowedOrigin,
"--observation-ms",
"0",
"--fetch-source-maps",
"--json",
],
{ env: { PATH: process.env.PATH ?? "" } },
);
child.stdin.end();
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk: Buffer) => (stdout += chunk.toString()));
child.stderr.on("data", (chunk: Buffer) => (stderr += chunk.toString()));
const exited = new Promise<{
readonly code: number | null;
readonly signal: NodeJS.Signals | null;
}>((resolveExit, reject) => {
child.once("error", reject);
child.once("close", (code, signal) => resolveExit({ code, signal }));
});
const deadline = setTimeout(() => child.kill("SIGKILL"), 20_000);
try {
const pendingMapPath = await Promise.race([
secondRequest,
exited.then(({ code, signal }) => {
throw new Error(
`CLI exited before second map request (${String(code)}, ${String(signal)}): ${stderr}`,
);
}),
]);
const retainedMapPath = await completedMapPath;
expect([retainedMapPath, pendingMapPath].sort()).toEqual([
"/first.js.map",
"/second.js.map",
]);
expect(child.kill("SIGINT")).toBe(true);
expect(await exited, stderr).toEqual({ code: 130, signal: null });
const failure = analysisErrorProjectionSchema.parse(JSON.parse(stdout));
expect(failure.code).toBe("cancelled");
const partial = parseEvidence(failure.details?.partial_observation);
expect(partial.operation).toBe("analyze_web_bundle");
const acquisition = webBundleAcquisitionPartialObservationSchema.parse(
partial.normalized_result,
);
expect(acquisition.analysis_status).toBe("not_performed");
expect(
acquisition.source_maps,
JSON.stringify(acquisition.source_maps),
).toMatchObject({
status: "partial",
requested: 2,
processed: 1,
excluded: [
{ section: "source_maps", reason: "disallowed_origin", count: 1 },
],
items: [
{
status: "included",
declared_url: `${browser.allowedOrigin}${retainedMapPath}`,
artifact: {
text: mapText,
sha256: createHash("sha256").update(mapText).digest("hex"),
},
},
],
});
expect(acquisition.source_maps.items).toHaveLength(1);
expect(acquisition.inspection.completeness.excluded).toContainEqual({
section: "source_maps",
reason: "disallowed_origin",
count: 1,
});
expect(JSON.stringify(partial)).not.toContain("outside.example.test");
expect(browser.activeConnectionCount()).toBe(0);
expect(
browser.httpRequests
.filter(({ url }) => url.endsWith(".map"))
.map(({ url }) => url),
).toEqual([retainedMapPath, pendingMapPath]);
} finally {
clearTimeout(deadline);
child.kill("SIGTERM");
await close();
}
},
);
@@ -3,7 +3,7 @@ import { createServer } from "node:http";
import { expect, it } from "vitest";
import { fetchWebSourceMaps } from "../../../src/browser/WebSourceMapFetcher.js";
import { fetchWebSourceMapsOk } from "../../fixtures/fetchWebSourceMapsOk.js";
import { analyzeWebBundleInputSchema } from "../../../src/domain/webBundleAnalysis.js";
it("retains unshadowed optional require dependencies from HTTP source-map originals", async () => {
@@ -31,7 +31,7 @@ it("retains unshadowed optional require dependencies from HTTP source-map origin
throw new TypeError("Expected a TCP listener address");
const origin = `http://127.0.0.1:${String(address.port)}`;
const url = `${origin}/app.js.map`;
const result = await fetchWebSourceMaps(
const result = await fetchWebSourceMapsOk(
[{ scriptKey: `scr_${"1".repeat(64)}`, declaredUrl: url, fetchUrl: url }],
analyzeWebBundleInputSchema.parse({
cdp_endpoint: origin,
@@ -4,6 +4,8 @@ import { createServer } from "node:http";
import { describe, expect, it } from "vitest";
import { fetchWebSourceMaps } from "../../../src/browser/WebSourceMapFetcher.js";
import { fetchWebSourceMapsOk } from "../../fixtures/fetchWebSourceMapsOk.js";
import { AnalysisCancelledError } from "../../../src/domain/analysisErrorCore.js";
import {
analyzeWebBundleInputSchema,
webSourceMapsSchema,
@@ -68,7 +70,7 @@ it.each(["regular", "indexed"])(
if (address === null || typeof address === "string")
throw new TypeError("Expected a TCP listener address");
const localOrigin = `http://127.0.0.1:${String(address.port)}`;
const result = await fetchWebSourceMaps(
const result = await fetchWebSourceMapsOk(
texts.map((_, index) => ({
...request,
fetchUrl: `${localOrigin}/${index}`,
@@ -159,7 +161,7 @@ it("parses ECMA-426 HTTP source-map prefixes while retaining exact response evid
if (address === null || typeof address === "string")
throw new TypeError("Expected a TCP listener address");
const localOrigin = `http://127.0.0.1:${String(address.port)}`;
const result = await fetchWebSourceMaps(
const result = await fetchWebSourceMapsOk(
texts.map((_, index) => ({
...request,
fetchUrl: `${localOrigin}/${index}`,
@@ -249,7 +251,7 @@ describe("source-map redirect URL resolution", () => {
throw new TypeError("Expected a TCP listener address");
const localOrigin = `http://127.0.0.1:${String(address.port)}`;
const declaredUrl = `${localOrigin}${initialPath}`;
const result = await fetchWebSourceMaps(
const result = await fetchWebSourceMapsOk(
[{ ...request, fetchUrl: declaredUrl, declaredUrl }],
input({ allowed_origins: [localOrigin] }),
);
@@ -302,7 +304,7 @@ describe("source-map redirect URL resolution", () => {
async (responseStatus, body, itemStatus) => {
const effectiveUrl = `${origin}${deliveredPath}`;
const calls: string[] = [];
const result = await fetchWebSourceMaps([request], input(), undefined, {
const result = await fetchWebSourceMapsOk([request], input(), undefined, {
fetch: (url) => {
calls.push(String(url));
return Promise.resolve(
@@ -321,6 +323,10 @@ describe("source-map redirect URL resolution", () => {
declared_url: request.declaredUrl,
effective_url: effectiveUrl,
artifact: null,
limitation:
responseStatus === 200
? "Source-map JSON is not a version 3 map. Response text was not retained; completed response body identity is available."
: "Source-map server returned HTTP 404.",
});
},
);
@@ -328,7 +334,7 @@ describe("source-map redirect URL resolution", () => {
it("retains the last observed response URL when the next redirect fetch rejects", async () => {
const nextUrl = `${origin}${deliveredPath}`;
const calls: string[] = [];
const result = await fetchWebSourceMaps([request], input(), undefined, {
const result = await fetchWebSourceMapsOk([request], input(), undefined, {
fetch: (url) => {
calls.push(String(url));
return calls.length === 1
@@ -351,7 +357,7 @@ describe("source-map redirect URL resolution", () => {
});
it("reports an absent effective URL when the first fetch rejects", async () => {
const result = await fetchWebSourceMaps([request], input(), undefined, {
const result = await fetchWebSourceMapsOk([request], input(), undefined, {
fetch: () => Promise.reject(new TypeError("fixture connection failed")),
});
expect(result.items[0]).toMatchObject({
@@ -374,7 +380,7 @@ describe("web source-map fetching and validation: fetching maps and following re
sourcesContent: ["import './dependency.ts';\nexport const entry = 1;"],
mappings: "AAAAA",
});
const result = await fetchWebSourceMaps([request], input(), undefined, {
const result = await fetchWebSourceMapsOk([request], input(), undefined, {
fetch: (url, init) => {
calls.push({ url: String(url), init });
return Promise.resolve(
@@ -433,7 +439,7 @@ describe("web source-map fetching and validation: fetching maps and following re
it("reauthorizes every redirect and never contacts a disallowed origin", async () => {
const calls: string[] = [];
const result = await fetchWebSourceMaps([request], input(), undefined, {
const result = await fetchWebSourceMapsOk([request], input(), undefined, {
fetch: (url) => {
calls.push(String(url));
return Promise.resolve(
@@ -467,7 +473,7 @@ describe("web source-map fetching and validation: fetching maps and following re
"filters a declared fetch URL with %s before any request",
async (_description, fetchUrl) => {
const calls: string[] = [];
const result = await fetchWebSourceMaps(
const result = await fetchWebSourceMapsOk(
[{ ...request, fetchUrl }],
input(),
undefined,
@@ -489,7 +495,7 @@ describe("web source-map fetching and validation: fetching maps and following re
it("follows an approved redirect chain without an arbitrary hop ceiling", async () => {
const calls: string[] = [];
const result = await fetchWebSourceMaps([request], input(), undefined, {
const result = await fetchWebSourceMapsOk([request], input(), undefined, {
fetch: (url) => {
const current = String(url);
calls.push(current);
@@ -534,7 +540,7 @@ describe("web source-map fetching and validation: enforcing fetch limits and val
throw new TypeError("Expected a TCP listener address");
const localOrigin = `http://127.0.0.1:${String(address.port)}`;
const url = `${localOrigin}/map?n=0`;
const result = await fetchWebSourceMaps(
const result = await fetchWebSourceMapsOk(
[{ ...request, fetchUrl: url, declaredUrl: url }],
input({ allowed_origins: [localOrigin] }),
undefined,
@@ -589,7 +595,7 @@ describe("web source-map fetching and validation: enforcing fetch limits and val
throw new TypeError("Expected a TCP listener address");
const localOrigin = `http://127.0.0.1:${String(address.port)}`;
const url = `${localOrigin}/map`;
const result = await fetchWebSourceMaps(
const result = await fetchWebSourceMapsOk(
[{ ...request, fetchUrl: url, declaredUrl: url }],
input({ allowed_origins: [localOrigin] }),
undefined,
@@ -655,12 +661,101 @@ describe("web source-map fetching and validation: enforcing fetch limits and val
await pullStarted;
controller.abort(abortReason);
await expect(pending).rejects.toBe(abortReason);
const result = await pending;
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error.cause).toBe(abortReason);
await cancelled;
});
});
describe("completed source-map cancellation", () => {
it.each([null, { stopped: "caller" }, new AnalysisCancelledError("test")])(
"retains a completed map when a later response is cancelled with reason %s",
async (reason) => {
let markPullStarted: (() => void) | undefined;
const pullStarted = new Promise<void>((resolve) => {
markPullStarted = resolve;
});
let markCancelled: (() => void) | undefined;
const cancelled = new Promise<void>((resolve) => {
markCancelled = resolve;
});
const secondBody = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode("{"));
},
pull() {
markPullStarted?.();
},
cancel() {
markCancelled?.();
},
});
const firstBody = JSON.stringify({
version: 3,
sources: ["src/first.ts"],
names: [],
mappings: "AAAA",
});
const first = {
...request,
declaredUrl: `${origin}/first.map`,
fetchUrl: `${origin}/first.map`,
};
const second = {
...request,
declaredUrl: `${origin}/second.map`,
fetchUrl: `${origin}/second.map`,
};
let fetches = 0;
const controller = new AbortController();
const pending = fetchWebSourceMaps(
[first, second],
input(),
controller.signal,
{
fetch: async () => {
fetches += 1;
return fetches === 1
? new Response(firstBody, { status: 200 })
: new Response(secondBody, { status: 200 });
},
},
);
await pullStarted;
controller.abort(reason);
const result = await pending;
expect(result.ok).toBe(false);
if (result.ok) return;
const failure = result.error;
expect(failure).toBeInstanceOf(AnalysisCancelledError);
expect(failure.cause).toBe(reason);
expect(failure.partialObservation).toMatchObject({
kind: "web-source-map-fetch",
source_maps: {
requested: 2,
processed: 1,
items: [
{
status: "included",
artifact: {
text: firstBody,
sha256: createHash("sha256").update(firstBody).digest("hex"),
},
},
],
},
});
await cancelled;
},
);
});
describe("source-map response validation", () => {
it("reports malformed source-map JSON as invalid", async () => {
const result = await fetchWebSourceMaps([request], input(), undefined, {
const result = await fetchWebSourceMapsOk([request], input(), undefined, {
fetch: () => Promise.resolve(new Response("not-json", { status: 200 })),
});
expect(result.items[0]?.status).toBe("invalid");
@@ -693,7 +788,7 @@ describe("selected source-map fetch deadline", () => {
throw new TypeError("Expected a TCP listener address");
const localOrigin = `http://127.0.0.1:${String(address.port)}`;
const url = `${localOrigin}/map?n=0`;
const result = await fetchWebSourceMaps(
const result = await fetchWebSourceMapsOk(
[{ ...request, fetchUrl: url, declaredUrl: url }],
input({
source_map_fetch_timeout_ms: 150,
@@ -749,7 +844,7 @@ describe("browser map decoded validation", () => {
...request,
scriptKey: `scr_${String(requestIndex + 1).padStart(64, "0")}`,
}));
const result = await fetchWebSourceMaps(requests, input(), undefined, {
const result = await fetchWebSourceMapsOk(requests, input(), undefined, {
fetch: () =>
Promise.resolve(
new Response(JSON.stringify(maps[index++]), { status: 200 }),
@@ -773,7 +868,7 @@ describe("browser map decoded validation", () => {
describe("source-map response encoding", () => {
it("rejects malformed UTF-8 source-map response bytes", async () => {
const result = await fetchWebSourceMaps([request], input(), undefined, {
const result = await fetchWebSourceMapsOk([request], input(), undefined, {
fetch: () =>
Promise.resolve(
new Response(Uint8Array.of(0x7b, 0x22, 0xc3, 0x28, 0x22, 0x7d), {
@@ -786,7 +881,8 @@ describe("source-map response encoding", () => {
expect(result.items[0]).toMatchObject({
status: "invalid",
artifact: null,
limitation: "Source-map response is not valid UTF-8.",
limitation:
"Source-map response is not valid UTF-8. Response text was not retained; completed response body identity is available.",
});
});
});
@@ -798,7 +894,7 @@ describe("source-map operation deadline observations", () => {
request,
{ ...request, scriptKey: `scr_${"2".repeat(64)}` },
];
const result = await fetchWebSourceMaps(requests, input(), undefined, {
const result = await fetchWebSourceMapsOk(requests, input(), undefined, {
timeoutMs: 100,
fetch: () => {
calls += 1;
@@ -828,7 +924,7 @@ describe("indexed source-map offsets", () => {
{ line: 0 },
{ line: 0, column: Number.MAX_SAFE_INTEGER + 1 },
])("retains an invalid-map item for malformed offset %j", async (offset) => {
const result = await fetchWebSourceMaps([request], input(), undefined, {
const result = await fetchWebSourceMapsOk([request], input(), undefined, {
fetch: () =>
Promise.resolve(
new Response(
@@ -863,7 +959,7 @@ describe("web source-map sourcesContent validation", () => {
["a scalar sourcesContent value", { sourcesContent: "source" }],
["a non-string, non-null source entry", { sourcesContent: [42] }],
])("reports %s as invalid", async (_description, content) => {
const result = await fetchWebSourceMaps([request], input(), undefined, {
const result = await fetchWebSourceMapsOk([request], input(), undefined, {
fetch: () =>
Promise.resolve(
new Response(
@@ -898,7 +994,7 @@ describe("web source-map sourcesContent validation", () => {
])(
"keeps every source declaration when sourcesContent is %s than sources",
async (_length, sourcesContent) => {
const result = await fetchWebSourceMaps([request], input(), undefined, {
const result = await fetchWebSourceMapsOk([request], input(), undefined, {
fetch: () =>
Promise.resolve(
new Response(
@@ -937,7 +1033,7 @@ describe("web source-map sourcesContent validation", () => {
);
it("accepts null source-map source contents", async () => {
const result = await fetchWebSourceMaps([request], input(), undefined, {
const result = await fetchWebSourceMapsOk([request], input(), undefined, {
fetch: () =>
Promise.resolve(
new Response(
@@ -967,7 +1063,7 @@ describe("web source-map sourcesContent validation", () => {
});
it("keeps unnamed source slots and mapping indices", async () => {
const result = await fetchWebSourceMaps([request], input(), undefined, {
const result = await fetchWebSourceMapsOk([request], input(), undefined, {
fetch: () =>
Promise.resolve(
new Response(
@@ -1022,7 +1118,7 @@ describe("source-map original dependency syntax", () => {
'import("./dynamic.js");',
'require("./common.cjs");',
].join("\n");
const result = await fetchWebSourceMaps([request], input(), undefined, {
const result = await fetchWebSourceMapsOk([request], input(), undefined, {
fetch: () =>
Promise.resolve(
new Response(
@@ -1048,7 +1144,7 @@ describe("source-map original dependency syntax", () => {
"require(`./common.cjs`);",
"require(`./${name}.cjs`);",
].join("\n");
const result = await fetchWebSourceMaps([request], input(), undefined, {
const result = await fetchWebSourceMapsOk([request], input(), undefined, {
fetch: () =>
Promise.resolve(
new Response(
@@ -1095,7 +1191,7 @@ describe("source-map original syntax boundaries", () => {
] as const)(
"retains literal syntax without inventing module calls",
async (text, status, expected) => {
const result = await fetchWebSourceMaps([request], input(), undefined, {
const result = await fetchWebSourceMapsOk([request], input(), undefined, {
fetch: () =>
Promise.resolve(
new Response(
@@ -1123,7 +1219,7 @@ describe("source-map original syntax boundaries", () => {
describe("source-map dependency coverage of hard-to-parse sources", () => {
const fetchWithSources = (sources: readonly string[]) =>
fetchWebSourceMaps([request], input(), undefined, {
fetchWebSourceMapsOk([request], input(), undefined, {
fetch: () =>
Promise.resolve(
new Response(
@@ -1188,7 +1284,7 @@ describe("source-map dependency coverage of hard-to-parse sources", () => {
});
it("selects parser mode from the original source URL pathname", async () => {
const result = await fetchWebSourceMaps([request], input(), undefined, {
const result = await fetchWebSourceMapsOk([request], input(), undefined, {
fetch: () =>
Promise.resolve(
new Response(
@@ -1242,16 +1338,14 @@ describe("source-map dependency coverage of hard-to-parse sources", () => {
describe("web source-map collection", () => {
it("rejects mapping expansion before the trace-mapping decoder allocates it", async () => {
const response = new Response(
JSON.stringify({
version: 3,
names: [],
sources: [],
mappings: Array.from({ length: 262_145 }, () => "A").join(","),
}),
{ status: 200 },
);
const result = await fetchWebSourceMaps([request], input(), undefined, {
const mapText = JSON.stringify({
version: 3,
names: [],
sources: [],
mappings: Array.from({ length: 262_145 }, () => "A").join(","),
});
const response = new Response(mapText, { status: 200 });
const result = await fetchWebSourceMapsOk([request], input(), undefined, {
fetch: () => Promise.resolve(response),
});
@@ -1274,7 +1368,7 @@ describe("web source-map collection", () => {
request,
{ ...request, scriptKey: `scr_${"2".repeat(64)}` },
];
const result = await fetchWebSourceMaps(requests, input(), undefined, {
const result = await fetchWebSourceMapsOk(requests, input(), undefined, {
fetch: () => Promise.resolve(new Response(mapText, { status: 200 })),
});
@@ -1298,7 +1392,7 @@ describe("web source-map collection", () => {
version: 3,
sections: [{ offset: { line: 0, column: 0 }, map }],
};
const result = await fetchWebSourceMaps([request], input(), undefined, {
const result = await fetchWebSourceMapsOk([request], input(), undefined, {
fetch: () =>
Promise.resolve(new Response(JSON.stringify(map), { status: 200 })),
});
@@ -1320,7 +1414,7 @@ describe("web source-map collection", () => {
sourcesContent: ["export const a = 1"],
mappings: Array.from({ length: segmentCount }, () => "AAAA").join(","),
};
const result = await fetchWebSourceMaps([request], input(), undefined, {
const result = await fetchWebSourceMapsOk([request], input(), undefined, {
fetch: () =>
Promise.resolve(
new Response(
@@ -1345,7 +1439,7 @@ describe("web source-map collection", () => {
scriptKey: `scr_${String(index + 1).padStart(64, "0")}`,
fetchUrl: `${origin}/assets/${String(index)}.js.map`,
}));
const result = await fetchWebSourceMaps(requests, input(), undefined, {
const result = await fetchWebSourceMapsOk(requests, input(), undefined, {
fetch: (url) => {
calls.push(String(url));
return Promise.resolve(validMapResponse());
@@ -1370,7 +1464,7 @@ describe("web source-map collection", () => {
sourcesContent: [content],
mappings: "AAAA",
});
const result = await fetchWebSourceMaps([request], input(), undefined, {
const result = await fetchWebSourceMapsOk([request], input(), undefined, {
fetch: () => Promise.resolve(new Response(map, { status: 200 })),
});
@@ -1394,7 +1488,7 @@ describe("source-map output admission", () => {
const effectiveUrl = `${origin}/delivered.map?selection=${"\\".repeat(selectionMiB * 1_024 * 1_024)}`;
const secondRequest = { ...request, scriptKey: `scr_${"2".repeat(64)}` };
let calls = 0;
const result = await fetchWebSourceMaps(
const result = await fetchWebSourceMapsOk(
[request, secondRequest],
input(),
undefined,
@@ -1455,7 +1549,7 @@ describe("source-map set output admission", () => {
sources: ["x"],
mappings: "AAAA",
});
const result = await fetchWebSourceMaps(
const result = await fetchWebSourceMapsOk(
requests,
input({ allowed_origins: [localOrigin] }),
undefined,
@@ -1517,7 +1611,7 @@ describe("source-map set output admission", () => {
})),
];
let calls = 0;
const result = await fetchWebSourceMaps(requests, input(), undefined, {
const result = await fetchWebSourceMapsOk(requests, input(), undefined, {
fetch: () => {
calls += 1;
return Promise.resolve(
@@ -1559,7 +1653,7 @@ describe("expanded source-map evidence budget", () => {
sources: [source],
mappings: "",
});
const result = await fetchWebSourceMaps([request], input(), undefined, {
const result = await fetchWebSourceMapsOk([request], input(), undefined, {
fetch: () => Promise.resolve(new Response(map, { status: 200 })),
});
expect(result.items[0]?.status).toBe("included");
@@ -1584,7 +1678,7 @@ describe("expanded source-map evidence budget", () => {
mappings: "AAAA",
});
const responses = [malformed, malformed, valid];
const result = await fetchWebSourceMaps(
const result = await fetchWebSourceMapsOk(
responses.map((_, index) => ({
...request,
scriptKey: `scr_${String(index + 1).padStart(64, "0")}`,
@@ -1616,7 +1710,7 @@ describe("expanded source-map evidence budget", () => {
...request,
scriptKey: `scr_${String(index + 1).padStart(64, "0")}`,
}));
const result = await fetchWebSourceMaps(requests, input(), undefined, {
const result = await fetchWebSourceMapsOk(requests, input(), undefined, {
fetch: () =>
Promise.resolve(new Response(responses.shift() ?? "", { status: 200 })),
});
@@ -1641,7 +1735,7 @@ describe("expanded source-map evidence budget", () => {
sources: ["main.ts"],
mappings: "",
});
const result = await fetchWebSourceMaps([request], input(), undefined, {
const result = await fetchWebSourceMapsOk([request], input(), undefined, {
fetch: () => Promise.resolve(new Response(map, { status: 200 })),
});
@@ -1659,7 +1753,7 @@ describe("expanded source-map evidence budget", () => {
sources: ["main.ts"],
mappings: "AAAA,AAAA,AAAA",
});
const result = await fetchWebSourceMaps([request], input(), undefined, {
const result = await fetchWebSourceMapsOk([request], input(), undefined, {
fetch: () => Promise.resolve(new Response(map, { status: 200 })),
});
@@ -1682,7 +1776,7 @@ describe("expanded source-map evidence budget", () => {
sources: Array.from({ length: 65 }, (_, index) => `source-${index}.ts`),
mappings: "",
});
const result = await fetchWebSourceMaps([request], input(), undefined, {
const result = await fetchWebSourceMapsOk([request], input(), undefined, {
fetch: () => Promise.resolve(new Response(map, { status: 200 })),
});
@@ -1699,18 +1793,16 @@ describe("expanded source-map evidence budget", () => {
describe("raw source-map structure preflight", () => {
it("budgets source inventory before schema parsing its entries", async () => {
const response = new Response(
JSON.stringify({
version: 3,
names: [],
sources: Array.from({ length: 262_145 }, (_, index) =>
index === 0 ? 42 : "source.js",
),
mappings: "",
}),
{ status: 200 },
);
const result = await fetchWebSourceMaps([request], input(), undefined, {
const mapText = JSON.stringify({
version: 3,
names: [],
sources: Array.from({ length: 262_145 }, (_, index) =>
index === 0 ? 42 : "source.js",
),
mappings: "",
});
const response = new Response(mapText, { status: 200 });
const result = await fetchWebSourceMapsOk([request], input(), undefined, {
fetch: () => Promise.resolve(response),
});
@@ -1745,7 +1837,7 @@ const input = (overrides: Record<string, unknown> = {}) => {
};
it("keeps bare original module specifiers unresolved", async () => {
const result = await fetchWebSourceMaps([request], input(), undefined, {
const result = await fetchWebSourceMapsOk([request], input(), undefined, {
fetch: () =>
Promise.resolve(
new Response(
@@ -1817,7 +1909,7 @@ describe("source-map original artifact language metadata", () => {
throw new TypeError("Expected a TCP listener address");
const localOrigin = `http://127.0.0.1:${String(address.port)}`;
const url = `${localOrigin}/main.js.map`;
const result = await fetchWebSourceMaps(
const result = await fetchWebSourceMapsOk(
[{ ...request, fetchUrl: url, declaredUrl: url }],
input({ allowed_origins: [localOrigin] }),
AbortSignal.timeout(2_000),
@@ -1863,7 +1955,7 @@ describe("deep source-map original syntax", () => {
sourcesContent: [source],
mappings: "AAAA",
});
const result = await fetchWebSourceMaps([request], input(), undefined, {
const result = await fetchWebSourceMapsOk([request], input(), undefined, {
fetch: async () => new Response(text),
});
expect(result).toMatchObject({
@@ -4,6 +4,7 @@ import { createServer } from "node:http";
import { expect, it } from "vitest";
import { fetchWebSourceMaps } from "../../../src/browser/WebSourceMapFetcher.js";
import { fetchWebSourceMapsOk } from "../../fixtures/fetchWebSourceMapsOk.js";
import {
analyzeWebBundleInputSchema,
webSourceMapsSchema,
@@ -55,7 +56,7 @@ it.each([
fetch_source_maps: true,
});
const url = `${origin}/${String(status)}`;
const result = await fetchWebSourceMaps(
const result = await fetchWebSourceMapsOk(
[
{
scriptKey: `scr_${"1".repeat(64)}`,
@@ -102,7 +103,7 @@ it("keeps the declared URL and reports malformed redirect Location evidence", as
allowed_origins: ["https://app.example.test"],
fetch_source_maps: true,
});
const result = await fetchWebSourceMaps(
const result = await fetchWebSourceMapsOk(
[
{
scriptKey: `scr_${"1".repeat(64)}`,
@@ -139,7 +140,7 @@ it.each(["invalid JSON", "invalid UTF-8", "HTTP failure", "response limit"])(
const origin = "https://app.example.test";
const declaredUrl = `${origin}/declared.map`;
const fetchedUrl = `${origin}/maps/current.map?token=source-value#fragment`;
const result = await fetchWebSourceMaps(
const result = await fetchWebSourceMapsOk(
[{ scriptKey: "script", declaredUrl, fetchUrl: declaredUrl }],
analyzeWebBundleInputSchema.parse({
cdp_endpoint: "http://127.0.0.1:9222",
@@ -188,16 +189,26 @@ it.each(["invalid JSON", "invalid UTF-8", "HTTP failure", "response limit"])(
);
it.each([
{ cancellationFails: false, reason: null },
{ cancellationFails: true, reason: new Error("fixture cancel failure") },
{ cancellationFails: true, reason: undefined },
{ cancellationFails: false, reason: null, abortDuringCleanup: false },
{
cancellationFails: true,
reason: new Error("fixture cancel failure"),
abortDuringCleanup: false,
},
{ cancellationFails: true, reason: undefined, abortDuringCleanup: false },
{
cancellationFails: true,
reason: new Error("cleanup failed before caller abort"),
abortDuringCleanup: true,
},
])(
"reserves a fetched URL before reading an over-cap response; cancellation failure=$cancellationFails, reason=$reason",
async ({ cancellationFails, reason }) => {
"reserves a fetched URL before reading an over-cap response; cancellation failure=$cancellationFails, caller abort=$abortDuringCleanup",
async ({ cancellationFails, reason, abortDuringCleanup }) => {
const origin = "https://app.example.test";
const fetchedUrl = `${origin}/${"u".repeat(WEB_SOURCE_MAP_LIMITS.outputBytes)}`;
let cancelled = false;
let read = false;
const controller = new AbortController();
const body = new ReadableStream<Uint8Array>(
{
pull(controller) {
@@ -211,6 +222,8 @@ it.each([
},
cancel() {
cancelled = true;
if (abortDuringCleanup)
controller.abort({ requested: "stop during response cleanup" });
if (cancellationFails) throw reason;
},
},
@@ -230,26 +243,33 @@ it.each([
allowed_origins: [origin],
fetch_source_maps: true,
}),
undefined,
controller.signal,
{ fetch: () => Promise.resolve(new Response(body)) },
);
if (cancellationFails) {
await expect(pending).rejects.toMatchObject({
_tag: "AnalysisResourceConstraintError",
message: expect.stringContaining("Expanded source-map evidence"),
cleanupIncomplete: true,
cleanupResources: ["source-map-response"],
cause: expect.objectContaining({
errors: expect.arrayContaining([reason]),
}),
const result = await pending;
expect(result).toMatchObject({
ok: false,
error: {
_tag: "AnalysisResourceConstraintError",
message: expect.stringContaining("Expanded source-map evidence"),
cleanupIncomplete: true,
cleanupResources: ["source-map-response"],
cause: expect.objectContaining({
errors: expect.arrayContaining([reason]),
}),
},
});
} else {
await expect(pending).resolves.toMatchObject({
status: "unavailable",
requested: 1,
processed: 0,
items: [],
limitation: expect.stringContaining("1 requested map"),
ok: true,
value: {
status: "unavailable",
requested: 1,
processed: 0,
items: [],
limitation: expect.stringContaining("1 requested map"),
},
});
}
expect(cancelled).toBe(true);
@@ -288,10 +308,10 @@ it("charges complete escaped redirect errors and cancels every discarded respons
);
},
};
const control = await fetchWebSourceMaps([request], input, undefined, host);
const control = await fetchWebSourceMapsOk([request], input, undefined, host);
expect(control.items[0]?.limitation).toContain(JSON.stringify(location));
expect(control.items[0]?.limitation?.length).toBeGreaterThan(512);
const result = await fetchWebSourceMaps(
const result = await fetchWebSourceMapsOk(
Array.from({ length: 1_500 }, () => request),
input,
undefined,
@@ -1,6 +1,6 @@
import { createHash } from "node:crypto";
import { expect, it } from "vitest";
import { fetchWebSourceMaps } from "../../../src/browser/WebSourceMapFetcher.js";
import { fetchWebSourceMapsOk } from "../../fixtures/fetchWebSourceMapsOk.js";
import { analyzeWebBundleInputSchema } from "../../../src/domain/webBundleAnalysis.js";
import { traceSourceMap } from "../../../src/javascript/sourceMaps/TraceSourceMap.js";
@@ -335,7 +335,7 @@ it.each(cases)(
expect(point.matches).toHaveLength(1);
}
}
const result = await fetchWebSourceMaps(
const result = await fetchWebSourceMapsOk(
[{ scriptKey: "scr_" + "1".repeat(64), declaredUrl: url, fetchUrl: url }],
analyzeWebBundleInputSchema.parse({
cdp_endpoint: "http://127.0.0.1:9222",
@@ -415,7 +415,7 @@ it("rejects a source mapping that crosses an indexed section boundary", async ()
fetchUrl: url,
};
const fetchMap = (map: ReturnType<typeof makeMap>) =>
fetchWebSourceMaps([request], input, undefined, {
fetchWebSourceMapsOk([request], input, undefined, {
fetch: () =>
Promise.resolve(new Response(JSON.stringify(map), { status: 200 })),
});
@@ -1,6 +1,6 @@
import { createServer } from "node:http";
import { describe, expect, it, onTestFinished } from "vitest";
import { fetchWebSourceMaps } from "../../../src/browser/WebSourceMapFetcher.js";
import { fetchWebSourceMapsOk } from "../../fixtures/fetchWebSourceMapsOk.js";
import { analyzeWebBundleInputSchema } from "../../../src/domain/webBundleAnalysis.js";
describe("source-map discarded HTTP response ownership", () => {
@@ -55,7 +55,7 @@ describe("source-map discarded HTTP response ownership", () => {
target_id: "page-1",
fetch_source_maps: true,
});
const result = await fetchWebSourceMaps(
const result = await fetchWebSourceMapsOk(
[
{
scriptKey: `scr_${"1".repeat(64)}`,
@@ -0,0 +1,224 @@
import { createHash } from "node:crypto";
import { expect, it, onTestFinished, vi } from "vitest";
import { fetchWebSourceMaps } from "../../../src/browser/WebSourceMapFetcher.js";
import {
analyzeWebBundleInputSchema,
webSourceMapsSchema,
} from "../../../src/domain/webBundleAnalysis.js";
const origin = "https://maps.example.test";
const request = (index: number) => ({
scriptKey: `scr_${String(index).padStart(64, "0")}`,
declaredUrl: `${origin}/${String(index)}.js.map`,
fetchUrl: `${origin}/${String(index)}.js.map`,
});
const input = analyzeWebBundleInputSchema.parse({
cdp_endpoint: "http://127.0.0.1:9222",
allowed_origins: [origin],
target_id: "page-1",
fetch_source_maps: true,
});
const digest = (bytes: Uint8Array | string): string =>
createHash("sha256").update(bytes).digest("hex");
it.each([
{ label: "an empty body", body: "" },
{ label: "JSON", body: "not-json" },
{ label: "BOM and XSSI framing", body: "\uFEFF)]}'\nnot-json" },
{
label: "mapping indices",
body: JSON.stringify({
version: 3,
sources: ["a.js"],
names: [],
mappings: "ACAA",
}),
},
])(
"retains completed body identity when $label is invalid",
async ({ body }) => {
const result = await fetchWebSourceMaps([request(1)], input, undefined, {
fetch: () => Promise.resolve(new Response(body, { status: 200 })),
});
if (!result.ok) throw result.error;
expect(result.value).toMatchObject({
status: "unavailable",
requested: 1,
processed: 1,
items: [
{
status: "invalid",
artifact: null,
response_identity: {
sha256: digest(body),
bytes: Buffer.byteLength(body),
representation: "fetch-response-body-bytes",
},
original_sources: [],
original_module_edges: [],
mappings: [],
limitation: expect.stringContaining("Response text was not retained"),
},
],
});
expect(webSourceMapsSchema.parse(result.value)).toEqual(result.value);
},
);
it("retains only completed response bytes when UTF-8 decoding fails", async () => {
const body = Uint8Array.of(0x7b, 0x22, 0xc3, 0x28, 0x22, 0x7d);
const result = await fetchWebSourceMaps([request(1)], input, undefined, {
fetch: () => Promise.resolve(new Response(body, { status: 200 })),
});
if (!result.ok) throw result.error;
expect(result.value).toMatchObject({
status: "unavailable",
items: [
{
status: "invalid",
artifact: null,
response_identity: {
sha256: digest(body),
bytes: body.byteLength,
representation: "fetch-response-body-bytes",
},
original_sources: [],
original_module_edges: [],
mappings: [],
limitation: expect.stringContaining("Response text was not retained"),
},
],
});
});
it("retains completed body identity when map interpretation reaches its deadline", async () => {
const body = JSON.stringify({
version: 3,
sources: ["a.js"],
names: [],
mappings: "AAAA",
});
const response = new Response(body, { status: 200 });
const realDecode = TextDecoder.prototype.decode;
let clock = 0;
const clockSpy = vi.spyOn(performance, "now").mockImplementation(() => clock);
const decodeSpy = vi
.spyOn(TextDecoder.prototype, "decode")
.mockImplementation(function (
this: TextDecoder,
input?: Parameters<TextDecoder["decode"]>[0],
options?: Parameters<TextDecoder["decode"]>[1],
) {
const text = realDecode.call(this, input, options);
clock = 1_001;
return text;
});
try {
const result = await fetchWebSourceMaps([request(1)], input, undefined, {
timeoutMs: 1_000,
fetch: () => Promise.resolve(response),
});
if (!result.ok) throw result.error;
expect(result.value).toMatchObject({
status: "unavailable",
requested: 1,
processed: 1,
items: [
{
status: "fetch_failed",
artifact: null,
response_identity: {
sha256: digest(body),
bytes: Buffer.byteLength(body),
representation: "fetch-response-body-bytes",
},
original_sources: [],
original_module_edges: [],
mappings: [],
limitation: expect.stringContaining(
"source_map_fetch_timeout_ms deadline",
),
},
],
});
} finally {
decodeSpy.mockRestore();
clockSpy.mockRestore();
}
});
it("refuses oversized text before JSON interpretation while retaining body identity and later maps", async () => {
// JSON escaping expands these decoded control characters beyond the fixed
// 32 MiB source-map output allowance while the response stays below 64 MiB.
const oversized = Buffer.alloc(6 * 1024 * 1024, 1);
const oversizedText = oversized.toString("utf8");
const valid = JSON.stringify({
version: 3,
sources: ["a.js"],
names: [],
mappings: "AAAA",
});
const parseSpy = vi.spyOn(JSON, "parse");
onTestFinished(() => parseSpy.mockRestore());
let calls = 0;
const result = await fetchWebSourceMaps(
[request(1), request(2)],
input,
undefined,
{
fetch: () =>
Promise.resolve(
new Response(calls++ === 0 ? oversized : valid, { status: 200 }),
),
},
);
if (!result.ok) throw result.error;
expect(result.value).toMatchObject({
status: "partial",
requested: 2,
processed: 2,
items: [
{
status: "fetch_failed",
artifact: null,
response_identity: {
sha256: digest(oversized),
bytes: oversized.byteLength,
representation: "fetch-response-body-bytes",
},
mappings: [],
limitation: expect.stringContaining("Response text was not retained"),
},
{ status: "included", artifact: { text: valid }, mappings: [{}] },
],
});
expect(Buffer.byteLength(JSON.stringify(result.value))).toBeLessThanOrEqual(
32 * 1024 * 1024,
);
expect(parseSpy.mock.calls.some(([text]) => text === oversizedText)).toBe(
false,
);
expect(calls).toBe(2);
});
it("keeps historical invalid rows with no response identity admissible", () => {
const historical = {
status: "unavailable",
requested: 1,
processed: 1,
items: [
{
script_key: request(1).scriptKey,
declared_url: request(1).declaredUrl,
status: "invalid",
artifact: null,
original_sources: [],
original_module_edges: [],
mappings: [],
limitation: "Source-map JSON is not a version 3 map.",
},
],
};
expect(webSourceMapsSchema.parse(historical)).toMatchObject(historical);
});
@@ -0,0 +1,150 @@
import { createHash } from "node:crypto";
import type { CallToolResult } from "@modelcontextprotocol/server";
import { expect, it, onTestFinished, vi } from "vitest";
import { z } from "zod";
import { CdpBrowserProvider } from "../../../src/browser/CdpBrowserProvider.js";
import { toolContract } from "../../../src/contracts/toolContracts.js";
import { parseEvidence } from "../../../src/domain/evidence.js";
import { webBundleAcquisitionPartialObservationSchema } from "../../../src/domain/webBundleAnalysis.js";
import { ToolResultDelivery } from "../../../src/server/ToolResultDelivery.js";
import { startSourceMapCancellationBrowser } from "../../fixtures/browserSourceMapCancellation.js";
import { createDeferred } from "../../fixtures/binarySession.js";
import {
createApplicationMcpHarness,
type ApplicationMcpHarness,
} from "../../fixtures/applicationMcpHarness.js";
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
it("retains completed source maps through MCP cancellation for later Evidence retrieval", async () => {
const {
browser,
mapText,
completedMapPath,
secondRequest,
releasePendingResponse,
close,
} = await startSourceMapCancellationBrowser();
const controller = new AbortController();
let harness: ApplicationMcpHarness | undefined;
onTestFinished(async () => {
controller.abort();
vi.restoreAllMocks();
releasePendingResponse();
try {
await harness?.close();
} finally {
await close();
}
});
const connected = await createApplicationMcpHarness({
serverOptions: { browserObservation: new CdpBrowserProvider() },
});
harness = connected;
const delivered = createDeferred<CallToolResult>();
const original = ToolResultDelivery.prototype.toPartialErrorToolResult;
vi.spyOn(
ToolResultDelivery.prototype,
"toPartialErrorToolResult",
).mockImplementation(function (this: ToolResultDelivery, ...args) {
const result = original.apply(this, args);
delivered.resolve(result);
return result;
});
const call = connected.client.callTool(
{
name: "analyze_web_bundle",
arguments: {
cdp_endpoint: browser.endpoint,
allowed_origins: [browser.allowedOrigin],
target_id: "allowed-page",
observation_ms: 0,
fetch_source_maps: true,
},
},
{ signal: controller.signal },
);
const pendingMapPath = await Promise.race([
secondRequest,
call.then(
() => {
throw new Error("MCP completed before the second map request");
},
(cause: unknown) => {
throw cause;
},
),
]);
const retainedMapPath = await completedMapPath;
expect([retainedMapPath, pendingMapPath].sort()).toEqual([
"/first.js.map",
"/second.js.map",
]);
controller.abort(new Error("Caller cancelled after the first map"));
await expect(call).rejects.toThrow();
// SDK cancellation suppresses the reply. This is the server's recording boundary.
const { error } = parseMcpToolError(await delivered.promise);
expect(error.code).toBe("cancelled");
const reference = z
.object({
partial_observation: z.object({
kind: z.literal("retained-evidence"),
evidence_id: z.string(),
}),
})
.parse(error.details).partial_observation;
const reply = await connected.client.callTool({
name: "get_evidence_bundle",
arguments: { detail: "complete" },
});
expect(reply.isError, JSON.stringify(reply.content)).not.toBe(true);
const bundle = toolContract("get_evidence_bundle").outputSchema.parse(
reply.structuredContent,
).result;
if ("kind" in bundle) throw new Error("Expected complete Evidence bundle");
const evidence = parseEvidence(
bundle.records.find(
(record) => record.evidence_id === reference.evidence_id,
),
);
expect(evidence.operation).toBe("analyze_web_bundle");
const acquisition = webBundleAcquisitionPartialObservationSchema.parse(
evidence.normalized_result,
);
expect(acquisition.analysis_status).toBe("not_performed");
expect(acquisition.source_maps).toMatchObject({
status: "partial",
requested: 2,
processed: 1,
excluded: [
{ section: "source_maps", reason: "disallowed_origin", count: 1 },
],
items: [
{
status: "included",
declared_url: `${browser.allowedOrigin}${retainedMapPath}`,
artifact: {
text: mapText,
sha256: createHash("sha256").update(mapText).digest("hex"),
},
},
],
});
expect(acquisition.source_maps.items).toHaveLength(1);
expect(acquisition.inspection.completeness.excluded).toContainEqual({
section: "source_maps",
reason: "disallowed_origin",
count: 1,
});
expect(JSON.stringify(evidence)).not.toContain("outside.example.test");
await expect(connected.client.ping()).resolves.toEqual({});
expect(browser.activeConnectionCount()).toBe(0);
expect(
browser.httpRequests
.filter(({ url }) => url.endsWith(".map"))
.map(({ url }) => url),
).toEqual([retainedMapPath, pendingMapPath]);
});
+76
View File
@@ -0,0 +1,76 @@
import type { ServerResponse } from "node:http";
import { createDeferred } from "./binarySession.js";
import { startFakeCdpBrowser } from "./fakeCdpBrowser.js";
/** Two allowed maps and one excluded map, with the second HTTP response held open. */
export const startSourceMapCancellationBrowser = async () => {
const mapText = JSON.stringify({
version: 3,
names: [],
sources: ["src/original.js"],
sourcesContent: ["export const value = 1;"],
mappings: "AAAA",
});
const scriptText = "export const observed = true;";
const completedMapPath = createDeferred<string>();
const secondRequest = createDeferred<string>();
let firstPath: string | undefined;
let heldResponse: ServerResponse | undefined;
const browser = await startFakeCdpBrowser({
sourceMapReply(url, response) {
if (url !== "/first.js.map" && url !== "/second.js.map") return false;
if (firstPath === undefined) {
firstPath = url;
response.end(mapText);
completedMapPath.resolve(url);
return true;
}
heldResponse = response;
secondRequest.resolve(url);
return true;
},
commandResult: ({ method }) =>
method === "Debugger.getScriptSource"
? { scriptSource: scriptText }
: 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(scriptText),
isModule: true,
executionContextId: 1,
scriptLanguage: "JavaScript",
sourceMapURL,
},
});
return [
script("first", "/first.js.map"),
script("second", "/second.js.map"),
script("excluded", "https://outside.example.test/other.js.map"),
];
},
});
const releasePendingResponse = (): void => {
heldResponse?.destroy();
};
return {
browser,
mapText,
completedMapPath: completedMapPath.promise,
secondRequest: secondRequest.promise,
releasePendingResponse,
async close() {
releasePendingResponse();
await browser.close();
},
};
};
+2
View File
@@ -268,6 +268,8 @@ const serveSourceMap = (
response: ServerResponse,
options: FakeOptions,
): boolean => {
if (options.sourceMapReply?.(request.url ?? "", response) === true)
return true;
if (
options.hangSourceMap === true &&
request.url?.startsWith("/app.js.map") === true
+3
View File
@@ -1,5 +1,6 @@
import type { JsonValue } from "../../src/domain/jsonValue.js";
import type { CdpEvent } from "../../src/browser/CdpConnection.js";
import type { ServerResponse } from "node:http";
export interface FakeCdpCommand {
readonly id: number;
readonly method: string;
@@ -90,6 +91,8 @@ export interface FakeOptions {
readonly webSocketWithoutFrames?: boolean;
readonly invalidBinaryWebSocketEvent?: boolean;
readonly sourceMapBody?: string;
/** Test-owned HTTP response for selected map URLs; true means handled. */
readonly sourceMapReply?: (url: string, response: ServerResponse) => boolean;
/** Accept a source-map request and leave the response open. */
readonly hangSourceMap?: boolean;
readonly sourceMapRedirect?: string;
+11
View File
@@ -0,0 +1,11 @@
import { fetchWebSourceMaps } from "../../src/browser/WebSourceMapFetcher.js";
import type { WebSourceMaps } from "../../src/domain/webBundleAnalysis.js";
/** Assert the successful producer branch in tests of completed map collections. */
export const fetchWebSourceMapsOk = async (
...args: Parameters<typeof fetchWebSourceMaps>
): Promise<WebSourceMaps> => {
const result = await fetchWebSourceMaps(...args);
if (!result.ok) throw result.error;
return result.value;
};