diff --git a/src/browser/cdp/selectivity/index.ts b/src/browser/cdp/selectivity/index.ts index 15196ee63..30077f7d4 100644 --- a/src/browser/cdp/selectivity/index.ts +++ b/src/browser/cdp/selectivity/index.ts @@ -144,8 +144,20 @@ export const clearUnusedSelectivityDumps = async (config: Config, isRunFailed: b } }; +// Pauses the renderer at page boundaries so JS coverage can be resolved/reset while "scriptId"s are still valid: +// - "page start": first thing on every new document, and on bfcache restore ("pageshow" with persisted=true), +// BEFORE the incoming page's scripts run — the point where the previous page is done and the next hasn't started; +// - "beforeunload": while the leaving page is still alive (needed to capture it before a cross-process isolate swap). +const testplaneCoveragePageStartScriptName = "__testplane_cdp_coverage_page_start"; const testplaneCoverageBreakScriptName = "__testplane_cdp_coverage_snapshot_pause"; -const scriptToEvaluateOnNewDocument = `window.addEventListener("beforeunload", function ${testplaneCoverageBreakScriptName}() {debugger;});`; +const scriptToEvaluateOnNewDocument = [ + "if (window.top === window) {", + ` function ${testplaneCoveragePageStartScriptName}() { debugger; }`, + ` ${testplaneCoveragePageStartScriptName}();`, + ` window.addEventListener("pageshow", function (e) { if (e.persisted) { ${testplaneCoveragePageStartScriptName}(); } });`, + ` window.addEventListener("beforeunload", function ${testplaneCoverageBreakScriptName}() { debugger; });`, + "}", +].join("\n"); export const startSelectivity = async (browser: ExistingBrowser): Promise => { const { enabled, compression, sourceRoot, testDependenciesPath, mapDependencyRelativePath, mapSourceMapUrl } = @@ -207,26 +219,43 @@ export const startSelectivity = async (browser: ExistingBrowser): Promise = Promise.resolve(); let isSelectivityStopped = false; + // A failure to process coverage means the test's dependencies would be incomplete. Missing a dependency is worse + // than failing the test (an incomplete dump silently under-runs later), so we remember the error and rethrow it + // from stopSelectivity — but still resume the renderer so the browser is never left paused. + let pageSwitchError: Error | null = null; + + const resume = (): void => { + cdp.debugger.resume(cdpSessionId).catch(() => {}); + }; const debuggerPausedFn = ({ callFrames }: DebuggerEvents["paused"], eventCdpSessionId?: CDPSessionId): void => { if (eventCdpSessionId !== cdpSessionId) { return; } - if (callFrames[0]?.functionName !== testplaneCoverageBreakScriptName || isSelectivityStopped) { - cdp.debugger.resume(cdpSessionId).catch(() => {}); + const functionName = callFrames[0]?.functionName; + const isBeforeUnloadPause = functionName === testplaneCoverageBreakScriptName; + const isPageStartPause = functionName === testplaneCoveragePageStartScriptName; + + if (isSelectivityStopped || (!isBeforeUnloadPause && !isPageStartPause)) { + resume(); return; } - pageSwitchPromise = pageSwitchPromise.finally(() => - Promise.all([cssSelectivity.takeCoverageSnapshot(), jsSelectivity.takeCoverageSnapshot()]) + pageSwitchPromise = pageSwitchPromise.finally(() => { + // "beforeunload": leaving page still alive — snapshot+resolve it (captures it before a cross-process swap). + // "page start" / bfcache restore: previous page done, next not started — resolve its tail, then reset + // coverage and clear the script maps so the next page starts with a clean window and id namespace. + const action = isBeforeUnloadPause + ? Promise.all([cssSelectivity.takeCoverageSnapshot(), jsSelectivity.takeCoverageSnapshot()]) + : jsSelectivity.flushPage(); + + return Promise.resolve(action) .catch(err => { - console.error("Selectivity: couldn't take snapshot while navigating:", err); + pageSwitchError ||= err instanceof Error ? err : new Error(String(err)); }) - .then(() => { - cdp.debugger.resume(cdpSessionId).catch(() => {}); - }), - ); + .then(resume); + }); }; cdp.debugger.on("paused", debuggerPausedFn); @@ -247,6 +276,10 @@ export const startSelectivity = async (browser: ExistingBrowser): Promise {}); + if (!drop && pageSwitchError) { + throw pageSwitchError; + } + if (jsDependenciesPromise.status === "rejected") { throw jsDependenciesPromise.reason; } diff --git a/src/browser/cdp/selectivity/js-selectivity.ts b/src/browser/cdp/selectivity/js-selectivity.ts index 4f6da27d0..dff1d1f23 100644 --- a/src/browser/cdp/selectivity/js-selectivity.ts +++ b/src/browser/cdp/selectivity/js-selectivity.ts @@ -18,10 +18,27 @@ import type { SelectivityMapSourceMapUrlFn } from "../../../config/types"; const SOURCE_CODE_EXTENSIONS = [".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".mts", ".cts"]; +const PRECISE_COVERAGE_PARAMS = { callCount: false, detailed: false, allowTriggeredUpdates: false } as const; + const isSourceCodeFile = (sourceFileName: string): boolean => { return SOURCE_CODE_EXTENSIONS.some(ext => sourceFileName.endsWith(ext)); }; +/** + * CDP "scriptId" is NOT stable across a test: it is reused for different scripts over time (a cross-process + * navigation restarts the counter; bfcache restore re-issues the previous document's ids; freed ids get reassigned + * to new scripts). "Profiler.takePreciseCoverage" reports only the raw "scriptId", with no way to tell which script + * a given id currently means. Therefore coverage can only be attributed correctly if it is resolved WHILE the + * scripts are still live and the "scriptId -> source" mapping is current. + * + * The orchestration in index.ts pauses the renderer at two boundaries and drives this class: + * - on "beforeunload": takeCoverageSnapshot() — resolve the leaving page while its isolate is still alive + * (required for cross-process navigation, where the old isolate is gone by the time the next document starts); + * - at the new document start (and bfcache "pageshow"): flushPage() — resolve whatever is left of the previous + * page (same-process, incl. code that ran during unload), then RESET precise coverage and CLEAR the script maps + * so the next page parses into an empty map and its coverage window cannot mix with the previous page's. + * Because the maps are cleared at every page boundary, plain "scriptId" keys are unambiguous within a page. + */ export class JSSelectivity { private readonly _cdp: CDP; private readonly _sessionId: CDPSessionId; @@ -30,11 +47,14 @@ export class JSSelectivity { private _debuggerOnScriptParsedFn: | ((params: DebuggerEvents["scriptParsed"], cdpSessionId?: CDPSessionId) => void) | null = null; - private readonly _scriptsSource: Record = {}; - private readonly _scriptsSourceMap: Record = {}; - private readonly _scriptIdToSourceUrl: Record = {}; - private readonly _scriptIdToSourceMapUrl: Record = {}; - private readonly _coverageResult: CDPScriptCoverage[] = []; + private _scriptsSource: Record = {}; + private _scriptsSourceMap: Record = {}; + private _scriptIdToSourceUrl: Record = {}; + private _scriptIdToSourceMapUrl: Record = {}; + /** Last seen content hash per "scriptId" — used to detect a "scriptId" reused for a different script */ + private _scriptIdToHash: Record = {}; + /** Source files collected across all pages of the current test */ + private readonly _dependingSourceFiles = new Set(); constructor( cdp: CDP, @@ -49,13 +69,27 @@ export class JSSelectivity { } private _processScript( - { scriptId, url, sourceMapURL }: DebuggerEvents["scriptParsed"], + { scriptId, url, sourceMapURL, hash }: DebuggerEvents["scriptParsed"], cdpSessionId?: CDPSessionId, ): void { if (!this._sessionId || cdpSessionId !== this._sessionId) { return; } + // A "scriptId" can be reused for a DIFFERENT script within a single page window (a cross-process forward + // navigation may restart ids before the next boundary clears the maps). Detect reuse by content hash and drop + // the now-stale mapping, so the "||=" below repopulates it for the new script instead of keeping the old one. + const previousHash = this._scriptIdToHash[scriptId]; + + if (previousHash !== undefined && previousHash !== hash) { + delete this._scriptsSource[scriptId]; + delete this._scriptsSourceMap[scriptId]; + delete this._scriptIdToSourceUrl[scriptId]; + delete this._scriptIdToSourceMapUrl[scriptId]; + } + + this._scriptIdToHash[scriptId] = hash; + this._scriptIdToSourceUrl[scriptId] ||= url; if (!url || !sourceMapURL || url.startsWith("chrome-error://")) { @@ -248,19 +282,92 @@ export class JSSelectivity { ]); } + /** + * Resolves the given coverage into original source files and accumulates them into "_dependingSourceFiles". + * Must run while the "scriptId -> source" maps still describe the scripts the coverage was taken from + * (i.e. before the page is navigated away and the maps are cleared). + */ + private async _resolveCoverageToDeps(coverageScripts: CDPScriptCoverage[]): Promise { + const grouppedByScriptCoverage = groupBy(coverageScripts, "scriptId"); + const scriptIds = Object.keys(grouppedByScriptCoverage); + + await Promise.all( + scriptIds.map(async scriptId => { + const [source, sourceMaps] = await Promise.all([ + this._scriptsSource[scriptId], + this._scriptsSourceMap[scriptId], + ]); + const sourceMapUrl = this._scriptIdToSourceMapUrl[scriptId]; + // Every "scriptId" has only one uniq "url" + const sourceUrl = this._scriptIdToSourceUrl[scriptId] || grouppedByScriptCoverage[scriptId][0].url; + + // Function was called, but source maps were not generated for the file + // Or its anonymous script, without source url + if (!source || !sourceMaps || !sourceUrl) { + return; + } + + if (source instanceof Error) { + throw new Error( + [`JS Selectivity: Couldn't load source code from ${sourceUrl}:`, String(source)].join("\n"), + ); + } + + if (sourceMaps instanceof Error) { + throw new Error( + [`JS Selectivity: Couldn't load source maps from ${sourceMapUrl}`, String(sourceMaps)].join( + "\n", + ), + ); + } + + if (isCachedOnFs(sourceMaps) && !sourceMapUrl) { + throw new Error("Assertation failed: souce map url has to present if source maps are fs-cached"); + } + + const [sourceString, sourceMapsString] = await Promise.all([ + isCachedOnFs(source) ? getCachedSelectivityFile(CacheType.Asset, sourceUrl) : source, + isCachedOnFs(sourceMaps) + ? getCachedSelectivityFile(CacheType.Asset, sourceMapUrl as string) + : sourceMaps, + ]); + + if (!sourceString || !sourceMapsString) { + throw new Error(`JS Selectivity: fs-cache is broken for ${sourceUrl}`); + } + + const parsedSourceMapRanges = await parseSourceMapRanges( + sourceString, + sourceMapsString, + this._sourceRoot, + ); + const dependingSourceFiles = extractSourceFilesDeps( + parsedSourceMapRanges, + grouppedByScriptCoverage[scriptId], + isSourceCodeFile, + ); + + for (const sourceFile of dependingSourceFiles.values()) { + this._dependingSourceFiles.add(sourceFile); + } + }), + ); + } + async start(): Promise { const debuggerOnScriptParsedFn = (this._debuggerOnScriptParsedFn = this._processScript.bind(this)); const sessionId = this._sessionId; this._cdp.debugger.on("scriptParsed", debuggerOnScriptParsedFn); - await this._cdp.profiler.startPreciseCoverage(sessionId, { - callCount: false, - detailed: false, - allowTriggeredUpdates: false, - }); + await this._cdp.profiler.startPreciseCoverage(sessionId, PRECISE_COVERAGE_PARAMS); } + /** + * Called on "beforeunload", while the leaving page is still alive (its isolate not yet destroyed). Resolves the + * page's coverage into dependencies. Does NOT reset coverage or clear maps — that happens later, at the next + * page boundary (flushPage), once the leaving page has fully finished executing. + */ async takeCoverageSnapshot(): Promise { const coveragePart = await this._cdp.profiler.takePreciseCoverage(this._sessionId); @@ -268,7 +375,40 @@ export class JSSelectivity { await this._waitForLoadingScripts(); - this._coverageResult.push(...coveragePart.result); + // Missing a dependency is worse than failing the test, so resolution errors are propagated (and turned into a + // test failure by the orchestrator in index.ts), never swallowed into a partial dump. + await this._resolveCoverageToDeps(coveragePart.result); + } + + /** + * Called at a page boundary — the new document's start, and on bfcache "pageshow" — while the renderer is paused + * before the incoming page's scripts run. Resolves whatever is left of the previous page (same-process code that + * ran during unload), then RESETS precise coverage and CLEARS the script maps. This guarantees the next page's + * coverage window and "scriptId" namespace start empty, so reused ids cannot be mistaken for the previous page's. + */ + async flushPage(): Promise { + const coveragePart = await this._cdp.profiler.takePreciseCoverage(this._sessionId); + + this._ensureScriptsAreLoading(coveragePart.result); + + await this._waitForLoadingScripts(); + + // Missing a dependency is worse than failing the test, so resolution and coverage-reset errors are + // propagated (turned into a test failure by index.ts), never swallowed into a partial dump. + await this._resolveCoverageToDeps(coveragePart.result); + + // Reset coverage so the next page's window starts empty (a persisting same-process isolate would otherwise + // keep re-reporting this page's scripts under ids that the next page reuses). + await this._cdp.profiler.stopPreciseCoverage(this._sessionId); + await this._cdp.profiler.startPreciseCoverage(this._sessionId, PRECISE_COVERAGE_PARAMS); + + // Clear the script maps so the next page parses into an empty namespace and reused "scriptId"s can't hit a + // stale mapping from the previous page. + this._scriptsSource = {}; + this._scriptsSourceMap = {}; + this._scriptIdToSourceUrl = {}; + this._scriptIdToSourceMapUrl = {}; + this._scriptIdToHash = {}; } /** @param drop only performs cleanup without providing actual deps. Should be "true" if test is failed */ @@ -279,79 +419,14 @@ export class JSSelectivity { } const coverageLastPart = await this._cdp.profiler.takePreciseCoverage(this._sessionId); - const coverageScripts = [...this._coverageResult, ...coverageLastPart.result]; this._ensureScriptsAreLoading(coverageLastPart.result); - const totalDependingSourceFiles = new Set(); - const grouppedByScriptCoverage = groupBy(coverageScripts, "scriptId"); - const scriptIds = Object.keys(grouppedByScriptCoverage); - - await Promise.all( - scriptIds.map(async scriptId => { - const [source, sourceMaps] = await Promise.all([ - this._scriptsSource[scriptId], - this._scriptsSourceMap[scriptId], - ]); - const sourceMapUrl = this._scriptIdToSourceMapUrl[scriptId]; - // Every "scriptId" has only one uniq "url" - const sourceUrl = this._scriptIdToSourceUrl[scriptId] || grouppedByScriptCoverage[scriptId][0].url; - - // Function was called, but source maps were not generated for the file - // Or its anonymous script, without source url - if (!source || !sourceMaps || !sourceUrl) { - return; - } - - if (source instanceof Error) { - throw new Error( - [`JS Selectivity: Couldn't load source code from ${sourceUrl}:`, String(source)].join("\n"), - ); - } - - if (sourceMaps instanceof Error) { - throw new Error( - [`JS Selectivity: Couldn't load source maps from ${sourceMapUrl}`, String(sourceMaps)].join( - "\n", - ), - ); - } - - if (isCachedOnFs(sourceMaps) && !sourceMapUrl) { - throw new Error( - "Assertation failed: souce map url has to present if source maps are fs-cached", - ); - } - - const [sourceString, sourceMapsString] = await Promise.all([ - isCachedOnFs(source) ? getCachedSelectivityFile(CacheType.Asset, sourceUrl) : source, - isCachedOnFs(sourceMaps) - ? getCachedSelectivityFile(CacheType.Asset, sourceMapUrl as string) - : sourceMaps, - ]); - - if (!sourceString || !sourceMapsString) { - throw new Error(`JS Selectivity: fs-cache is broken for ${sourceUrl}`); - } - - const parsedSourceMapRanges = await parseSourceMapRanges( - sourceString, - sourceMapsString, - this._sourceRoot, - ); - const dependingSourceFiles = extractSourceFilesDeps( - parsedSourceMapRanges, - grouppedByScriptCoverage[scriptId], - isSourceCodeFile, - ); + await this._waitForLoadingScripts(); - for (const sourceFile of dependingSourceFiles.values()) { - totalDependingSourceFiles.add(sourceFile); - } - }), - ); + await this._resolveCoverageToDeps(coverageLastPart.result); - return totalDependingSourceFiles; + return this._dependingSourceFiles; } finally { this._debuggerOnScriptParsedFn && this._cdp.debugger.off("scriptParsed", this._debuggerOnScriptParsedFn); } diff --git a/test/src/browser/cdp/selectivity/index.ts b/test/src/browser/cdp/selectivity/index.ts index fe8d3929c..90f0b73f4 100644 --- a/test/src/browser/cdp/selectivity/index.ts +++ b/test/src/browser/cdp/selectivity/index.ts @@ -29,7 +29,12 @@ describe("CDP/Selectivity", () => { }; let cssSelectivityMock: { start: SinonStub; stop: SinonStub; takeCoverageSnapshot: SinonStub }; - let jsSelectivityMock: { start: SinonStub; stop: SinonStub; takeCoverageSnapshot: SinonStub }; + let jsSelectivityMock: { + start: SinonStub; + stop: SinonStub; + takeCoverageSnapshot: SinonStub; + flushPage: SinonStub; + }; let testDependenciesWriterMock: { saveFor: SinonStub }; let hashWriterMock: { addTestDependencyHashes: SinonStub; addPatternDependencyHash: SinonStub; save: SinonStub }; let hashReaderMock: { patternHasChanged: SinonStub; getTestChangedDeps: SinonStub }; @@ -60,7 +65,7 @@ describe("CDP/Selectivity", () => { dom: { enable: SinonStub }; css: { enable: SinonStub }; debugger: { enable: SinonStub; on: SinonStub; off: SinonStub; resume: SinonStub }; - page: { enable: SinonStub; addScriptToEvaluateOnNewDocument: SinonStub }; + page: { enable: SinonStub; addScriptToEvaluateOnNewDocument: SinonStub; on: SinonStub; off: SinonStub }; profiler: { enable: SinonStub }; } | null; }; @@ -75,6 +80,7 @@ describe("CDP/Selectivity", () => { start: sandbox.stub().resolves(), stop: sandbox.stub().resolves(new Set(["src/app.js"])), takeCoverageSnapshot: sandbox.stub().resolves(), + flushPage: sandbox.stub().resolves(), }; testDependenciesWriterMock = { saveFor: sandbox.stub().resolves(), @@ -158,6 +164,8 @@ describe("CDP/Selectivity", () => { page: { enable: sandbox.stub().resolves(), addScriptToEvaluateOnNewDocument: sandbox.stub().resolves(), + on: sandbox.stub(), + off: sandbox.stub(), }, profiler: { enable: sandbox.stub().resolves() }, }, @@ -327,6 +335,32 @@ describe("CDP/Selectivity", () => { assert.notCalled(jsSelectivityMock.takeCoverageSnapshot); }); + it("should add page-start (doc-start + bfcache pageshow) pauses to the injected script", async () => { + await startSelectivity(browserMock as unknown as ExistingBrowser); + + const source = browserMock.cdp!.page.addScriptToEvaluateOnNewDocument.args[0][1].source; + + assert.include(source, "function __testplane_cdp_coverage_page_start()"); + assert.include(source, "window.top === window"); + assert.include(source, "pageshow"); + assert.include(source, "e.persisted"); + }); + + it("should flush the JS page (not css) when debugger pauses on the page-start handler", async () => { + await startSelectivity(browserMock as unknown as ExistingBrowser); + + const pausedHandler = browserMock.cdp!.debugger.on.getCall(0).args[1]; + + pausedHandler({ callFrames: [{ functionName: "__testplane_cdp_coverage_page_start" }] }, "session-123"); + + await new Promise(resolve => setTimeout(resolve, 10)); + + assert.calledOnce(jsSelectivityMock.flushPage); + assert.notCalled(jsSelectivityMock.takeCoverageSnapshot); + assert.notCalled(cssSelectivityMock.takeCoverageSnapshot); + assert.calledWith(browserMock.cdp!.debugger.resume, "session-123"); + }); + it("should handle window handle containing target ID", async () => { browserMock.publicAPI.getWindowHandle.resolves("CDwindow-target-123-suffix"); browserMock.cdp!.target.getTargets.resolves({ @@ -415,6 +449,30 @@ describe("CDP/Selectivity", () => { await assert.isRejected(stopFn(mockTest, false), "JS error"); }); + it("should fail the stop when flushPage errors on a page-start pause (never swallow)", async () => { + jsSelectivityMock.flushPage.rejects(new Error("flush boom")); + + const pausedHandler = browserMock.cdp!.debugger.on.getCall(0).args[1]; + pausedHandler({ callFrames: [{ functionName: "__testplane_cdp_coverage_page_start" }] }, "session-123"); + + // Let the paused-handler chain settle (capture error + resume) + await new Promise(resolve => setTimeout(resolve, 10)); + + assert.calledWith(browserMock.cdp!.debugger.resume, "session-123"); + await assert.isRejected(stopFn(mockTest, false), "flush boom"); + }); + + it("should NOT fail the stop on a page-start error when dropping", async () => { + jsSelectivityMock.flushPage.rejects(new Error("flush boom")); + + const pausedHandler = browserMock.cdp!.debugger.on.getCall(0).args[1]; + pausedHandler({ callFrames: [{ functionName: "__testplane_cdp_coverage_page_start" }] }, "session-123"); + + await new Promise(resolve => setTimeout(resolve, 10)); + + await stopFn(mockTest, true); + }); + it("should handle test dependencies writer errors", async () => { testDependenciesWriterMock.saveFor.rejects(new Error("Save error")); diff --git a/test/src/browser/cdp/selectivity/js-selectivity.ts b/test/src/browser/cdp/selectivity/js-selectivity.ts index f51151cec..de5cb2efe 100644 --- a/test/src/browser/cdp/selectivity/js-selectivity.ts +++ b/test/src/browser/cdp/selectivity/js-selectivity.ts @@ -46,6 +46,7 @@ describe("CDP/Selectivity/JSSelectivity", () => { profiler: { enable: sandbox.stub().resolves(), startPreciseCoverage: sandbox.stub().resolves(), + stopPreciseCoverage: sandbox.stub().resolves(), takePreciseCoverage: sandbox.stub().resolves({ result: [] }), } as SinonStubbedInstance, runtime: {} as SinonStubbedInstance, @@ -333,6 +334,117 @@ describe("CDP/Selectivity/JSSelectivity", () => { }); }); + describe("page boundaries", () => { + const coverageForScript = (scriptId: string, url: string): any => ({ + timestamp: 1, + result: [ + { + scriptId, + url, + functions: [ + { + functionName: "f", + isBlockCoverage: false, + ranges: [{ startOffset: 0, endOffset: 10, count: 1 }], + }, + ], + }, + ], + }); + + it("takeCoverageSnapshot should NOT reset precise coverage (leaving page still alive)", async () => { + const jsSelectivity = new JSSelectivity(cdpMock as unknown as CDP, sessionId, sourceRoot, null); + + await jsSelectivity.start(); + await jsSelectivity.takeCoverageSnapshot(); + + assert.notCalled(cdpMock.profiler.stopPreciseCoverage); + }); + + it("flushPage should reset precise coverage", async () => { + const jsSelectivity = new JSSelectivity(cdpMock as unknown as CDP, sessionId, sourceRoot, null); + + await jsSelectivity.start(); + await jsSelectivity.flushPage(); + + assert.calledOnceWith(cdpMock.profiler.stopPreciseCoverage, sessionId); + // once in start(), once when resetting in flushPage + assert.calledTwice(cdpMock.profiler.startPreciseCoverage); + assert.alwaysCalledWith(cdpMock.profiler.startPreciseCoverage, sessionId, { + callCount: false, + detailed: false, + allowTriggeredUpdates: false, + }); + }); + + it("flushPage should clear script maps so a reused scriptId is treated as a new script", async () => { + const jsSelectivity = new JSSelectivity(cdpMock as unknown as CDP, sessionId, sourceRoot, null); + + await jsSelectivity.start(); + + const scriptParsedHandler = cdpMock.debugger.on.getCall(0).args[1]; + + // Page 1: script "10" belongs to http://page1/app.js + scriptParsedHandler({ scriptId: "10", url: "http://page1/app.js", sourceMapURL: "app.js.map" }, sessionId); + + await jsSelectivity.flushPage(); + + // Page 2 reuses scriptId "10" for a different script + scriptParsedHandler({ scriptId: "10", url: "http://page2/app.js", sourceMapURL: "app.js.map" }, sessionId); + + cdpMock.profiler.takePreciseCoverage.resolves({ timestamp: 2, result: [] }); + await jsSelectivity.stop(); + + // Maps were cleared on flushPage, so page 2's "10" is fetched fresh instead of reusing page 1's mapping + assert.calledTwice(cdpMock.debugger.getScriptSource); + assert.alwaysCalledWith(cdpMock.debugger.getScriptSource, sessionId, "10"); + }); + + it("should re-resolve a scriptId reused for different content (hash change) within one page", async () => { + const jsSelectivity = new JSSelectivity(cdpMock as unknown as CDP, sessionId, sourceRoot, null); + + await jsSelectivity.start(); + + const scriptParsedHandler = cdpMock.debugger.on.getCall(0).args[1]; + + // Same scriptId "10", first script (hash AAA) + scriptParsedHandler( + { scriptId: "10", url: "http://page/a.js", sourceMapURL: "a.js.map", hash: "AAA" }, + sessionId, + ); + + // scriptId "10" reused for a DIFFERENT script (hash BBB) — no page boundary in between + scriptParsedHandler( + { scriptId: "10", url: "http://page/b.js", sourceMapURL: "b.js.map", hash: "BBB" }, + sessionId, + ); + + cdpMock.profiler.takePreciseCoverage.resolves({ timestamp: 1, result: [] }); + await jsSelectivity.stop(); + + // Hash change drops the stale mapping, so the new script is fetched fresh instead of reusing a.js + assert.calledTwice(cdpMock.debugger.getScriptSource); + assert.alwaysCalledWith(cdpMock.debugger.getScriptSource, sessionId, "10"); + }); + + it("should propagate (not swallow) when coverage fails to resolve", async () => { + hasCachedSelectivityFileStub.resolves(false); + getCachedSelectivityFileStub.resolves(null); + cdpMock.debugger.getScriptSource.rejects(new Error("boom")); + cdpMock.profiler.takePreciseCoverage.resolves(coverageForScript("3", "http://page1/app.js")); + + const jsSelectivity = new JSSelectivity(cdpMock as unknown as CDP, sessionId, sourceRoot, null); + + await jsSelectivity.start(); + + // Missing a dependency is worse than failing the test — the error must surface, not be swallowed + await assert.isRejected( + jsSelectivity.takeCoverageSnapshot(), + "JS Selectivity: Couldn't load source code from http://page1/app.js", + ); + }); + }); + describe("stop", () => { it("should return empty array when drop is true", async () => { const jsSelectivity = new JSSelectivity(cdpMock as unknown as CDP, sessionId, sourceRoot, null);