From fc20fb7567273018cf284b88b3c69b71da24443b Mon Sep 17 00:00:00 2001 From: quality Date: Fri, 9 Oct 2026 08:53:01 -0400 Subject: [PATCH] test(e2e): credit coverage for runs that span a source boundary normalizeRanges rewrites a V8 record into the coarsest partition it can: one range per run of equal counts, however far that run reaches. In a bundle carrying several src/** modules such a run routinely starts in one module and ends in another, and v8-to-istanbul abandons it outright -- offsetToOriginalRelative returns {} when a range's start and end map to different originals, so applyCoverage records no branch for either module. convertScript builds attributedPaths from exactly those branches, so the multi-source guard in collectE2ECoverage then skips every source of the script and coverage a browser genuinely executed is dropped. Split each normalized range at the generated offsets where the source map changes source, ending each sub-range one offset short of the cut so its end position stays on the source side of the boundary. A 1:1 bundle has no boundaries and is left byte-identical. Replayed against the e2e-coverage artifact of run 37914482229 at b00ff4e: src regions move from 447/466 (95.92%) to 649/665 (97.59%), uncovered regions fall from 19 to 16, src/components/MemberDirectory/DirectoryFreshness.js drops lines 30, 44 and 49, and src/components/MemberDirectory/utils.js becomes measurable for the first time. Line coverage is unchanged at 100%. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: quality --- tests/e2e-coverage-source-boundary.test.mjs | 210 ++++++++++++++++++++ tests/tools/e2e-coverage-report.mjs | 75 ++++++- 2 files changed, 283 insertions(+), 2 deletions(-) create mode 100644 tests/e2e-coverage-source-boundary.test.mjs diff --git a/tests/e2e-coverage-source-boundary.test.mjs b/tests/e2e-coverage-source-boundary.test.mjs new file mode 100644 index 00000000..91f45088 --- /dev/null +++ b/tests/e2e-coverage-source-boundary.test.mjs @@ -0,0 +1,210 @@ +// A bundle carries many `src/**` modules in one script, and `normalizeRanges` +// (tests/tools/e2e-coverage-report.mjs) rewrites a V8 record into the coarsest +// partition it can: one range per run of equal counts, however far that run +// reaches. A page that executes an uninterrupted stretch of the bundle +// therefore produces a single range whose start lies in one original module +// and whose end lies in another. +// +// v8-to-istanbul cannot place such a range. `offsetToOriginalRelative` +// (v8-to-istanbul lib/source.js) maps the range's start and end back through +// the source map and abandons the range outright when they disagree: +// +// if (start.source !== end.source) { +// return {} +// } +// +// `applyCoverage` then returns before recording a branch, so neither module is +// credited. Because `convertScript` builds `attributedPaths` from exactly the +// branches and functions that were recorded, the multi-source guard in +// `collectE2ECoverage` +// +// if (converted.sourceFiles.length > 1 && !converted.attributedPaths.has(path)) +// +// goes on to skip *every* source of that script. Coverage that a browser +// genuinely executed is dropped on the floor, which is what made fixture pages +// and variant builds added to reach a rare arm buy nothing measurable (#1236). +// +// These tests pin the two halves of the contract: a range that spans a source +// boundary credits both modules, and a 1:1 bundle -- which has no boundaries to +// cut at -- is left exactly as it was. +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { collectE2ECoverage } from './tools/e2e-coverage-report.mjs'; +import { + initCoverageRun, + sealCoverageRun, + writeCoverageArtifact, +} from './tools/e2e-coverage-run.mjs'; + +const ALPHA = 'const alpha = 1;\nconst alphaRare = 2;\n'; +const BETA = 'const beta = 1;\nconst betaRare = 2;\n'; +const ALPHA_PATH = 'src/components/Alpha/index.js'; +const BETA_PATH = 'src/components/Beta/index.js'; + +// One identity mapping per generated line, each at column 0. 'AAAA' is +// [generatedColumn 0, source 0, originalLine 0, originalColumn 0]; 'AACA' +// advances the original line by one; 'ACDA' advances the source index by one +// and rewinds the original line to zero. Generated lines 1-2 are Alpha's two +// lines and generated lines 3-4 are Beta's, so the only source boundary in the +// script is the start of generated line 3. +const TWO_SOURCE_MAPPINGS = 'AAAA;AACA;ACDA;AACA'; +const ONE_SOURCE_MAPPINGS = 'AAAA;AACA'; + +async function fixture({ sources, mappings, generated }) { + const root = await mkdtemp(join(tmpdir(), 'endusers-source-boundary-')); + const runDir = join(root, 'coverage'); + const buildDir = join(root, 'build'); + for (const source of sources) { + await mkdir(join(root, source.path, '..'), { recursive: true }); + await writeFile(join(root, source.path), source.text); + } + const script = `${generated}\n//# sourceMappingURL=app.js.map\n`; + await mkdir(join(buildDir, 'assets/js'), { recursive: true }); + await writeFile(join(buildDir, 'assets/js/app.js'), script); + await writeFile( + join(buildDir, 'assets/js/app.js.map'), + JSON.stringify({ + version: 3, + file: 'app.js', + sources: sources.map((source) => `webpack://endusers/./${source.path}`), + sourcesContent: sources.map((source) => source.text), + names: [], + mappings, + }), + ); + await initCoverageRun(runDir, 'run-1', { project: 'chromium' }); + return { root, runDir, buildDir, script }; +} + +async function writeWholeScriptHit(runDir, script) { + await writeCoverageArtifact(runDir, 'capture-0', { + schemaVersion: 1, + kind: 'endusers.playwright.v8-coverage', + runId: 'run-1', + result: [ + { + url: 'http://localhost:3000/assets/js/app.js', + scriptId: '1', + functions: [ + { + functionName: '', + isBlockCoverage: true, + ranges: [{ startOffset: 0, endOffset: script.length, count: 1 }], + }, + ], + }, + ], + }); +} + +test('a run spanning a source boundary credits both original modules', async () => { + const context = await fixture({ + sources: [ + { path: ALPHA_PATH, text: ALPHA }, + { path: BETA_PATH, text: BETA }, + ], + mappings: TWO_SOURCE_MAPPINGS, + generated: ALPHA + BETA, + }); + await writeWholeScriptHit(context.runDir, context.script); + await sealCoverageRun(context.runDir, 'passed'); + + const report = await collectE2ECoverage(context.runDir, { + root: context.root, + buildDir: context.buildDir, + }); + + // Before the split this call threw `No src/** coverage was attributable`: + // the one range covering the whole script was discarded, so neither module + // reached `report.sources` at all. + assert.deepEqual(report.sources.map((source) => source.file).sort(), [ + ALPHA_PATH, + BETA_PATH, + ]); + for (const source of report.sources) { + assert.equal(source.linePercent, 100, source.file); + assert.equal(source.regionPercent, 100, source.file); + assert.deepEqual(source.uncoveredRegions, [], source.file); + } +}); + +test('a single-source bundle has no boundary to cut at and is unchanged', async () => { + const context = await fixture({ + sources: [{ path: ALPHA_PATH, text: ALPHA }], + mappings: ONE_SOURCE_MAPPINGS, + generated: ALPHA, + }); + await writeWholeScriptHit(context.runDir, context.script); + await sealCoverageRun(context.runDir, 'passed'); + + const report = await collectE2ECoverage(context.runDir, { + root: context.root, + buildDir: context.buildDir, + }); + + assert.equal(report.sources.length, 1); + const [source] = report.sources; + assert.equal(source.file, ALPHA_PATH); + assert.equal(source.executableLines, 2); + assert.equal(source.coveredLines, 2); + // One range in, one region out: the split must not fragment a script that + // maps to a single original file. + assert.equal(source.regions, 1); + assert.equal(source.coveredRegions, 1); +}); + +test('a zero-count run spanning a boundary is charged to both modules', async () => { + const context = await fixture({ + sources: [ + { path: ALPHA_PATH, text: ALPHA }, + { path: BETA_PATH, text: BETA }, + ], + mappings: TWO_SOURCE_MAPPINGS, + generated: ALPHA + BETA, + }); + // Line 2 of Alpha through line 1 of Beta never ran: one contiguous + // zero-count run across the boundary, bracketed by executed code so the + // surrounding ranges exist too. + const zeroStart = ALPHA.indexOf('const alphaRare'); + const zeroEnd = ALPHA.length + BETA.indexOf('const betaRare'); + await writeCoverageArtifact(context.runDir, 'capture-0', { + schemaVersion: 1, + kind: 'endusers.playwright.v8-coverage', + runId: 'run-1', + result: [ + { + url: 'http://localhost:3000/assets/js/app.js', + scriptId: '1', + functions: [ + { + functionName: '', + isBlockCoverage: true, + ranges: [ + { + startOffset: 0, + endOffset: context.script.length, + count: 1, + }, + { startOffset: zeroStart, endOffset: zeroEnd, count: 0 }, + ], + }, + ], + }, + ], + }); + await sealCoverageRun(context.runDir, 'passed'); + + const report = await collectE2ECoverage(context.runDir, { + root: context.root, + buildDir: context.buildDir, + }); + + const byFile = new Map(report.sources.map((source) => [source.file, source])); + assert.deepEqual([...byFile.keys()].sort(), [ALPHA_PATH, BETA_PATH]); + assert.deepEqual(byFile.get(ALPHA_PATH).uncoveredLines, [2]); + assert.deepEqual(byFile.get(BETA_PATH).uncoveredLines, [1]); +}); diff --git a/tests/tools/e2e-coverage-report.mjs b/tests/tools/e2e-coverage-report.mjs index ce643b28..6f3b2c7a 100644 --- a/tests/tools/e2e-coverage-report.mjs +++ b/tests/tools/e2e-coverage-report.mjs @@ -6,7 +6,7 @@ import { dirname, join, relative, resolve, sep } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import v8ToIstanbul from 'v8-to-istanbul'; -import { AnyMap, encodedMap } from '@jridgewell/trace-mapping'; +import { AnyMap, decodedMap, encodedMap } from '@jridgewell/trace-mapping'; import { countsForScript, enumerateSourceFiles } from './coverage-report.mjs'; import { @@ -499,6 +499,75 @@ async function locateScript(scriptRoots, pathname, url) { ); } +// `normalizeRanges` rewrites a V8 record into the coarsest partition it can: +// one range per run of equal counts, however far that run reaches. In a bundle +// that carries several `src/**` modules such a run routinely starts in one +// module and ends in another, and v8-to-istanbul cannot place it. It picks the +// original source of the range's *start* and then converts the range's end -- +// a position in a different source -- into that source's coordinates, which +// lands past its last line, so `sliceRange` returns no lines and the range is +// dropped without recording a branch anywhere (v8-to-istanbul applyCoverage, +// `if (!lines.length) return`). Neither the starting module nor the module the +// run actually ended in is credited, and because `attributedPaths` is built +// from the branches that were recorded, every source of the script is then +// skipped by the multi-source guard in `collectE2ECoverage`. +// +// Splitting each range at the generated offsets where the source map changes +// source keeps every sub-range inside one original file, which is the only +// shape v8-to-istanbul can attribute. A single-source map yields no boundaries +// and no splits, so 1:1 bundles are byte-identical to before. +function sourceBoundaryOffsets(map, mappedSource) { + const lineStarts = [0]; + for (let index = 0; index < mappedSource.length; index += 1) { + if (mappedSource[index] === '\n') lineStarts.push(index + 1); + } + const boundaries = new Set(); + let previousSource = null; + for (const [line, segments] of decodedMap( + new AnyMap(map), + ).mappings.entries()) { + for (const segment of segments) { + if (segment.length < 4) continue; + const source = segment[1]; + if (previousSource !== null && source !== previousSource) { + const lineStart = lineStarts[line]; + if (lineStart !== undefined) { + const offset = Math.min(lineStart + segment[0], mappedSource.length); + if (offset > 0) boundaries.add(offset); + } + } + previousSource = source; + } + } + return [...boundaries].sort((a, b) => a - b); +} + +function splitRangeAtBoundaries(range, boundaries) { + const cuts = boundaries.filter( + (offset) => offset > range.startOffset && offset < range.endOffset, + ); + if (cuts.length === 0) return [range]; + const parts = []; + let start = range.startOffset; + for (const cut of cuts) { + // The sub-range must end *inside* the source it starts in: v8-to-istanbul + // abandons a range whose start and end map to different originals + // (source.js `if (start.source !== end.source) return {}`), and the first + // offset at a boundary already belongs to the next source. Ending one + // offset short keeps the end position on the source side of the cut; that + // offset is not lost, because it opens the following sub-range. + const end = cut - 1; + if (end > start) { + parts.push({ ...range, startOffset: start, endOffset: end }); + } + start = cut; + } + if (range.endOffset > start) { + parts.push({ ...range, startOffset: start, endOffset: range.endOffset }); + } + return parts.length === 0 ? [range] : parts; +} + async function convertScript(scriptCoverage, root, scriptRoots) { const pathname = scriptPathname(scriptCoverage.url); const located = await locateScript(scriptRoots, pathname, scriptCoverage.url); @@ -536,13 +605,15 @@ async function convertScript(scriptCoverage, root, scriptRoots) { scriptCoverage, generatedSource.length, ); + const boundaries = sourceBoundaryOffsets(map, mappedSource); for (const fn of normalizedFunctions) { fn.ranges = fn.ranges .map((range) => ({ ...range, endOffset: Math.min(range.endOffset, mappedSource.length), })) - .filter((range) => range.startOffset < range.endOffset); + .filter((range) => range.startOffset < range.endOffset) + .flatMap((range) => splitRangeAtBoundaries(range, boundaries)); } const usableFunctions = normalizedFunctions.filter( (fn) => fn.ranges.length > 0,