diff --git a/package-lock.json b/package-lock.json index af76f235b..69f6884ff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@babel/code-frame": "7.24.2", "@gemini-testing/commander": "2.15.4", + "@jridgewell/trace-mapping": "0.3.31", "@jspm/core": "2.0.1", "@jsquash/png": "3.1.1", "@puppeteer/browsers": "2.7.1", @@ -48,7 +49,6 @@ "sizzle": "2.3.6", "socket.io": "4.7.5", "socket.io-client": "4.7.5", - "source-map-js": "1.2.1", "strftime": "0.10.2", "strip-ansi": "6.0.1", "temp": "0.8.3", @@ -132,7 +132,7 @@ "uglifyify": "3.0.4" }, "engines": { - "node": ">= 18.17.0" + "node": ">= 22" }, "peerDependencies": { "@babel/parser": ">=7.0.0", @@ -899,6 +899,17 @@ "node": ">=12" } }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.8", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.8.tgz", @@ -1706,7 +1717,6 @@ }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.0", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -1714,16 +1724,16 @@ }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.0", - "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.9", - "dev": true, + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "license": "MIT", "dependencies": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" } }, "node_modules/@jspm/core": { @@ -15637,17 +15647,6 @@ "node": ">=10.12.0" } }, - "node_modules/v8-to-istanbul/node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, "node_modules/v8-to-istanbul/node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -17088,6 +17087,18 @@ "dev": true, "requires": { "@jridgewell/trace-mapping": "0.3.9" + }, + "dependencies": { + "@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "requires": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + } } }, "@esbuild/aix-ppc64": { @@ -17509,19 +17520,18 @@ } }, "@jridgewell/resolve-uri": { - "version": "3.1.0", - "dev": true + "version": "3.1.0" }, "@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "dev": true + "version": "1.5.0" }, "@jridgewell/trace-mapping": { - "version": "0.3.9", - "dev": true, + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "requires": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" } }, "@jspm/core": { @@ -26840,16 +26850,6 @@ "convert-source-map": "^2.0.0" }, "dependencies": { - "@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "requires": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, "convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", diff --git a/package.json b/package.json index 102368532..2b31cb444 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "dependencies": { "@babel/code-frame": "7.24.2", "@gemini-testing/commander": "2.15.4", + "@jridgewell/trace-mapping": "0.3.31", "@jspm/core": "2.0.1", "@jsquash/png": "3.1.1", "@puppeteer/browsers": "2.7.1", @@ -112,7 +113,6 @@ "sizzle": "2.3.6", "socket.io": "4.7.5", "socket.io-client": "4.7.5", - "source-map-js": "1.2.1", "strftime": "0.10.2", "strip-ansi": "6.0.1", "temp": "0.8.3", diff --git a/src/browser/cdp/selectivity/css-selectivity.ts b/src/browser/cdp/selectivity/css-selectivity.ts index 50f0e7072..00c9a32a4 100644 --- a/src/browser/cdp/selectivity/css-selectivity.ts +++ b/src/browser/cdp/selectivity/css-selectivity.ts @@ -357,7 +357,7 @@ export class CSSSelectivity { rawSourceMap.sources.forEach(sourceFilePath => { // Ignore generated postcss styles: // https://github.com/postcss/postcss/blob/eae46db765d752cf8f40c4fa2b0b85030079c43d/lib/map-generator.js#L122 - if (sourceFilePath === "") { + if (!sourceFilePath || sourceFilePath === "") { return; } diff --git a/src/browser/cdp/selectivity/fs-cache.ts b/src/browser/cdp/selectivity/fs-cache.ts index 481bda1b1..0e61f6b00 100644 --- a/src/browser/cdp/selectivity/fs-cache.ts +++ b/src/browser/cdp/selectivity/fs-cache.ts @@ -11,6 +11,7 @@ export const CacheType = { TestFile: "t", Asset: "a", CssSessionCache: "cs", + SourceMapParseResult: "sm", } as const; type CacheTypeValue = (typeof CacheType)[keyof typeof CacheType]; diff --git a/src/browser/cdp/selectivity/js-selectivity.ts b/src/browser/cdp/selectivity/js-selectivity.ts index 4f9861004..4f6da27d0 100644 --- a/src/browser/cdp/selectivity/js-selectivity.ts +++ b/src/browser/cdp/selectivity/js-selectivity.ts @@ -1,7 +1,13 @@ import { groupBy } from "lodash"; import { resolve as urlResolve } from "node:url"; import { JS_SOURCE_MAP_URL_COMMENT } from "../../../error-snippets/constants"; -import { extractSourceFilesDeps, fetchTextWithBrowserFallback, isCachedOnFs, isDataProtocol } from "./utils"; +import { + extractSourceFilesDeps, + fetchTextWithBrowserFallback, + isCachedOnFs, + isDataProtocol, + parseSourceMapRanges, +} from "./utils"; import { CacheType, getCachedSelectivityFile, hasCachedSelectivityFile, setCachedSelectivityFile } from "./fs-cache"; import { debugSelectivity } from "./debug"; import type { CDP } from ".."; @@ -328,11 +334,14 @@ export class JSSelectivity { throw new Error(`JS Selectivity: fs-cache is broken for ${sourceUrl}`); } - const dependingSourceFiles = extractSourceFilesDeps( + const parsedSourceMapRanges = await parseSourceMapRanges( sourceString, sourceMapsString, - grouppedByScriptCoverage[scriptId], this._sourceRoot, + ); + const dependingSourceFiles = extractSourceFilesDeps( + parsedSourceMapRanges, + grouppedByScriptCoverage[scriptId], isSourceCodeFile, ); diff --git a/src/browser/cdp/selectivity/utils.ts b/src/browser/cdp/selectivity/utils.ts index 8c419adab..4a8458953 100644 --- a/src/browser/cdp/selectivity/utils.ts +++ b/src/browser/cdp/selectivity/utils.ts @@ -1,5 +1,5 @@ -import { sortedIndex, memoize } from "lodash"; -import { SourceFindPosition, SourceMapConsumer, type RawSourceMap } from "source-map-js"; +import { sortedLastIndex, memoize } from "lodash"; +import { TraceMap, eachMapping, type EncodedSourceMap } from "@jridgewell/trace-mapping"; import fs from "fs"; import path from "path"; import { URL } from "url"; @@ -22,6 +22,17 @@ import { SelectivityDependencyScope, SelectivityMapDependencyRelativePathFn, } from "../../../config/types"; +import { CacheType, getCachedSelectivityFile, setCachedSelectivityFile } from "./fs-cache"; + +/** + * Sorted list of generated-bundle segment boundaries. + * Segment `i` owns generated bytes `[offset[i], offset[i + 1])` (the last segment runs to the end of the bundle) + */ +interface ParsedSourceMapRanges { + offset: number[]; + /** Can be empty if segment is glue code */ + filename: string[]; +} /** * Tries to fetch text by url from node.js, then falls back to "fetch" from browser, if node.js fetch fails @@ -67,30 +78,79 @@ const isWebpackProtocol = (posixRelativeSourceFilePath: string): boolean => { * @param sourceMap Raw source maps in https://tc39.es/ecma426/ format * @param sourceRoot Source root */ -export const patchSourceMapSources = (sourceMap: RawSourceMap, sourceRoot?: string): RawSourceMap => { +export const patchSourceMapSources = (sourceMap: EncodedSourceMap, sourceRoot?: string): EncodedSourceMap => { sourceMap.sourceRoot = sourceRoot || sourceMap.sourceRoot; for (let i = 0; i < sourceMap.sources.length; i++) { - if (isWebpackProtocol(sourceMap.sources[i])) { - sourceMap.sources[i] = sourceMap.sources[i].slice(WEBPACK_PROTOCOL.length); + if (sourceMap.sources[i] && isWebpackProtocol(sourceMap.sources[i] as string)) { + sourceMap.sources[i] = (sourceMap.sources[i] as string).slice(WEBPACK_PROTOCOL.length); } } return sourceMap; }; -/** - * With cdp coverage's offset style - * @returns sourcemap's 0-based line number - */ -const offsetToSourceMapLineNumber = (offsetToLine: number[], offset: number): number => { - let lineNumber = sortedIndex(offsetToLine, offset); +const getSourceLineOffsetArray = (source: string): number[] => { + let sourceOffset = source.indexOf("\n"); + const offsetToLine = [0]; - if (offset < offsetToLine[lineNumber]) { - lineNumber--; + while (sourceOffset !== -1) { + offsetToLine.push(++sourceOffset); + sourceOffset = source.indexOf("\n", sourceOffset); } - return lineNumber; + return offsetToLine; +}; + +export const parseSourceMapRanges = async ( + source: string, + sourceMaps: string, + sourceRoot: string, +): Promise => { + let jsonCachedResult: ParsedSourceMapRanges | null = null; + + try { + const cachedResult = await getCachedSelectivityFile(CacheType.SourceMapParseResult, sourceMaps); + jsonCachedResult = cachedResult ? JSON.parse(cachedResult) : null; + } catch {} // eslint-disable-line no-empty + + if (jsonCachedResult) { + return jsonCachedResult; + } + + const lineOffsetArray = getSourceLineOffsetArray(source); + const sourceMapsParsed = patchSourceMapSources(JSON.parse(sourceMaps), sourceRoot); + const traceMap = new TraceMap(sourceMapsParsed); + + const result: ParsedSourceMapRanges = { + offset: [], + filename: [], + }; + + // "eachMapping" walks segments in generated order (line asc, then column asc). + // A single source can still appear in several disjoint segments (bundlers interleave/concatenate modules), + let lastRecordedSource: string | undefined; + eachMapping(traceMap, mapping => { + const mappingSource = mapping.source || ""; + const lineOffset = lineOffsetArray[mapping.generatedLine - 1]; + + // Skip out-of-range + if (typeof lineOffset === "undefined") { + return; + } + + if (mappingSource === lastRecordedSource) { + return; + } + + result.offset.push(lineOffset + mapping.generatedColumn); + result.filename.push(mappingSource); + lastRecordedSource = mappingSource; + }); + + setCachedSelectivityFile(CacheType.SourceMapParseResult, sourceMaps, JSON.stringify(result)).catch(() => {}); + + return result; }; /** @@ -103,63 +163,37 @@ const offsetToSourceMapLineNumber = (offsetToLine: number[], offset: number): nu * @param sourceRoot Source root */ export const extractSourceFilesDeps = ( - source: string, - sourceMaps: string, + { offset, filename }: ParsedSourceMapRanges, coverages: CDPScriptCoverage[], - sourceRoot: string, sourceFilterFn?: (sourceFileName: string) => boolean, ): Set => { const dependantSourceFiles = new Set(); - const sourceMapsParsed = patchSourceMapSources(JSON.parse(sourceMaps), sourceRoot); - const consumer = new SourceMapConsumer(sourceMapsParsed); - - let sourceOffset = source.indexOf("\n"); - const offsetToLine = [0]; - - while (sourceOffset !== -1) { - offsetToLine.push(++sourceOffset); - sourceOffset = source.indexOf("\n", sourceOffset); + if (!offset.length) { + return dependantSourceFiles; } for (const scriptCoverage of coverages) { for (const functionCall of scriptCoverage.functions) { - for (const range of functionCall.ranges) { - const startLine = offsetToSourceMapLineNumber(offsetToLine, range.startOffset); - - const column = range.startOffset - offsetToLine[startLine]; - const prevPosition = consumer.originalPositionFor({ - line: startLine + 1, - column, - bias: SourceMapConsumer.GREATEST_LOWER_BOUND, - }); - - if (prevPosition && prevPosition.source && (!sourceFilterFn || sourceFilterFn(prevPosition.source))) { - (prevPosition as SourceFindPosition).bias = SourceMapConsumer.GREATEST_LOWER_BOUND; - const generatedPosition = consumer.generatedPositionFor(prevPosition); - - if (typeof generatedPosition.line === "number" && generatedPosition.line >= startLine + 1) { - dependantSourceFiles.add(prevPosition.source); - break; - } - } + // ranges[0] is the function's full extent in V8 coverage; nested ranges are sub-blocks + const range = functionCall.ranges[0]; + + if (!range || !range.count) { + continue; + } + + // Ignore full-file wrapper + if (range.startOffset <= offset[0] && range.endOffset >= offset[offset.length - 1]) { + continue; + } + + const startIdx = Math.max(0, sortedLastIndex(offset, range.startOffset) - 1); + + for (let idx = startIdx; idx < offset.length && offset[idx] < range.endOffset; idx++) { + const sourceFile = filename[idx]; - // Bundler source file function wrapper case - const nextPosition = consumer.originalPositionFor({ - line: startLine + 1, - column, - bias: SourceMapConsumer.LEAST_UPPER_BOUND, - }); - - if (nextPosition && nextPosition.source && (!sourceFilterFn || sourceFilterFn(nextPosition.source))) { - (nextPosition as SourceFindPosition).bias = SourceMapConsumer.LEAST_UPPER_BOUND; - const generatedPosition = consumer.generatedPositionFor(nextPosition); - const endLine = offsetToSourceMapLineNumber(offsetToLine, range.endOffset); - - if (typeof generatedPosition.line === "number" && generatedPosition.line <= endLine + 1) { - dependantSourceFiles.add(nextPosition.source); - break; - } + if (sourceFile && (!sourceFilterFn || sourceFilterFn(sourceFile))) { + dependantSourceFiles.add(sourceFile); } } } diff --git a/src/error-snippets/source-maps.ts b/src/error-snippets/source-maps.ts index 4901ee0ae..de9272394 100644 --- a/src/error-snippets/source-maps.ts +++ b/src/error-snippets/source-maps.ts @@ -1,4 +1,4 @@ -import { RawSourceMap, SourceMapConsumer } from "source-map-js"; +import { TraceMap, originalPositionFor, sourceContentFor, EncodedSourceMap } from "@jridgewell/trace-mapping"; import url from "url"; import { JS_SOURCE_MAP_URL_COMMENT } from "./constants"; import { getSourceCodeFile } from "./utils"; @@ -6,7 +6,7 @@ import { softFileURLToPath } from "../utils/fs"; import { transformCode } from "../utils/typescript"; import type { SufficientStackFrame, ResolvedFrame } from "./types"; -export const extractSourceMaps = async (fileContents: string, fileName: string): Promise => { +export const extractSourceMaps = async (fileContents: string, fileName: string): Promise => { const hasNoSourceMaps = fileContents.indexOf(JS_SOURCE_MAP_URL_COMMENT) === -1; const isEsmFile = fileName.startsWith("file://"); @@ -27,19 +27,16 @@ export const extractSourceMaps = async (fileContents: string, fileName: string): : fileContents.slice(sourceMapsStartIndex + JS_SOURCE_MAP_URL_COMMENT.length, sourceMapsEndIndex); const sourceMaps = await getSourceCodeFile(url.resolve(fileName, sourceMapUrl)); - const rawSourceMaps = JSON.parse(sourceMaps) as RawSourceMap; + const rawSourceMaps = JSON.parse(sourceMaps) as EncodedSourceMap; rawSourceMaps.file = rawSourceMaps.file || fileName; - return new SourceMapConsumer(rawSourceMaps); + return new TraceMap(rawSourceMaps); }; -export const resolveLocationWithSourceMap = ( - stackFrame: SufficientStackFrame, - sourceMaps: SourceMapConsumer, -): ResolvedFrame => { - const positions = sourceMaps.originalPositionFor({ line: stackFrame.lineNumber, column: stackFrame.columnNumber }); - const source = positions.source ? sourceMaps.sourceContentFor(positions.source) : null; +export const resolveLocationWithSourceMap = (stackFrame: SufficientStackFrame, sourceMaps: TraceMap): ResolvedFrame => { + const positions = originalPositionFor(sourceMaps, { line: stackFrame.lineNumber, column: stackFrame.columnNumber }); + const source = positions.source ? sourceContentFor(sourceMaps, positions.source) : null; const location = { line: positions.line!, column: positions.column! }; if (!source) { diff --git a/test/src/browser/cdp/selectivity/js-selectivity.ts b/test/src/browser/cdp/selectivity/js-selectivity.ts index 0be8259aa..f51151cec 100644 --- a/test/src/browser/cdp/selectivity/js-selectivity.ts +++ b/test/src/browser/cdp/selectivity/js-selectivity.ts @@ -17,6 +17,7 @@ describe("CDP/Selectivity/JSSelectivity", () => { }; let fetchTextWithBrowserFallbackStub: SinonStub; let extractSourceFilesDepsStub: SinonStub; + let parseSourceMapRangesStub: SinonStub; let urlResolveStub: SinonStub; let groupByStub: SinonStub; let isDataProtocolStub: SinonStub; @@ -52,6 +53,7 @@ describe("CDP/Selectivity/JSSelectivity", () => { fetchTextWithBrowserFallbackStub = sandbox.stub().resolves("mock source map"); extractSourceFilesDepsStub = sandbox.stub().returns(new Set(["src/app.js", "src/utils.js"])); + parseSourceMapRangesStub = sandbox.stub().resolves({ offset: [0], filename: ["src/app.js"] }); urlResolveStub = sandbox.stub().returnsArg(1); groupByStub = sandbox.stub().callsFake((arr, key) => { const result: Record = {}; @@ -73,6 +75,7 @@ describe("CDP/Selectivity/JSSelectivity", () => { "node:url": { resolve: urlResolveStub }, "./utils": { extractSourceFilesDeps: extractSourceFilesDepsStub, + parseSourceMapRanges: parseSourceMapRangesStub, fetchTextWithBrowserFallback: fetchTextWithBrowserFallbackStub, isDataProtocol: isDataProtocolStub, }, @@ -375,11 +378,15 @@ describe("CDP/Selectivity/JSSelectivity", () => { assert.calledWith(cdpMock.profiler.takePreciseCoverage, sessionId); assert.calledWith( - extractSourceFilesDepsStub, + parseSourceMapRangesStub, "mock source\n//# sourceMappingURL=app.js.map", "mock source map", - mockCoverage.result, sourceRoot, + ); + assert.calledWith( + extractSourceFilesDepsStub, + await parseSourceMapRangesStub.returnValues[0], + mockCoverage.result, sinon.match.func, ); @@ -421,8 +428,9 @@ describe("CDP/Selectivity/JSSelectivity", () => { assert.calledOnceWith( extractSourceFilesDepsStub, - "mock source\n//# sourceMappingURL=app.js.map", - "mock source map", + await parseSourceMapRangesStub.returnValues[0], + [mockCoverage.result[1]], + sinon.match.func, ); }); diff --git a/test/src/browser/cdp/selectivity/utils.ts b/test/src/browser/cdp/selectivity/utils.ts index 6551dabff..f078df6ab 100644 --- a/test/src/browser/cdp/selectivity/utils.ts +++ b/test/src/browser/cdp/selectivity/utils.ts @@ -10,7 +10,12 @@ describe("CDP/Selectivity/Utils", () => { let fsStub: { existsSync: SinonStub }; let pathStub: { posix: { relative: SinonStub; resolve: SinonStub; join: SinonStub; sep: string } }; let softFileURLToPathStub: SinonStub; - let SourceMapConsumerStub: SinonStub; + let traceMapStub: SinonStub; + let originalPositionForStub: SinonStub; + let generatedPositionForStub: SinonStub; + + const GREATEST_LOWER_BOUND = 1; + const LEAST_UPPER_BOUND = -1; beforeEach(() => { fetchStub = sandbox.stub(globalThis, "fetch").resolves({ @@ -40,13 +45,19 @@ describe("CDP/Selectivity/Utils", () => { }, }; softFileURLToPathStub = sandbox.stub().returnsArg(0); - SourceMapConsumerStub = sandbox.stub(); + traceMapStub = sandbox.stub(); + originalPositionForStub = sandbox.stub(); + generatedPositionForStub = sandbox.stub(); utils = proxyquire("src/browser/cdp/selectivity/utils", { fs: fsStub, path: pathStub, - "source-map-js": { - SourceMapConsumer: SourceMapConsumerStub, + "@jridgewell/trace-mapping": { + TraceMap: traceMapStub, + originalPositionFor: originalPositionForStub, + generatedPositionFor: generatedPositionForStub, + GREATEST_LOWER_BOUND, + LEAST_UPPER_BOUND, }, "../../../utils/fs": { softFileURLToPath: softFileURLToPathStub, @@ -131,7 +142,7 @@ describe("CDP/Selectivity/Utils", () => { describe("patchSourceMapSources", () => { it("should patch webpack protocol sources", () => { const sourceMap = { - version: 3 as unknown as string, + version: 3 as const, sources: ["webpack://src/app.js", "webpack://src/utils.js", "regular/file.js"], sourceRoot: "", names: [], @@ -147,7 +158,7 @@ describe("CDP/Selectivity/Utils", () => { it("should use existing sourceRoot if no custom sourceRoot provided", () => { const sourceMap = { - version: 3 as unknown as string, + version: 3 as const, sources: ["webpack:///src/app.js"], sourceRoot: "/existing/root", names: [], @@ -162,7 +173,7 @@ describe("CDP/Selectivity/Utils", () => { it("should handle sources without webpack protocol", () => { const sourceMap = { - version: 3 as unknown as string, + version: 3 as const, sources: ["src/app.js", "lib/utils.js"], sourceRoot: "", names: [], @@ -177,22 +188,18 @@ describe("CDP/Selectivity/Utils", () => { }); describe("extractSourceFilesDeps", () => { - let consumerMock: { originalPositionFor: SinonStub; generatedPositionFor: SinonStub }; - - const GREATEST_LOWER_BOUND = 1; - const LEAST_UPPER_BOUND = 2; - - const sourceMaps = JSON.stringify({ - version: 3, - sources: ["src/app.js"], - sourceRoot: "/root", - names: [], - mappings: "", - file: "bundle.js", - }); + // Boundary list of the generated bundle: + // segment 0: [10, 100) -> a.js + // segment 1: [100, 200) -> "" (bundler glue, no source) + // segment 2: [200, 300) -> b.js + // segment 3: [300, +inf) -> c.js + const parsedMaps = { + offset: [10, 100, 200, 300], + filename: ["a.js", "", "b.js", "c.js"], + }; const mkCoverages = ( - ranges: Array<{ startOffset: number; endOffset: number }>, + functions: Array<{ startOffset: number; endOffset: number; count?: number }>, ): Array<{ scriptId: string; url: string; @@ -205,256 +212,195 @@ describe("CDP/Selectivity/Utils", () => { { scriptId: "1", url: "http://example.com/bundle.js", - functions: [ - { - functionName: "fn", - ranges: ranges.map(r => ({ ...r, count: 1 })), - isBlockCoverage: false, - }, - ], + functions: functions.map((fn, idx) => ({ + functionName: `fn${idx}`, + ranges: [{ startOffset: fn.startOffset, endOffset: fn.endOffset, count: fn.count ?? 1 }], + isBlockCoverage: false, + })), }, ]; - beforeEach(() => { - consumerMock = { - originalPositionFor: sandbox.stub(), - generatedPositionFor: sandbox.stub(), - }; - SourceMapConsumerStub.returns(consumerMock); - (SourceMapConsumerStub as any).GREATEST_LOWER_BOUND = GREATEST_LOWER_BOUND; - (SourceMapConsumerStub as any).LEAST_UPPER_BOUND = LEAST_UPPER_BOUND; - }); - - it("should extract source files from coverage via GREATEST_LOWER_BOUND", () => { - // source: "line1\nline2\nline3\nline4" - // offsets: line0 starts at 0, line1 at 6, line2 at 12, line3 at 18 - const source = "line1\nline2\nline3\nline4"; - const coverages = [ - { - scriptId: "1", - url: "http://example.com/bundle.js", - functions: [ - { - functionName: "fn1", - ranges: [{ startOffset: 0, endOffset: 5, count: 1 }], - isBlockCoverage: false, - }, - { - functionName: "fn2", - ranges: [{ startOffset: 6, endOffset: 11, count: 1 }], - isBlockCoverage: false, - }, - ], - }, - ]; - - consumerMock.originalPositionFor - .withArgs(sinon.match({ bias: GREATEST_LOWER_BOUND })) - .onCall(0) - .returns({ source: "src/app.js", line: 1, column: 0 }) - .onCall(1) - .returns({ source: "src/utils.js", line: 2, column: 0 }); - - consumerMock.generatedPositionFor - .onCall(0) - .returns({ line: 1 }) // >= startLine + 1 (1 >= 1) -> pass - .onCall(1) - .returns({ line: 2 }); // >= startLine + 1 (2 >= 2) -> pass - - const result = utils.extractSourceFilesDeps(source, sourceMaps, coverages, "/root"); + it("should return empty set when there are no parsed segments", () => { + const result = utils.extractSourceFilesDeps( + { offset: [], filename: [] }, + mkCoverages([{ startOffset: 0, endOffset: 50 }]), + ); - assert.equal(result.size, 2); - assert.isTrue(result.has("src/app.js")); - assert.isTrue(result.has("src/utils.js")); + assert.equal(result.size, 0); }); - it("should handle empty coverages array", () => { - const source = "line1\nline2"; - - const result = utils.extractSourceFilesDeps(source, sourceMaps, [], "/root"); + it("should return empty set for empty coverages array", () => { + const result = utils.extractSourceFilesDeps(parsedMaps, []); assert.equal(result.size, 0); }); - it("should reject GREATEST_LOWER_BOUND source if generated position line is before startLine", () => { - const source = "line1\nline2\nline3"; - // startOffset 6 -> startLine 1 -> startLine + 1 = 2 - const coverages = mkCoverages([{ startOffset: 6, endOffset: 11 }]); + it("should attribute a single-file wrapper to its source file", () => { + const result = utils.extractSourceFilesDeps( + parsedMaps, + mkCoverages([{ startOffset: 210, endOffset: 290 }]), + ); - consumerMock.originalPositionFor - .withArgs(sinon.match({ bias: GREATEST_LOWER_BOUND })) - .returns({ source: "src/app.js", line: 5, column: 0 }); + assert.equal(result.size, 1); + assert.isTrue(result.has("b.js")); + }); - // generatedPosition.line (1) < startLine + 1 (2) -> fail - consumerMock.generatedPositionFor.returns({ line: 1 }); + it("should collect every source file a function range intersects", () => { + // [50, 250) spans a.js, the glue segment and b.js + const result = utils.extractSourceFilesDeps(parsedMaps, mkCoverages([{ startOffset: 50, endOffset: 250 }])); - consumerMock.originalPositionFor - .withArgs(sinon.match({ bias: LEAST_UPPER_BOUND })) - .returns({ source: null }); + assert.equal(result.size, 2); + assert.isTrue(result.has("a.js")); + assert.isTrue(result.has("b.js")); + }); - const result = utils.extractSourceFilesDeps(source, sourceMaps, coverages, "/root"); + it("should ignore the whole-bundle wrapper that spans the entire mapped range", () => { + const result = utils.extractSourceFilesDeps(parsedMaps, mkCoverages([{ startOffset: 0, endOffset: 400 }])); assert.equal(result.size, 0); }); - it("should not add source if generatedPositionFor returns null line for GREATEST_LOWER_BOUND", () => { - const source = "line1\nline2"; - const coverages = mkCoverages([{ startOffset: 0, endOffset: 5 }]); - - consumerMock.originalPositionFor - .withArgs(sinon.match({ bias: GREATEST_LOWER_BOUND })) - .returns({ source: "src/app.js", line: 1, column: 0 }); + it("should skip functions that were not executed (count === 0)", () => { + const result = utils.extractSourceFilesDeps( + parsedMaps, + mkCoverages([{ startOffset: 210, endOffset: 290, count: 0 }]), + ); - consumerMock.generatedPositionFor.returns({ line: null }); + assert.equal(result.size, 0); + }); - consumerMock.originalPositionFor - .withArgs(sinon.match({ bias: LEAST_UPPER_BOUND })) - .returns({ source: null }); + it("should skip functions without ranges", () => { + const coverages = [ + { + scriptId: "1", + url: "http://example.com/bundle.js", + functions: [{ functionName: "fn", ranges: [], isBlockCoverage: false }], + }, + ]; - const result = utils.extractSourceFilesDeps(source, sourceMaps, coverages, "/root"); + const result = utils.extractSourceFilesDeps(parsedMaps, coverages); assert.equal(result.size, 0); }); - it("should fall through to LEAST_UPPER_BOUND when GREATEST_LOWER_BOUND returns no source", () => { - const source = "line1\nline2\nline3"; - // endOffset 17 -> endLine 2 -> endLine + 1 = 3 - const coverages = mkCoverages([{ startOffset: 0, endOffset: 17 }]); - - consumerMock.originalPositionFor - .withArgs(sinon.match({ bias: GREATEST_LOWER_BOUND })) - .returns({ source: null }); + it("should not attribute glue-only ranges to any file", () => { + // [120, 180) lies entirely within the glue segment [100, 200) + const result = utils.extractSourceFilesDeps( + parsedMaps, + mkCoverages([{ startOffset: 120, endOffset: 180 }]), + ); - consumerMock.originalPositionFor - .withArgs(sinon.match({ bias: LEAST_UPPER_BOUND })) - .returns({ source: "src/wrapped.js", line: 1, column: 0 }); + assert.equal(result.size, 0); + }); - // generatedPosition.line (2) <= endLine + 1 (3) -> pass - consumerMock.generatedPositionFor.returns({ line: 2 }); + it("should filter sources by filter function, if provided", () => { + const filterFn = (file: string): boolean => file === "a.js"; - const result = utils.extractSourceFilesDeps(source, sourceMaps, coverages, "/root"); + const result = utils.extractSourceFilesDeps( + parsedMaps, + mkCoverages([{ startOffset: 50, endOffset: 250 }]), + filterFn, + ); assert.equal(result.size, 1); - assert.isTrue(result.has("src/wrapped.js")); + assert.isTrue(result.has("a.js")); }); + }); - it("should fall through to LEAST_UPPER_BOUND when GREATEST_LOWER_BOUND bounds check fails", () => { - const source = "line1\nline2\nline3"; - // startOffset 6 -> startLine 1, endOffset 17 -> endLine 2 - const coverages = mkCoverages([{ startOffset: 6, endOffset: 17 }]); - - consumerMock.originalPositionFor - .withArgs(sinon.match({ bias: GREATEST_LOWER_BOUND })) - .returns({ source: "src/wrong.js", line: 10, column: 0 }); - - consumerMock.originalPositionFor - .withArgs(sinon.match({ bias: LEAST_UPPER_BOUND })) - .returns({ source: "src/correct.js", line: 2, column: 0 }); + describe("parseSourceMapRanges", () => { + let getCachedSelectivityFileStub: SinonStub; + let setCachedSelectivityFileStub: SinonStub; + let utilsReal: typeof import("src/browser/cdp/selectivity/utils"); - consumerMock.generatedPositionFor - .onCall(0) - .returns({ line: 1 }) // GREATEST_LOWER_BOUND: 1 < startLine + 1 (2) -> fail - .onCall(1) - .returns({ line: 3 }); // LEAST_UPPER_BOUND: 3 <= endLine + 1 (3) -> pass + // Builds a source map JSON string with already-decoded mappings (TraceMap accepts both + // encoded VLQ strings and decoded segment arrays), so we avoid hand-writing VLQ. + const mkSourceMaps = (sources: string[], mappings: number[][][]): string => + JSON.stringify({ + version: 3, + file: "bundle.js", + names: [], + sourceRoot: "", + sources, + mappings, + }); - const result = utils.extractSourceFilesDeps(source, sourceMaps, coverages, "/root"); + beforeEach(() => { + getCachedSelectivityFileStub = sandbox.stub().resolves(null); + setCachedSelectivityFileStub = sandbox.stub().resolves(undefined); - assert.equal(result.size, 1); - assert.isTrue(result.has("src/correct.js")); + // Use the real "@jridgewell/trace-mapping" here (no override) so eachMapping actually walks segments + utilsReal = proxyquire("src/browser/cdp/selectivity/utils", { + fs: fsStub, + path: pathStub, + "../../../utils/fs": { + softFileURLToPath: softFileURLToPathStub, + }, + "./fs-cache": { + CacheType: { SourceMapParseResult: "source-map-parse-result" }, + getCachedSelectivityFile: getCachedSelectivityFileStub, + setCachedSelectivityFile: setCachedSelectivityFileStub, + }, + }); }); - it("should reject LEAST_UPPER_BOUND source if generated position line is after endLine", () => { - const source = "line1\nline2\nline3"; - // startOffset 0, endOffset 5 -> endLine 0 -> endLine + 1 = 1 - const coverages = mkCoverages([{ startOffset: 0, endOffset: 5 }]); - - consumerMock.originalPositionFor - .withArgs(sinon.match({ bias: GREATEST_LOWER_BOUND })) - .returns({ source: null }); - - consumerMock.originalPositionFor - .withArgs(sinon.match({ bias: LEAST_UPPER_BOUND })) - .returns({ source: "src/far.js", line: 1, column: 0 }); + it("should return the cached result without parsing when present", async () => { + const cached = { offset: [1, 2], filename: ["x.js", "y.js"] }; + getCachedSelectivityFileStub.resolves(JSON.stringify(cached)); - // generatedPosition.line (5) > endLine + 1 (1) -> fail - consumerMock.generatedPositionFor.returns({ line: 5 }); + const result = await utilsReal.parseSourceMapRanges("ignored source", "not even valid json", ""); - const result = utils.extractSourceFilesDeps(source, sourceMaps, coverages, "/root"); - - assert.equal(result.size, 0); + assert.deepEqual(result, cached); + assert.notCalled(setCachedSelectivityFileStub); }); - it("should filter sources by filter function, if provided", () => { - // source: "line1\nline2\nline3\nline4" - // offsets: line0 starts at 0, line1 at 6, line2 at 12, line3 at 18 - const source = "line1\nline2\nline3\nline4"; - const filterFn = (file: string): boolean => file.endsWith(".js"); - const coverages = [ - { - scriptId: "1", - url: "http://example.com/bundle.js", - functions: [ - { - functionName: "fn1", - ranges: [{ startOffset: 0, endOffset: 5, count: 1 }], - isBlockCoverage: false, - }, - { - functionName: "fn2", - ranges: [{ startOffset: 6, endOffset: 11, count: 1 }], - isBlockCoverage: false, - }, - ], - }, - ]; - - consumerMock.originalPositionFor - .withArgs(sinon.match({ bias: GREATEST_LOWER_BOUND })) - .onCall(0) - .returns({ source: "src/app.js", line: 1, column: 0 }) - .onCall(1) - .returns({ source: "webpack/startup", line: 2, column: 0 }); - - consumerMock.generatedPositionFor - .onCall(0) - .returns({ line: 1 }) // >= startLine + 1 (1 >= 1) -> pass - .onCall(1) - .returns({ line: 2 }); // >= startLine + 1 (2 >= 2) -> pass + it("should build sorted segment boundaries, coalescing consecutive same-source mappings", async () => { + const source = "AAA\nBBB\nCCC\nDDD"; + const sourceMaps = mkSourceMaps( + ["a.js", "b.js"], + [ + [[0, 0, 0, 0]], // genLine 1 -> a.js + [[0, 0, 1, 0]], // genLine 2 -> a.js (coalesced, no new boundary) + [[0, 1, 0, 0]], // genLine 3 -> b.js + [[0, 1, 1, 0]], // genLine 4 -> b.js (coalesced) + ], + ); - const result = utils.extractSourceFilesDeps(source, sourceMaps, coverages, "/root", filterFn); + const result = await utilsReal.parseSourceMapRanges(source, sourceMaps, ""); - assert.equal(result.size, 1); - assert.isTrue(result.has("src/app.js")); + assert.deepEqual(result, { offset: [0, 8], filename: ["a.js", "b.js"] }); + assert.calledWith(setCachedSelectivityFileStub, "source-map-parse-result", sourceMaps, sinon.match.string); }); - it("should process multiple ranges within a function and break on first match", () => { - const source = "line1\nline2\nline3"; - const coverages = mkCoverages([ - { startOffset: 0, endOffset: 5 }, - { startOffset: 6, endOffset: 11 }, - ]); + it("should record glue boundaries for generated-only mappings (no source)", async () => { + const source = "AAA\nBBB\nCCC"; + const sourceMaps = mkSourceMaps( + ["a.js", "b.js"], + [ + [[0, 0, 0, 0]], // genLine 1 -> a.js + [[0]], // genLine 2 -> generated only -> glue ("") + [[0, 1, 0, 0]], // genLine 3 -> b.js + ], + ); - // First range: GREATEST_LOWER_BOUND returns no source - // First range: LEAST_UPPER_BOUND returns no source - // Second range: GREATEST_LOWER_BOUND returns a source with valid bounds - consumerMock.originalPositionFor - .withArgs(sinon.match({ bias: GREATEST_LOWER_BOUND })) - .onCall(0) - .returns({ source: null }) - .onCall(1) - .returns({ source: "src/app.js", line: 2, column: 0 }); + const result = await utilsReal.parseSourceMapRanges(source, sourceMaps, ""); - consumerMock.originalPositionFor - .withArgs(sinon.match({ bias: LEAST_UPPER_BOUND })) - .onCall(0) - .returns({ source: null }); + assert.deepEqual(result, { offset: [0, 4, 8], filename: ["a.js", "", "b.js"] }); + }); - consumerMock.generatedPositionFor.returns({ line: 2 }); // >= startLine + 1 (2) -> pass + it("should skip mappings whose generated line is out of the source's range", async () => { + // source has a single line, but the map references a second generated line + const source = "AAA"; + const sourceMaps = mkSourceMaps( + ["a.js", "b.js"], + [ + [[0, 0, 0, 0]], // genLine 1 -> a.js + [[0, 1, 0, 0]], // genLine 2 -> out of range -> skipped + ], + ); - const result = utils.extractSourceFilesDeps(source, sourceMaps, coverages, "/root"); + const result = await utilsReal.parseSourceMapRanges(source, sourceMaps, ""); - assert.equal(result.size, 1); - assert.isTrue(result.has("src/app.js")); + assert.deepEqual(result, { offset: [0], filename: ["a.js"] }); }); }); @@ -882,8 +828,12 @@ describe("CDP/Selectivity/Utils", () => { utils = proxyquire("src/browser/cdp/selectivity/utils", { fs: fsStub, path: { ...pathStub, join: pathJoinStub }, - "source-map-js": { - SourceMapConsumer: SourceMapConsumerStub, + "@jridgewell/trace-mapping": { + TraceMap: traceMapStub, + originalPositionFor: originalPositionForStub, + generatedPositionFor: generatedPositionForStub, + GREATEST_LOWER_BOUND, + LEAST_UPPER_BOUND, }, "../../../utils/fs": { softFileURLToPath: softFileURLToPathStub, @@ -963,8 +913,12 @@ describe("CDP/Selectivity/Utils", () => { utils = proxyquire("src/browser/cdp/selectivity/utils", { fs: fsStub, path: { ...pathStub, join: pathJoinStub }, - "source-map-js": { - SourceMapConsumer: SourceMapConsumerStub, + "@jridgewell/trace-mapping": { + TraceMap: traceMapStub, + originalPositionFor: originalPositionForStub, + generatedPositionFor: generatedPositionForStub, + GREATEST_LOWER_BOUND, + LEAST_UPPER_BOUND, }, "../../../utils/fs": { softFileURLToPath: softFileURLToPathStub, diff --git a/test/src/error-snippets/source-maps.ts b/test/src/error-snippets/source-maps.ts index 1c375ef66..42aaab481 100644 --- a/test/src/error-snippets/source-maps.ts +++ b/test/src/error-snippets/source-maps.ts @@ -1,15 +1,31 @@ import sinon, { type SinonStub } from "sinon"; -import { MappedPosition, SourceMapConsumer } from "source-map-js"; -import { extractSourceMaps, resolveLocationWithSourceMap } from "./../../../src/error-snippets/source-maps"; +import proxyquire from "proxyquire"; +import { TraceMap } from "@jridgewell/trace-mapping"; import type { SufficientStackFrame, ResolvedFrame } from "../../../src/error-snippets/types"; describe("error-snippets/source-maps", () => { const sandbox = sinon.createSandbox(); let fetchStub: SinonStub; + let originalPositionForStub: SinonStub; + let sourceContentForStub: SinonStub; + let extractSourceMaps: typeof import("../../../src/error-snippets/source-maps").extractSourceMaps; + let resolveLocationWithSourceMap: typeof import("../../../src/error-snippets/source-maps").resolveLocationWithSourceMap; beforeEach(() => { fetchStub = sandbox.stub(globalThis, "fetch"); + originalPositionForStub = sandbox.stub(); + sourceContentForStub = sandbox.stub(); + + const sourceMaps = proxyquire("../../../src/error-snippets/source-maps", { + "@jridgewell/trace-mapping": { + originalPositionFor: originalPositionForStub, + sourceContentFor: sourceContentForStub, + }, + }); + + extractSourceMaps = sourceMaps.extractSourceMaps; + resolveLocationWithSourceMap = sourceMaps.resolveLocationWithSourceMap; }); afterEach(() => sandbox.restore()); @@ -36,10 +52,10 @@ describe("error-snippets/source-maps", () => { const result = await extractSourceMaps(fileContents, fileName); - assert.instanceOf(result, SourceMapConsumer); + assert.instanceOf(result, TraceMap); }); - it("should return a SourceMapConsumer instance if source maps comment is present in file content", async () => { + it("should return a TraceMap instance if source maps comment is present in file content", async () => { const inlineSourceMap = "data:application/json;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IiJ9"; const fileContents = `console.log("Hello, World!");\n//# sourceMappingURL=${inlineSourceMap}`; @@ -51,18 +67,19 @@ describe("error-snippets/source-maps", () => { const result = await extractSourceMaps(fileContents, fileName); - assert.instanceOf(result, SourceMapConsumer); + assert.instanceOf(result, TraceMap); }); }); describe("resolveLocationWithSourceMap", () => { it("should throw an error when source is null", async () => { - const sourceMaps = new SourceMapConsumer({ - version: 3 as unknown as string, + const sourceMaps = new TraceMap({ + version: 3, sources: [], names: [], mappings: "", }); + originalPositionForStub.returns({ source: null }); const stackFrame = { lineNumber: 5, columnNumber: 10 } as SufficientStackFrame; const fn = (): ResolvedFrame => resolveLocationWithSourceMap(stackFrame, sourceMaps); @@ -71,15 +88,15 @@ describe("error-snippets/source-maps", () => { }); it("should throw an error when line or column is null", async () => { - const sourceMaps = new SourceMapConsumer({ - // Specification says it should be number - version: 3 as unknown as string, + const sourceMaps = new TraceMap({ + version: 3, sources: ["file1"], names: [], mappings: "", sourcesContent: ["content"], }); - sandbox.stub(sourceMaps, "originalPositionFor").returns({ source: "file1" } as MappedPosition); + originalPositionForStub.returns({ source: "file1" }); + sourceContentForStub.returns("content"); const stackFrame = { lineNumber: 5, columnNumber: 10 } as SufficientStackFrame; const fn = (): ResolvedFrame => resolveLocationWithSourceMap(stackFrame, sourceMaps); @@ -88,17 +105,16 @@ describe("error-snippets/source-maps", () => { }); it("should return ResolvedFrame", async () => { - const sourceMaps = new SourceMapConsumer({ - version: 3 as unknown as string, + const sourceMaps = new TraceMap({ + version: 3, sources: ["file1"], names: [], mappings: "AAAA;AACA", sourcesContent: ["content"], file: "file:///file1", }); - sandbox - .stub(sourceMaps, "originalPositionFor") - .returns({ source: "file1", line: 100, column: 500 } as MappedPosition); + originalPositionForStub.returns({ source: "file1", line: 100, column: 500 }); + sourceContentForStub.returns("content"); const stackFrame = { lineNumber: 1, columnNumber: 1 } as SufficientStackFrame; const result = resolveLocationWithSourceMap(stackFrame, sourceMaps);