Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
210 changes: 210 additions & 0 deletions tests/e2e-coverage-source-boundary.test.mjs
Original file line number Diff line number Diff line change
@@ -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]);
});
75 changes: 73 additions & 2 deletions tests/tools/e2e-coverage-report.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down
Loading