|
| 1 | +// V8 -> istanbul coverage harvest for the Playwright suite. |
| 2 | +// |
| 3 | +// When PW_V8_COVERAGE=1 the suite runs against a NON-instrumented build (built |
| 4 | +// with COVERAGE_V8=true, which only adds source maps). Chromium collects native |
| 5 | +// V8 coverage with near-zero runtime overhead; we convert it back to per-source |
| 6 | +// istanbul data via v8-to-istanbul (using the on-disk source maps), filter to |
| 7 | +// src/**, and write the same .nyc_output/*.json the istanbul path produced — so |
| 8 | +// `nyc report` and the strict baseline gate are unchanged. |
| 9 | +// |
| 10 | +// Conversion (v8-to-istanbul load() parses the large bundle source map) is the |
| 11 | +// expensive part, so we do NOT convert per test. Instead each worker collects |
| 12 | +// raw V8 coverage from every test, merges it with @bcoe/v8-coverage (which sums |
| 13 | +// counts and reconciles overlapping ranges correctly — applyCoverage can't be |
| 14 | +// called repeatedly, it pushes/overwrites), and converts ONCE at worker |
| 15 | +// teardown. That cuts conversions from ~152 (per test) to ~1 per worker. |
| 16 | +import v8toIstanbul from 'v8-to-istanbul' |
| 17 | +import libCoverage from 'istanbul-lib-coverage' |
| 18 | +import { mergeProcessCovs } from '@bcoe/v8-coverage' |
| 19 | +import { mkdirSync, writeFileSync, existsSync } from 'node:fs' |
| 20 | +import { randomUUID } from 'node:crypto' |
| 21 | +import path from 'node:path' |
| 22 | + |
| 23 | +const COVERAGE_DIR = path.resolve(process.cwd(), '.nyc_output') |
| 24 | +const DIST_ASSETS = path.resolve(process.cwd(), 'dist', 'assets') |
| 25 | +// Absolute app source dir. Match on this (not a bare "/src/" substring) — the |
| 26 | +// repo itself lives under .../go/src/..., so a substring check would collide. |
| 27 | +const SRC_DIR = path.resolve(process.cwd(), 'src') + path.sep |
| 28 | +// Only our own bundle chunks under /assets/*.js carry app source maps. |
| 29 | +const APP_CHUNK = /\/assets\/([^/?]+\.js)(\?|$)/ |
| 30 | + |
| 31 | +export async function startV8(page) { |
| 32 | + // resetOnNavigation:false so hard navigations (goto) within a test accumulate. |
| 33 | + await page.coverage.startJSCoverage({ resetOnNavigation: false }) |
| 34 | +} |
| 35 | + |
| 36 | +// One accumulator per worker (created by the worker-scoped fixture). |
| 37 | +export function createAccumulator() { |
| 38 | + const processCovs = [] |
| 39 | + |
| 40 | + return { |
| 41 | + // Called on each test teardown with that test's V8 coverage entries. |
| 42 | + add(entries) { |
| 43 | + const result = entries |
| 44 | + .filter((e) => APP_CHUNK.test(e.url)) |
| 45 | + // Keep only structural fields (drop the ~1MB `source` per entry — it's |
| 46 | + // re-read from disk at convert time — to bound per-worker memory). |
| 47 | + .map((e) => ({ scriptId: e.scriptId || e.url, url: e.url, functions: e.functions })) |
| 48 | + if (result.length) processCovs.push({ result }) |
| 49 | + }, |
| 50 | + |
| 51 | + // Called once at worker teardown: merge all tests' coverage, convert, write. |
| 52 | + async flush() { |
| 53 | + if (processCovs.length === 0) return |
| 54 | + const merged = mergeProcessCovs(processCovs) |
| 55 | + const map = libCoverage.createCoverageMap({}) |
| 56 | + |
| 57 | + for (const script of merged.result) { |
| 58 | + const m = APP_CHUNK.exec(script.url) |
| 59 | + if (!m) continue |
| 60 | + const diskPath = path.join(DIST_ASSETS, m[1]) |
| 61 | + if (!existsSync(diskPath)) continue |
| 62 | + |
| 63 | + // v8-to-istanbul auto-loads source + sibling .map from disk; the served |
| 64 | + // bytes match dist, so the V8 ranges line up. |
| 65 | + const converter = v8toIstanbul(diskPath, 0) |
| 66 | + try { |
| 67 | + await converter.load() |
| 68 | + converter.applyCoverage(script.functions) |
| 69 | + const data = converter.toIstanbul() |
| 70 | + for (const [key, fileCov] of Object.entries(data)) { |
| 71 | + // v8-to-istanbul keys are already absolute; keep only app sources. |
| 72 | + if (!key.startsWith(SRC_DIR) || key.includes(`${path.sep}node_modules${path.sep}`)) continue |
| 73 | + map.merge({ [key]: fileCov }) |
| 74 | + } |
| 75 | + } catch { |
| 76 | + // skip a chunk we couldn't convert |
| 77 | + } finally { |
| 78 | + converter.destroy() |
| 79 | + } |
| 80 | + } |
| 81 | + |
| 82 | + const json = map.toJSON() |
| 83 | + if (Object.keys(json).length === 0) return |
| 84 | + mkdirSync(COVERAGE_DIR, { recursive: true }) |
| 85 | + writeFileSync(path.join(COVERAGE_DIR, `v8-${randomUUID()}.json`), JSON.stringify(json)) |
| 86 | + }, |
| 87 | + } |
| 88 | +} |
0 commit comments