From 99fb7591d98cc8ca1152721695239157f202a285 Mon Sep 17 00:00:00 2001 From: quality Date: Sun, 4 Oct 2026 00:00:23 -0400 Subject: [PATCH] test: make the e2e coverage artifact renderable without its build Rendering the published e2e-coverage artifact required the exact build/ that produced it, and that build does not reproduce outside the runner, so the number behind --check-source-regions could not be audited anywhere else. Seal now copies each referenced script and its source map into the run directory, and the reporter prefers those copies over build/. The artifact travels with its own inputs; no workflow change is needed because ci.yml already uploads coverage/e2e/ and already runs the seal step. A script is only captured together with the map it names, so a partial copy can never shadow a usable build/ copy. The sourceLength/sourceSha256 checks still apply to the captured copy, and a script resolvable from no root still fails the report closed rather than rendering on partial data. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: quality --- tests/e2e-coverage-report.test.mjs | 229 +++++++++++++++++++++++++++ tests/e2e-coverage-run.test.mjs | 126 +++++++++++++++ tests/tools/e2e-coverage-report.mjs | 62 ++++---- tests/tools/e2e-coverage-run.mjs | 38 ++++- tests/tools/e2e-coverage-scripts.mjs | 191 ++++++++++++++++++++++ 5 files changed, 612 insertions(+), 34 deletions(-) create mode 100644 tests/tools/e2e-coverage-scripts.mjs diff --git a/tests/e2e-coverage-report.test.mjs b/tests/e2e-coverage-report.test.mjs index 90efd320..25a4dea0 100644 --- a/tests/e2e-coverage-report.test.mjs +++ b/tests/e2e-coverage-report.test.mjs @@ -1909,3 +1909,232 @@ test('collectE2ECoverage reports a script recorded with no functions as unattrib await rm(fixture.root, { recursive: true, force: true }); } }); + +// The published artifact has to carry its own inputs. Rendering it required a +// byte-identical build/, and that build does not reproduce outside the runner, +// so the number behind the merge gate could not be audited anywhere else. +async function selfContainedFixture({ writeMap = true } = {}) { + const fixture = await fixtureRun(); + const source = 'const hit = 1;\nconst miss = 2;\n'; + const scriptText = `${source}\n//# sourceMappingURL=app.js.map\n`; + const original = join(fixture.root, 'src/components/Example/index.js'); + await mkdir(join(fixture.root, 'src/components/Example'), { + recursive: true, + }); + await writeFile(original, source); + await writeFile(join(fixture.buildDir, 'assets/js/app.js'), scriptText); + if (writeMap) { + await writeFile( + join(fixture.buildDir, 'assets/js/app.js.map'), + JSON.stringify({ + version: 3, + file: 'app.js', + sources: ['webpack://endusers/./src/components/Example/index.js'], + sourcesContent: [source], + names: [], + mappings: mapLines([ + [0, 0], + [0, 1], + ]), + }), + ); + } + await writeCoverageArtifact(fixture.runDir, 'worker-0-page-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: source.indexOf('const miss'), + endOffset: scriptText.length, + count: 0, + }, + { startOffset: 0, endOffset: scriptText.length, count: 1 }, + ], + }, + ], + }, + ], + }); + return { ...fixture, scriptText }; +} + +test('collectE2ECoverage renders a sealed run after the build directory is gone', async () => { + const fixture = await selfContainedFixture(); + try { + const manifest = await sealCoverageRun(fixture.runDir, 'passed', { + buildDir: fixture.buildDir, + }); + assert.equal(manifest.capturedScripts, 2); + assert.deepEqual(manifest.uncapturedScripts, []); + + // The exact condition an auditor is in: the artifact, the repository, and + // no build whatsoever. + await rm(fixture.buildDir, { recursive: true, force: true }); + + const report = await collectE2ECoverage(fixture.runDir, { + root: fixture.root, + buildDir: fixture.buildDir, + }); + assert.equal(report.status, 'ok'); + assert.deepEqual( + report.sources.map((entry) => [ + entry.file, + entry.executableLines, + entry.coveredLines, + ]), + [['src/components/Example/index.js', 2, 1]], + ); + assert.deepEqual(report.sources[0].uncoveredLines, [2]); + } finally { + await rm(fixture.root, { recursive: true, force: true }); + } +}); + +test('the captured copy is still held to the recorded source hash', async () => { + const fixture = await selfContainedFixture(); + try { + await writeCoverageArtifact(fixture.runDir, 'worker-0-page-1', { + schemaVersion: 1, + kind: 'endusers.playwright.v8-coverage', + runId: 'run-1', + result: [ + { + url: 'http://localhost:3000/assets/js/app.js', + scriptId: '2', + functions: [], + sourceLength: fixture.scriptText.length, + sourceSha256: createHash('sha256') + .update(fixture.scriptText) + .digest('hex'), + }, + ], + }); + await sealCoverageRun(fixture.runDir, 'passed', { + buildDir: fixture.buildDir, + }); + await rm(fixture.buildDir, { recursive: true, force: true }); + + // A self-contained artifact is only trustworthy while the integrity checks + // still apply to the copy that travels with it. + const captured = join(fixture.runDir, 'scripts/assets/js/app.js'); + await writeFile(captured, fixture.scriptText.replace('hit', 'HIT')); + await assert.rejects( + () => + collectE2ECoverage(fixture.runDir, { + root: fixture.root, + buildDir: fixture.buildDir, + }), + /generated source hash mismatch/, + ); + } finally { + await rm(fixture.root, { recursive: true, force: true }); + } +}); + +test('a script resolvable from no root fails the report closed', async () => { + const fixture = await selfContainedFixture(); + try { + await sealCoverageRun(fixture.runDir, 'passed', { + buildDir: fixture.buildDir, + }); + await rm(fixture.buildDir, { recursive: true, force: true }); + await rm(join(fixture.runDir, 'scripts'), { + recursive: true, + force: true, + }); + // Skipping the script instead would let the gate pass on partial data. + await assert.rejects( + () => + collectE2ECoverage(fixture.runDir, { + root: fixture.root, + buildDir: fixture.buildDir, + }), + /coverage script not found/, + ); + } finally { + await rm(fixture.root, { recursive: true, force: true }); + } +}); + +test('a script whose source map is missing is left to the build directory', async () => { + const fixture = await selfContainedFixture({ writeMap: false }); + try { + const manifest = await sealCoverageRun(fixture.runDir, 'passed', { + buildDir: fixture.buildDir, + }); + // Copying the script without its map would shadow a usable build/ copy + // with one the reporter cannot map, so neither is captured. + assert.equal(manifest.capturedScripts, 0); + assert.deepEqual(manifest.uncapturedScripts, ['assets/js/app.js']); + await assert.rejects( + () => + collectE2ECoverage(fixture.runDir, { + root: fixture.root, + buildDir: fixture.buildDir, + }), + /ENOENT/, + ); + } finally { + await rm(fixture.root, { recursive: true, force: true }); + } +}); + +test('sealing without a build directory captures nothing', async () => { + const fixture = await selfContainedFixture(); + try { + const manifest = await sealCoverageRun(fixture.runDir, 'passed'); + assert.equal(manifest.capturedScripts, undefined); + assert.equal(manifest.uncapturedScripts, undefined); + const report = await collectE2ECoverage(fixture.runDir, { + root: fixture.root, + buildDir: fixture.buildDir, + }); + assert.equal(report.status, 'ok'); + } finally { + await rm(fixture.root, { recursive: true, force: true }); + } +}); + +test('an escaping coverage URL is declined by capture and rejected by the report', async () => { + const fixture = await fixtureRun(); + try { + await writeFile(join(fixture.root, 'outside.js'), 'const outside = 1;\n'); + await writeCoverageArtifact(fixture.runDir, 'worker-0-page-0', { + schemaVersion: 1, + kind: 'endusers.playwright.v8-coverage', + runId: 'run-1', + result: [ + { + url: 'http://localhost:3000/%2e%2e%2foutside.js', + scriptId: '1', + functions: [], + }, + ], + }); + // Capture declines quietly so that sealing a run can never fail on a + // hostile URL; the reporter stays the one place that rejects it. + const manifest = await sealCoverageRun(fixture.runDir, 'passed', { + buildDir: fixture.buildDir, + }); + assert.equal(manifest.capturedScripts, 0); + await assert.rejects( + () => + collectE2ECoverage(fixture.runDir, { + root: fixture.root, + buildDir: fixture.buildDir, + }), + /coverage script escapes build directory/, + ); + } finally { + await rm(fixture.root, { recursive: true, force: true }); + } +}); diff --git a/tests/e2e-coverage-run.test.mjs b/tests/e2e-coverage-run.test.mjs index 58478ace..90047cea 100644 --- a/tests/e2e-coverage-run.test.mjs +++ b/tests/e2e-coverage-run.test.mjs @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { spawnSync } from 'node:child_process'; import { + mkdir, mkdtemp, readFile, readdir, @@ -242,3 +243,128 @@ test('run CLI entrypoint reports a failing command on stderr and exits 1', async await rm(runDir, { recursive: true, force: true }); } }); + +test('the seal CLI captures referenced scripts into the run directory', async () => { + const runDir = await tempRunDir(); + const buildDir = join(runDir, '..', 'build-cli'); + try { + await mkdir(join(buildDir, 'assets/js'), { recursive: true }); + const scriptText = 'const hit = 1;\n//# sourceMappingURL=app.js.map\n'; + await writeFile(join(buildDir, 'assets/js/app.js'), scriptText); + await writeFile(join(buildDir, 'assets/js/app.js.map'), '{"version":3}'); + await initCoverageRun(runDir, 'run-capture'); + await writeCoverageArtifact(runDir, 'worker-0-page-0', { + schemaVersion: 1, + kind: 'endusers.playwright.v8-coverage', + runId: 'run-capture', + result: [ + { + url: 'http://localhost:3000/assets/js/app.js', + scriptId: '1', + functions: [], + }, + ], + }); + + await main([ + 'seal', + '--dir', + runDir, + '--status', + 'passed', + '--build-dir', + buildDir, + ]); + + const manifest = await readCoverageRun(runDir); + assert.equal(manifest.capturedScripts, 2); + assert.deepEqual(manifest.uncapturedScripts, []); + assert.equal( + await readFile(join(runDir, 'scripts/assets/js/app.js'), 'utf8'), + scriptText, + ); + // No stray temporaries: the reporter refuses a run that holds any. + assert.deepEqual( + (await readdir(join(runDir, 'scripts/assets/js'))).sort(), + ['app.js', 'app.js.map'], + ); + } finally { + await rm(runDir, { recursive: true, force: true }); + await rm(buildDir, { recursive: true, force: true }); + } +}); + +test('sealing tolerates a build directory that was never produced', async () => { + const runDir = await tempRunDir(); + try { + await initCoverageRun(runDir, 'run-nobuild'); + await writeCoverageArtifact(runDir, 'worker-0-page-0', { + schemaVersion: 1, + kind: 'endusers.playwright.v8-coverage', + runId: 'run-nobuild', + result: [ + { + url: 'http://localhost:3000/assets/js/app.js', + scriptId: '1', + functions: [], + }, + ], + }); + // The seal step runs with if: always(), so a run that failed before the + // build step must still seal rather than mask the real failure. + const manifest = await sealCoverageRun(runDir, 'failed', { + buildDir: join(runDir, 'absent-build'), + }); + assert.equal(manifest.status, 'failed'); + assert.equal(manifest.capturedScripts, 0); + assert.deepEqual(manifest.uncapturedScripts, []); + } finally { + await rm(runDir, { recursive: true, force: true }); + } +}); + +test('capture skips unreadable files and scripts absent from the build', async () => { + const runDir = await tempRunDir(); + const buildDir = join(runDir, '..', 'build-partial'); + try { + await mkdir(join(buildDir, 'assets/js'), { recursive: true }); + await writeFile(join(buildDir, 'assets/js/present.js'), 'const a = 1;\n'); + await initCoverageRun(runDir, 'run-partial'); + await writeCoverageArtifact(runDir, 'worker-0-page-0', { + schemaVersion: 1, + kind: 'endusers.playwright.v8-coverage', + runId: 'run-partial', + result: [ + { + url: 'http://localhost:3000/assets/js/present.js', + scriptId: '1', + functions: [], + }, + { + url: 'http://localhost:3000/assets/js/absent.js', + scriptId: '2', + functions: [], + }, + // Already self-contained, so there is nothing on disk to copy. + { + url: 'http://localhost:3000/assets/js/inline.js', + scriptId: '3', + source: 'const inline = 1;\n', + functions: [], + }, + { url: 'about:blank', scriptId: '4', functions: [] }, + ], + }); + await writeFile(join(runDir, 'not-json.json'), 'this is not json'); + + const manifest = await sealCoverageRun(runDir, 'passed', { buildDir }); + assert.equal(manifest.capturedScripts, 1); + assert.deepEqual(manifest.uncapturedScripts, ['assets/js/absent.js']); + assert.deepEqual(await readdir(join(runDir, 'scripts/assets/js')), [ + 'present.js', + ]); + } finally { + await rm(runDir, { recursive: true, force: true }); + await rm(buildDir, { recursive: true, force: true }); + } +}); diff --git a/tests/tools/e2e-coverage-report.mjs b/tests/tools/e2e-coverage-report.mjs index e9efcfc3..05ca8a1f 100644 --- a/tests/tools/e2e-coverage-report.mjs +++ b/tests/tools/e2e-coverage-report.mjs @@ -13,12 +13,17 @@ import { COVERAGE_ARTIFACT_KIND, readCoverageRun, } from './e2e-coverage-run.mjs'; +import { + COVERAGE_SCRIPTS_DIR, + isEligibleScript, + resolveScriptInRoot, + scriptPathname, +} from './e2e-coverage-scripts.mjs'; const repoRoot = fileURLToPath(new URL('../..', import.meta.url)); const REPORT_KIND = 'endusers.e2e.coverage-report'; const ERROR_KIND = 'endusers.e2e.coverage-report-error'; const SOURCE_MAPPING_URL = /(?:\/\/[#@]\s*sourceMappingURL=)(\S+)/u; -const ELIGIBLE_SCRIPT = /(?:\.m?js$|\/assets\/js\/)/u; const ORIGINAL_SCRIPT = /\.(?:c|m)?(?:js|jsx|ts|tsx)$/u; class CoverageReportError extends Error { @@ -336,22 +341,25 @@ function getRegionCoverage(coverageData) { return regions; } -async function convertScript(scriptCoverage, root, buildDir) { - const parsed = new URL(scriptCoverage.url); - const pathname = decodeURIComponent(parsed.pathname).replace(/^\/+/, ''); - const scriptPath = resolve(buildDir, pathname); - if (!isInside(resolve(buildDir), scriptPath)) { - throw new Error( - `coverage script escapes build directory: ${scriptCoverage.url}`, - ); - } - const buildRoot = await realpath(buildDir); - const resolvedScriptPath = await realpath(scriptPath); - if (!isInside(buildRoot, resolvedScriptPath)) { - throw new Error( - `coverage script escapes build directory: ${scriptCoverage.url}`, - ); - } +// Captured scripts are resolved against each root in order -- the run's own +// `scripts/` copies first, then the build directory. A script missing from one +// root falls through to the next; a script missing from all of them still +// aborts the report, because rendering on partial data would let the merge gate +// pass on a number nobody can reproduce. +async function locateScript(scriptRoots, pathname, url) { + for (const root of scriptRoots) { + const located = await resolveScriptInRoot(root, pathname, url); + if (located) return located; + } + throw new Error( + `coverage script not found in ${scriptRoots.join(', ')}: ${url}`, + ); +} + +async function convertScript(scriptCoverage, root, scriptRoots) { + const pathname = scriptPathname(scriptCoverage.url); + const located = await locateScript(scriptRoots, pathname, scriptCoverage.url); + const resolvedScriptPath = located.path; const generatedSource = scriptCoverage.source ?? (await readFile(resolvedScriptPath, 'utf8')); if ( @@ -373,7 +381,7 @@ async function convertScript(scriptCoverage, root, buildDir) { const { map: rawMap, mapPath } = await loadSourceMap( reference, resolvedScriptPath, - buildRoot, + located.root, ); const { map, sourceFiles } = await normalizeSourceMap(rawMap, mapPath, root); const converter = v8ToIstanbul(resolvedScriptPath, 0, { @@ -417,18 +425,6 @@ async function convertScript(scriptCoverage, root, buildDir) { }; } -function isEligibleScript(url) { - try { - const parsed = new URL(url); - return ( - (parsed.protocol === 'http:' || parsed.protocol === 'https:') && - ELIGIBLE_SCRIPT.test(parsed.pathname) - ); - } catch { - return false; - } -} - function emptyReport(run, status = 'ok') { return { schemaVersion: 2, @@ -474,6 +470,10 @@ export async function collectE2ECoverage( throw new Error(`coverage run contains incomplete temporary artifacts`); } + // The run's own copies win over build/ so a published artifact renders the + // same way wherever it is unpacked, including where no build exists at all. + const scriptRoots = [join(runDir, COVERAGE_SCRIPTS_DIR), buildDir]; + const report = emptyReport(run); const sources = new Map(); const unmapped = new Set(); @@ -499,7 +499,7 @@ export async function collectE2ECoverage( report.scripts.ignored += 1; continue; } - const converted = await convertScript(scriptCoverage, root, buildDir); + const converted = await convertScript(scriptCoverage, root, scriptRoots); report.scripts.converted += 1; if (!converted.attributed) continue; for (const sourceFile of converted.sourceFiles) { diff --git a/tests/tools/e2e-coverage-run.mjs b/tests/tools/e2e-coverage-run.mjs index bcfa7331..5174b07b 100644 --- a/tests/tools/e2e-coverage-run.mjs +++ b/tests/tools/e2e-coverage-run.mjs @@ -7,8 +7,12 @@ import { writeFile, } from 'node:fs/promises'; import { randomUUID } from 'node:crypto'; -import { join } from 'node:path'; -import { pathToFileURL } from 'node:url'; +import { join, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +import { captureRunScripts } from './e2e-coverage-scripts.mjs'; + +const repoRoot = fileURLToPath(new URL('../..', import.meta.url)); export const COVERAGE_RUN_SCHEMA_VERSION = 1; export const COVERAGE_RUN_KIND = 'endusers.e2e.coverage-run'; @@ -98,7 +102,11 @@ export async function readCoverageRun(runDir) { return validateManifest(JSON.parse(raw)); } -export async function sealCoverageRun(runDir, status) { +export async function sealCoverageRun( + runDir, + status, + { buildDir = null } = {}, +) { if (!SEALED_STATUSES.has(status)) { throw new Error(`invalid coverage seal status: ${status}`); } @@ -108,10 +116,26 @@ export async function sealCoverageRun(runDir, status) { `coverage run ${manifest.runId} is already sealed as ${manifest.status}`, ); } + // Copied before the manifest flips to a sealed status: once sealed the run is + // immutable, and the reporter refuses to read it until then. Capture is + // opt-in so that sealing never reaches for a build directory the caller did + // not name; the CLI supplies the one CI builds into. + const scripts = buildDir + ? await captureRunScripts(runDir, { + buildDir, + artifactKind: COVERAGE_ARTIFACT_KIND, + }) + : null; const sealed = { ...manifest, status, sealedAt: new Date().toISOString(), + ...(scripts + ? { + capturedScripts: scripts.copied.length, + uncapturedScripts: scripts.missing, + } + : {}), }; await writeJsonAtomic(manifestPath(runDir), sealed); return sealed; @@ -148,6 +172,11 @@ function optionValue(argv, name) { return argv[index + 1]; } +function optionalValue(argv, name) { + const index = argv.indexOf(name); + return index === -1 ? undefined : argv[index + 1]; +} + export async function main(argv = process.argv.slice(2)) { const command = argv[0]; const runDir = optionValue(argv, '--dir'); @@ -161,9 +190,12 @@ export async function main(argv = process.argv.slice(2)) { return manifest; } if (command === 'seal') { + const buildDir = + optionalValue(argv, '--build-dir') ?? join(repoRoot, 'build'); const manifest = await sealCoverageRun( runDir, optionValue(argv, '--status'), + { buildDir: resolve(buildDir) }, ); process.stdout.write(`${JSON.stringify(manifest)}\n`); return manifest; diff --git a/tests/tools/e2e-coverage-scripts.mjs b/tests/tools/e2e-coverage-scripts.mjs new file mode 100644 index 00000000..343f34df --- /dev/null +++ b/tests/tools/e2e-coverage-scripts.mjs @@ -0,0 +1,191 @@ +import { randomUUID } from 'node:crypto'; +import { + copyFile, + mkdir, + readFile, + readdir, + realpath, + rename, + stat, +} from 'node:fs/promises'; +import { dirname, join, relative, resolve, sep } from 'node:path'; + +// Scripts captured by a run are copied here so the published artifact carries +// its own inputs. Without them the report can only be rendered against the +// exact build/ that produced it, and that build does not reproduce outside the +// runner -- which makes the number behind the merge gate unauditable. +export const COVERAGE_SCRIPTS_DIR = 'scripts'; + +const ELIGIBLE_SCRIPT = /(?:\.m?js$|\/assets\/js\/)/u; +const SOURCE_MAPPING_URL = /(?:\/\/[#@]\s*sourceMappingURL=)(\S+)/u; +const DATA_URL = /^data:/u; + +export function isInside(root, candidate) { + const rel = relative(root, candidate); + return rel === '' || (!rel.startsWith('..') && !rel.startsWith(sep)); +} + +export function isEligibleScript(url) { + try { + const parsed = new URL(url); + return ( + (parsed.protocol === 'http:' || parsed.protocol === 'https:') && + ELIGIBLE_SCRIPT.test(parsed.pathname) + ); + } catch { + return false; + } +} + +// The leading slash is stripped so the pathname can be joined onto a root. The +// result is still attacker-controlled -- every caller re-checks containment +// after resolving it. +export function scriptPathname(url) { + const parsed = new URL(url); + return decodeURIComponent(parsed.pathname).replace(/^\/+/u, ''); +} + +async function directoryExists(path) { + try { + return (await stat(path)).isDirectory(); + } catch { + return false; + } +} + +// Resolves a captured script's pathname inside one root, rejecting anything +// that escapes it. Returns null when the file is simply absent from this root +// so the caller can try the next one. An escape attempt throws under `strict` +// and returns null otherwise: the reporter is the single place that rejects a +// malicious URL, so capture only has to decline to copy it. +export async function resolveScriptInRoot( + root, + pathname, + url, + { strict = true } = {}, +) { + const escaped = () => { + if (strict) { + throw new Error(`coverage script escapes build directory: ${url}`); + } + return null; + }; + let rootPath; + try { + rootPath = await realpath(root); + } catch { + return null; + } + const candidate = resolve(root, pathname); + if (!isInside(resolve(root), candidate)) return escaped(); + let resolved; + try { + resolved = await realpath(candidate); + } catch { + return null; + } + if (!isInside(rootPath, resolved)) return escaped(); + return { root: rootPath, path: resolved }; +} + +async function copyInto(targetRoot, relativePath, sourcePath) { + // `relativePath` is always derived from a realpath already proved to sit + // inside the build root, so no further containment guard is reachable here. + const destination = resolve(targetRoot, relativePath); + await mkdir(dirname(destination), { recursive: true }); + // Copy through a temporary name so a crashed seal can never leave a + // half-written script that later passes the sha256 check by accident. + const temporary = `${destination}.${process.pid}.${randomUUID()}.tmp`; + await copyFile(sourcePath, temporary); + await rename(temporary, destination); + return destination; +} + +function sourceMapReference(source) { + const match = SOURCE_MAPPING_URL.exec(source); + return match ? match[1] : null; +} + +// A script is only copied together with the source map it names. Copying the +// script alone would shadow a usable build/ copy with one the reporter cannot +// map, so a script whose map is missing is left out entirely and the reporter +// falls back to build/. +async function captureScript(buildRoot, targetRoot, pathname, url) { + const script = await resolveScriptInRoot(buildRoot, pathname, url, { + strict: false, + }); + if (!script) return null; + + const source = await readFile(script.path, 'utf8'); + const reference = sourceMapReference(source); + let mapRelative = null; + if (reference && !DATA_URL.test(reference)) { + const mapPath = resolve(dirname(script.path), reference); + if (!isInside(script.root, mapPath)) return null; + let resolvedMapPath; + try { + resolvedMapPath = await realpath(mapPath); + } catch { + return null; + } + if (!isInside(script.root, resolvedMapPath)) return null; + mapRelative = relative(script.root, resolvedMapPath).split(sep).join('/'); + await copyInto(targetRoot, mapRelative, resolvedMapPath); + } + + const scriptRelative = relative(script.root, script.path) + .split(sep) + .join('/'); + await copyInto(targetRoot, scriptRelative, script.path); + return { script: scriptRelative, map: mapRelative }; +} + +/** + * Copies every build script a sealed run references into the run directory so + * the published artifact can be rendered without the build that produced it. + * + * Best effort by design: a script that cannot be copied is reported in + * `missing` and left to the reporter, which still fails closed when it cannot + * resolve the script from any root. + * + * @param {string} runDir - Coverage run directory holding the capture payloads + * @param {object} options + * @param {string} options.buildDir - Build directory the scripts were served from + * @param {string} options.artifactKind - `kind` marking a capture payload + * @returns {Promise<{copied: string[], missing: string[]}>} + */ +export async function captureRunScripts(runDir, { buildDir, artifactKind }) { + if (!(await directoryExists(buildDir))) return { copied: [], missing: [] }; + + const wanted = new Map(); + for (const entry of await readdir(runDir)) { + if (!entry.endsWith('.json') || entry === 'manifest.json') continue; + let payload; + try { + payload = JSON.parse(await readFile(join(runDir, entry), 'utf8')); + } catch { + continue; + } + if (payload?.kind !== artifactKind) continue; + for (const scriptCoverage of payload.result ?? []) { + // A payload that already inlines its source needs nothing on disk. + if (scriptCoverage?.source !== undefined) continue; + if (!isEligibleScript(scriptCoverage?.url)) continue; + wanted.set(scriptPathname(scriptCoverage.url), scriptCoverage.url); + } + } + + const targetRoot = join(runDir, COVERAGE_SCRIPTS_DIR); + const copied = []; + const missing = []; + for (const [pathname, url] of wanted) { + const result = await captureScript(buildDir, targetRoot, pathname, url); + if (!result) { + missing.push(pathname); + continue; + } + copied.push(result.script); + if (result.map) copied.push(result.map); + } + return { copied: copied.sort(), missing: missing.sort() }; +}