diff --git a/packages/core/src/core/runTests.ts b/packages/core/src/core/runTests.ts index dbdcedc1c..f9386bbed 100644 --- a/packages/core/src/core/runTests.ts +++ b/packages/core/src/core/runTests.ts @@ -655,6 +655,9 @@ export async function runTests(context: Rstest): Promise { updateSnapshot: context.snapshotManager.options.updateSnapshot, onCoverageResult: (coverage) => mergedCoverageMap?.merge(coverage), onTraceEvents: traceRun.onEvents, + coverageMergeWorker: coverageProvider?.coverageMergeWorker, + coverageMergeWorkerStreaming: + coverageProvider?.coverageMergeWorkerStreaming, }); return { diff --git a/packages/core/src/pool/index.ts b/packages/core/src/pool/index.ts index cf87f955b..3a3e19031 100644 --- a/packages/core/src/pool/index.ts +++ b/packages/core/src/pool/index.ts @@ -1,5 +1,8 @@ +import { readFile, unlink } from 'node:fs/promises'; import os from 'node:os'; +import { monitorEventLoopDelay, performance } from 'node:perf_hooks'; import { fileURLToPath } from 'node:url'; +import type { Worker } from 'node:worker_threads'; import type { SnapshotUpdateState } from '@vitest/snapshot'; import { basename, dirname, join, resolve } from 'pathe'; import { getFileTaskId } from '../runtime/runner'; @@ -22,7 +25,9 @@ import type { import { color, getForceColorEnv, + isDebug, isDeno, + logger, needFlagExperimentalDetectModule, toError, } from '../utils'; @@ -39,6 +44,14 @@ const getNumCpus = (): number => { return os.availableParallelism?.() ?? os.cpus().length; }; +/** + * Minimum number of per-file coverage payloads before end-of-run ingest is + * offloaded to a `worker_threads` pool. Below this, the host parses the handful + * of files itself — spinning up workers would cost more than it saves. See + * issue #1326. + */ +const COVERAGE_MERGE_MIN_FILES = 8; + const parseWorkers = (maxWorkers: string | number): number => { const parsed = Number.parseInt(maxWorkers.toString(), 10); @@ -278,6 +291,19 @@ export const createPool = async ({ onCoverageResult?: (coverage: CoverageMapData) => void; /** Perfetto trace events forwarded for caller-owned dumping. */ onTraceEvents?: (events: TraceEvent[]) => void; + /** + * Absolute path to the coverage provider's off-main-thread merge worker. + * When set (and enough files are produced), end-of-run coverage ingest runs + * in a `worker_threads` pool instead of on the host event loop. See issue + * #1326. + */ + coverageMergeWorker?: string; + /** + * Whether {@link coverageMergeWorker} supports the streaming ingest + * protocol. Gates the streaming path so a batch-only worker (e.g. v8) is + * never driven in streaming mode. See issue #1326. + */ + coverageMergeWorkerStreaming?: boolean; }) => Promise<{ results: TestFileResult[]; testResults: TestResult[]; @@ -369,6 +395,32 @@ export const createPool = async ({ throw `Invalid pool configuration: maxWorkers(${maxWorkers}) cannot be less than minWorkers(${minWorkers}).`; } + // Opt-in one-shot environment banner (`DEBUG=rstest`, zero overhead + // otherwise). Captures the run fingerprint that explains pool utilization — + // worker kind/count, isolate, coverage provider, CPU count + loadavg, memory + // + heap ceiling, node/platform — so a perf report (e.g. issue #1326) is + // self-contained and we don't have to ask the reporter for CI specs. + if (isDebug()) { + const { coverage } = context.normalizedConfig; + const { getHeapStatistics } = await import('node:v8'); + const mb = (bytes: number) => Math.round(bytes / 1024 / 1024); + logger.debug( + `pool: command=${context.command} workerKind=${workerKind} maxWorkers=${maxWorkers} minWorkers=${minWorkers} isolate=${isolate} coverage=${ + coverage?.enabled ? coverage.provider : 'disabled' + }`, + ); + logger.debug( + `pool: cpus=${numCpus} (${os.cpus()[0]?.model ?? 'unknown'}) loadavg=${os + .loadavg() + .map((n) => n.toFixed(2)) + .join('/')} mem(free/total)=${mb(os.freemem())}/${mb( + os.totalmem(), + )}MB heapLimit=${mb( + getHeapStatistics().heap_size_limit, + )}MB node=${process.version} ${process.platform}/${process.arch}`, + ); + } + const pool = new Pool({ workerEntry: resolve(__dirname, './worker.js'), isolate, @@ -464,6 +516,8 @@ export const createPool = async ({ updateSnapshot, onCoverageResult, onTraceEvents, + coverageMergeWorker, + coverageMergeWorkerStreaming, }) => { const projectName = project.name; const runtimeConfig = getRuntimeConfig(project); @@ -473,6 +527,75 @@ export const createPool = async ({ }); const setupAssets = setupEntries.flatMap((entry) => entry.files || []); + // [#1326] Paths to per-file coverage JSON written to disk by test workers. + // Collected during the run (cheap) and ingested off the host event loop + // once all workers have exited (see end-of-run block below). + const coverageFiles: string[] = []; + + // Opt-in diagnostics (`DEBUG=rstest`). Zero overhead otherwise. Measures + // host event-loop saturation during the run plus the end-of-run coverage + // ingest cost, so a slow/under-utilized run can be attributed without + // another round-trip: high event-loop delay ⇒ the host loop is the + // bottleneck (e.g. coverage being merged inline); low delay but low CPU + // ⇒ the cost is worker-side (instrumentation / fork churn). See #1326. + const diag = isDebug(); + const eld = diag ? monitorEventLoopDelay({ resolution: 20 }) : undefined; + eld?.enable(); + + // [#1326 follow-up — experimental, RSTEST_COV_INGEST=stream] Streaming + // ingest: a single long-lived merge thread consumes per-file coverage + // paths AS THEY ARRIVE and unlinks each temp file immediately, so the + // corpus never accumulates on disk and only ONE deduped map is ever + // resident — vs the end-of-run fan-out which materializes the whole + // corpus and up to N partial maps (N× amplification). The host loop still + // only handles path strings, so utilization stays high. + // Streaming is gated on a provider CAPABILITY flag, not the mere presence + // of a merge worker — a batch-only worker (v8) must never be handed the + // streaming protocol (it would crash async and silently drop coverage). + // Default ON for capable providers; `RSTEST_COV_INGEST=batch` forces the + // #1348 end-of-run fan-out, `=stream` is explicit opt-in. + let streamingIngest = + process.env.RSTEST_COV_INGEST !== 'batch' && + !!coverageMergeWorker && + coverageMergeWorkerStreaming === true; + let streamWorker: Worker | undefined; + let streamFinal: Promise | undefined; + let streamedCount = 0; + if (streamingIngest) { + try { + const { Worker } = await import('node:worker_threads'); + streamWorker = new Worker(coverageMergeWorker!, { + workerData: { streaming: true }, + }); + streamFinal = new Promise( + (resolveFinal, rejectFinal) => { + let settled = false; + streamWorker!.once('message', (m: CoverageMapData) => { + settled = true; + resolveFinal(m); + }); + streamWorker!.once('error', (e) => { + settled = true; + rejectFinal(e); + }); + // Unlike the #1348 fan-out, register `exit` too: a worker that + // dies without posting (OOM-kill, load failure) can never orphan + // the awaiter — it rejects instead of hanging forever. + streamWorker!.once('exit', (code) => { + if (!settled) + rejectFinal( + new Error(`coverage merge worker exited (code ${code})`), + ); + }); + }, + ); + } catch { + // worker_threads unavailable / merge worker failed to load: fall back + // to the batch path by collecting paths into `coverageFiles`. + streamingIngest = false; + } + } + const results = await Promise.all( entries.map(async (entryInfo, index) => { const task = await buildTask({ @@ -500,6 +623,22 @@ export const createPool = async ({ ); }); + // [#1326] When the provider supports it, the worker shipped only a + // path to its on-disk coverage; collect it (cheap) and defer all + // read + parse + merge to end-of-run, off the scheduling loop, so no + // per-file coverage graph is deserialized on the host during the run. + const covFile = result.coverageFile; + if (covFile) { + if (streamingIngest && streamWorker) { + // Hand the path to the long-lived consumer immediately (cheap — + // the host only posts a string; the worker reads+merges+unlinks). + streamWorker.postMessage({ type: 'file', path: covFile }); + streamedCount++; + } else { + coverageFiles.push(covFile); + } + delete result.coverageFile; + } if (result.coverage) { onCoverageResult?.(result.coverage); delete result.coverage; @@ -514,6 +653,124 @@ export const createPool = async ({ }), ); + if (diag && eld) { + eld.disable(); + const ms = (ns: number) => (ns / 1e6).toFixed(1); + logger.debug( + `pool(${projectName}): host event-loop delay during run — mean=${ms(eld.mean)}ms p99=${ms(eld.percentile(99))}ms max=${ms(eld.max)}ms (high p99/max ⇒ the host loop is the bottleneck)`, + ); + } + + // [#1326 follow-up] Streaming ingest: most merging already happened during + // the run; just drain the consumer's small backlog, take the single + // merged map, and tear it down. No corpus on disk, no N× amplification. + if (streamingIngest && streamWorker && streamFinal) { + const ingestStart = diag ? performance.now() : 0; + streamWorker.postMessage({ type: 'done' }); + const finalMap = await streamFinal.catch((error) => { + // The streaming consumer already unlinked the temp files it merged, so + // we cannot fall back to a batch re-read here. Surface a real WARNING + // (not a debug-only line) so a partial/empty coverage report is never + // silent — the user can re-run with RSTEST_COV_INGEST=batch. + logger.warn( + `coverage(${projectName}): streaming ingest failed (${ + toError(error).message + }) — coverage for this project may be incomplete. Re-run with RSTEST_COV_INGEST=batch to use the end-of-run merge.`, + ); + return undefined; + }); + if (finalMap) { + onCoverageResult?.(finalMap); + } + await streamWorker.terminate(); + if (diag) { + logger.debug( + `coverage(${projectName}): ingest strategy=streaming files=${streamedCount} took=${(performance.now() - ingestStart).toFixed(0)}ms (drain tail)`, + ); + } + } else if (coverageFiles.length) { + // [#1326] Off-main-thread coverage ingest. All test workers have exited + // (Promise.all resolved), so reading + parsing + merging the per-file + // coverage now runs off the scheduling critical path — a terminal tail, + // not a during-run plateau. With a provider-supplied merge worker, the + // expensive JSON.parse runs in a worker_threads pool and only a few small + // merged partials cross back to the host. + const ingestStart = diag ? performance.now() : 0; + let ingestStrategy: 'worker-threads' | 'main-thread' = 'main-thread'; + let ingestThreads = 0; + let ingested = false; + if ( + coverageMergeWorker && + coverageFiles.length >= COVERAGE_MERGE_MIN_FILES + ) { + try { + const { Worker } = await import('node:worker_threads'); + const threadCount = Math.min(getNumCpus(), coverageFiles.length); + ingestThreads = threadCount; + const chunks: string[][] = Array.from( + { length: threadCount }, + () => [], + ); + coverageFiles.forEach((file, i) => + chunks[i % threadCount]!.push(file), + ); + const partials = await Promise.all( + chunks + .filter((chunk) => chunk.length) + .map( + (files) => + new Promise( + (resolveChunk, rejectChunk) => { + const worker = new Worker(coverageMergeWorker, { + workerData: { files }, + }); + worker.once('message', (partial: CoverageMapData) => { + worker.terminate(); + resolveChunk(partial); + }); + worker.once('error', rejectChunk); + }, + ), + ), + ); + for (const partial of partials) { + onCoverageResult?.(partial); + } + ingestStrategy = 'worker-threads'; + ingested = true; + } catch (error) { + // worker_threads unavailable or the merge worker failed to load: + // fall back to a host-side parse of the same files below. + logger.debug( + `coverage(${projectName}): worker-threads ingest failed (${ + toError(error).message + }) — falling back to main-thread parse`, + ); + } + } + if (!ingested) { + const parsed = await Promise.all( + coverageFiles.map( + async (file) => + JSON.parse(await readFile(file, 'utf8')) as CoverageMapData, + ), + ); + for (const coverage of parsed) { + onCoverageResult?.(coverage); + } + } + await Promise.all( + coverageFiles.map((file) => unlink(file).catch(() => {})), + ); + if (diag) { + logger.debug( + `coverage(${projectName}): ingest strategy=${ingestStrategy}` + + `${ingestStrategy === 'worker-threads' ? ` threads=${ingestThreads}` : ''}` + + ` files=${coverageFiles.length} took=${(performance.now() - ingestStart).toFixed(0)}ms`, + ); + } + } + for (const result of results) { if (result.snapshotResult) { context.snapshotManager.add(result.snapshotResult); diff --git a/packages/core/src/runtime/worker/runInPool.ts b/packages/core/src/runtime/worker/runInPool.ts index d60810b1e..cc90b75ae 100644 --- a/packages/core/src/runtime/worker/runInPool.ts +++ b/packages/core/src/runtime/worker/runInPool.ts @@ -1,5 +1,9 @@ import type { FileCoverageData } from 'istanbul-lib-coverage'; +import { randomUUID } from 'node:crypto'; +import { writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; import { isMainThread, threadId } from 'node:worker_threads'; +import { join } from 'pathe'; import { install } from 'source-map-support'; import type { MaybePromise, @@ -652,13 +656,28 @@ export const runInPool = async ( outputModule: options.context.outputModule, }); if (coverageMap) { - // Attach coverage data to test result - results.coverage = {}; + // Build the plain coverage object (the istanbul `CoverageMapData` shape). + const covObj: Record = {}; Object.entries(coverageMap.toJSON()).forEach(([key, value]) => { if ('toJSON' in value) - results.coverage![key] = value.toJSON() as FileCoverageData; - else results.coverage![key] = value; + covObj[key] = value.toJSON() as FileCoverageData; + else covObj[key] = value; }); + // [#1326] When the provider supplies an off-main-thread merge worker, + // write this file's coverage to disk and ship only the PATH. This keeps + // the host from V8-deserializing a full per-file coverage object graph + // on its single event loop during the run — the measured cause of the + // worker-pool plateau when coverage is enabled. A `randomUUID` filename + // is collision-proof across pool kinds (forks share nothing, but threads + // share `process.pid` and `taskId` resets per project). Providers without + // a merge worker keep shipping the inline IPC payload. + if (coverageProvider.coverageMergeWorker) { + const file = join(tmpdir(), `rstest-cov-${randomUUID()}.json`); + await writeFile(file, JSON.stringify(covObj)); + results.coverageFile = file; + } else { + results.coverage = covObj; + } } } diff --git a/packages/core/src/types/coverage.ts b/packages/core/src/types/coverage.ts index a77d88dd6..3efcb1dce 100644 --- a/packages/core/src/types/coverage.ts +++ b/packages/core/src/types/coverage.ts @@ -179,6 +179,28 @@ export declare class CoverageProvider { */ createCoverageMap(): CoverageMap; + /** + * Optional absolute path to a `worker_threads` entry used for off-main-thread + * coverage ingest. The worker receives `{ files: string[] }` via `workerData` + * (paths to per-file coverage JSON written by test workers), reads + parses + + * merges them with the provider's own merge, and `postMessage`s the merged + * `CoverageMapData`. When present, the host parses/merges coverage off its + * event loop (and in parallel), so coverage ingest no longer competes with + * worker-pool scheduling. When absent, the host falls back to a main-thread + * merge. See issue #1326. + */ + coverageMergeWorker?: string; + + /** + * Whether {@link coverageMergeWorker} understands the STREAMING ingest + * protocol (`workerData.streaming`, `{ type: 'file' | 'done' }` messages). + * Only providers that opt in are driven in streaming mode; a provider that + * exposes a batch-only merge worker (e.g. v8) leaves this falsy and always + * uses the end-of-run fan-out — it is never mis-driven into a streaming + * worker it cannot speak (which would silently drop coverage). See #1326. + */ + coverageMergeWorkerStreaming?: boolean; + /** * Generate coverage for untested files */ diff --git a/packages/core/src/types/testSuite.ts b/packages/core/src/types/testSuite.ts index 1063e3821..23988c193 100644 --- a/packages/core/src/types/testSuite.ts +++ b/packages/core/src/types/testSuite.ts @@ -172,6 +172,14 @@ export type TestFileResult = TestResult & { results: TestResult[]; snapshotResult?: SnapshotResult; coverage?: Record; + /** + * Path to this file's on-disk coverage JSON, shipped instead of the inline + * `coverage` payload when the provider supplies an off-main-thread merge + * worker (see #1326). Read and stripped at the pool boundary. + * + * @internal + */ + coverageFile?: string; /** * Perfetto-compatible trace events. Stripped at the pool boundary. * diff --git a/packages/coverage-istanbul/rslib.config.ts b/packages/coverage-istanbul/rslib.config.ts index ab757ec76..84bed9d08 100644 --- a/packages/coverage-istanbul/rslib.config.ts +++ b/packages/coverage-istanbul/rslib.config.ts @@ -18,6 +18,21 @@ export default defineConfig({ sourceMap: process.env.SOURCEMAP === 'true', }, }, + { + // Off-main-thread coverage merge worker (issue #1326). Built as a + // standalone, self-contained entry so the host can spawn it by absolute + // path via `worker_threads`. Not a public export — referenced internally + // through `CoverageProvider.coverageMergeWorker`. + format: 'esm', + syntax: 'es2023', + dts: false, + source: { + entry: { coverageMergeWorker: './src/coverageMergeWorker.ts' }, + }, + output: { + sourceMap: process.env.SOURCEMAP === 'true', + }, + }, ], tools: { rspack: { diff --git a/packages/coverage-istanbul/src/coverageMergeWorker.ts b/packages/coverage-istanbul/src/coverageMergeWorker.ts new file mode 100644 index 000000000..57007c161 --- /dev/null +++ b/packages/coverage-istanbul/src/coverageMergeWorker.ts @@ -0,0 +1,81 @@ +import { readFileSync, unlinkSync } from 'node:fs'; +import { parentPort, workerData } from 'node:worker_threads'; +import type { + CoverageMap, + CoverageMapData, + FileCoverageData, +} from 'istanbul-lib-coverage'; +import { createFastCoverageMap } from './utils'; + +/** + * Off-main-thread coverage ingest worker (issue #1326). + * + * Test workers write each file's coverage JSON to disk and ship only the path, + * so the host never V8-deserializes a per-file coverage object graph on its + * single event loop. Two ingest shapes are supported: + * + * - BATCH (`workerData.files`): the host hands a whole chunk of paths at once + * (end-of-run fan-out). Read + JSON.parse + merge them and post one merged + * `CoverageMapData`. + * - STREAMING (`workerData.streaming`): a single long-lived consumer. The host + * posts `{ type: 'file', path }` AS EACH per-file coverage is produced during + * the run; we merge it incrementally and `unlinkSync` the temp file right + * away, then on `{ type: 'done' }` post the single merged map. This keeps the + * corpus from accumulating on disk and keeps exactly ONE deduped map resident + * (no N-way fan-out, so no N× memory amplification). See #1326 follow-up. + */ + +// istanbul's `CoverageMap.toJSON()` returns its internal map of `FileCoverage` +// *instances*. Their methods don't survive `postMessage` structured-clone +// (cloning leaves only a `{ data }` wrapper the host can't merge), so serialize +// each entry down to its raw `FileCoverageData` first — matching how test +// workers ship coverage in `runInPool.ts`. +function serializeMap(coverageMap: CoverageMap): CoverageMapData { + const merged: CoverageMapData = {}; + for (const [path, value] of Object.entries(coverageMap.toJSON())) { + merged[path] = + value && typeof (value as { toJSON?: unknown }).toJSON === 'function' + ? (value as unknown as { toJSON: () => FileCoverageData }).toJSON() + : (value as unknown as FileCoverageData); + } + return merged; +} + +const data = workerData as { files?: string[]; streaming?: boolean }; + +if (data.streaming) { + const coverageMap = createFastCoverageMap(); + parentPort!.on( + 'message', + (msg: { type: 'file'; path: string } | { type: 'done' }) => { + if (msg.type === 'file') { + try { + coverageMap.merge( + JSON.parse(readFileSync(msg.path, 'utf8')) as CoverageMapData, + ); + } catch { + // Skip a corrupt/partial coverage file rather than killing the stream. + } finally { + // Consume-then-delete: the corpus never accumulates on disk. + try { + unlinkSync(msg.path); + } catch { + // already gone / never written + } + } + return; + } + // type === 'done': emit the single merged map. The host terminates us. + parentPort!.postMessage(serializeMap(coverageMap)); + }, + ); +} else { + const { files } = data as { files: string[] }; + const coverageMap = createFastCoverageMap(); + for (const file of files) { + coverageMap.merge( + JSON.parse(readFileSync(file, 'utf8')) as CoverageMapData, + ); + } + parentPort!.postMessage(serializeMap(coverageMap)); +} diff --git a/packages/coverage-istanbul/src/provider.ts b/packages/coverage-istanbul/src/provider.ts index e647a2a64..b059a620c 100644 --- a/packages/coverage-istanbul/src/provider.ts +++ b/packages/coverage-istanbul/src/provider.ts @@ -1,3 +1,4 @@ +import { fileURLToPath } from 'node:url'; import type { NormalizedCoverageOptions, CoverageProvider as RstestCoverageProvider, @@ -15,12 +16,28 @@ import { const UNTESTED_FILES_CONCURRENCY = 4; +/** + * Absolute path to the bundled off-main-thread coverage merge worker. Built as + * a sibling entry (`dist/coverageMergeWorker.js`) by rslib. See issue #1326. + */ +const COVERAGE_MERGE_WORKER = fileURLToPath( + new URL('./coverageMergeWorker.js', import.meta.url), +); + // Global type declaration for coverage declare global { var __coverage__: any; } export class CoverageProvider implements RstestCoverageProvider { + /** @see {@link RstestCoverageProvider.coverageMergeWorker} */ + readonly coverageMergeWorker: string = COVERAGE_MERGE_WORKER; + /** + * This provider's merge worker implements the streaming ingest protocol, so + * the host consumes per-file coverage incrementally during the run instead of + * fanning out at end-of-run. @see {@link RstestCoverageProvider.coverageMergeWorkerStreaming} + */ + readonly coverageMergeWorkerStreaming = true; private coverageMap: CoverageMap | null = null; // Cache to avoid redundant readFile calls in generateCoverageForUntestedFiles and generateReports. private sourcemapUrlCache = new Map(); diff --git a/packages/coverage-v8/rslib.config.ts b/packages/coverage-v8/rslib.config.ts index c075326fc..8fc72d857 100644 --- a/packages/coverage-v8/rslib.config.ts +++ b/packages/coverage-v8/rslib.config.ts @@ -8,6 +8,18 @@ export default defineConfig({ syntax: 'es2021', dts: true, }, + { + // Off-main-thread coverage merge worker (issue #1326). Built as a + // standalone, self-contained entry so the host can spawn it by absolute + // path via `worker_threads`. Not a public export — referenced internally + // through `CoverageProvider.coverageMergeWorker`. + format: 'esm', + syntax: 'es2021', + dts: false, + source: { + entry: { coverageMergeWorker: './src/coverageMergeWorker.ts' }, + }, + }, ], tools: { rspack: { diff --git a/packages/coverage-v8/src/coverageMergeWorker.ts b/packages/coverage-v8/src/coverageMergeWorker.ts new file mode 100644 index 000000000..accac55c4 --- /dev/null +++ b/packages/coverage-v8/src/coverageMergeWorker.ts @@ -0,0 +1,39 @@ +import { readFileSync } from 'node:fs'; +import { parentPort, workerData } from 'node:worker_threads'; +import type { CoverageMapData, FileCoverageData } from 'istanbul-lib-coverage'; +import { createFastCoverageMap } from './utils'; + +/** + * Off-main-thread coverage ingest worker (issue #1326). + * + * Test workers convert v8 coverage to istanbul format, write each file's + * coverage JSON to disk and ship only the path, so the host never + * V8-deserializes a per-file coverage object graph on its single event loop + * during the run. The host then hands a chunk of those paths to this worker via + * `workerData.files`. Here we read + JSON.parse + merge them with istanbul's + * real merge ({@link createFastCoverageMap}) and post back a single merged + * `CoverageMapData`. + * + * Running the expensive `JSON.parse` here keeps it off the host event loop, and + * returning one merged map (deduplicated to roughly the unique-module set, + * regardless of how many files were in the chunk) keeps the host-side + * structured-clone receive cheap. + */ +const { files } = workerData as { files: string[] }; +const coverageMap = createFastCoverageMap(); +for (const file of files) { + coverageMap.merge(JSON.parse(readFileSync(file, 'utf8')) as CoverageMapData); +} +// istanbul's `CoverageMap.toJSON()` returns its internal map of `FileCoverage` +// *instances*. Their methods don't survive `postMessage` structured-clone +// (cloning leaves only a `{ data }` wrapper the host can't merge), so serialize +// each entry down to its raw `FileCoverageData` first — matching how test +// workers ship coverage in `runInPool.ts`. +const merged: CoverageMapData = {}; +for (const [path, value] of Object.entries(coverageMap.toJSON())) { + merged[path] = + value && typeof (value as { toJSON?: unknown }).toJSON === 'function' + ? (value as unknown as { toJSON: () => FileCoverageData }).toJSON() + : (value as unknown as FileCoverageData); +} +parentPort!.postMessage(merged); diff --git a/packages/coverage-v8/src/provider.ts b/packages/coverage-v8/src/provider.ts index 93c9c5139..c4a81e04e 100644 --- a/packages/coverage-v8/src/provider.ts +++ b/packages/coverage-v8/src/provider.ts @@ -26,7 +26,17 @@ type SourceMapLike = { sourcesContent?: (string | null)[]; }; +/** + * Absolute path to the bundled off-main-thread coverage merge worker. Built as + * a sibling entry (`dist/coverageMergeWorker.js`) by rslib. See issue #1326. + */ +const COVERAGE_MERGE_WORKER = fileURLToPath( + new URL('./coverageMergeWorker.js', import.meta.url), +); + export class CoverageProvider implements RstestCoverageProvider { + /** @see {@link RstestCoverageProvider.coverageMergeWorker} */ + readonly coverageMergeWorker: string = COVERAGE_MERGE_WORKER; private session: inspector.Session | null = null; private isMatch: (filePath: string) => boolean; private isIncluded: (filePath: string) => boolean;