Skip to content

Commit e8f7f63

Browse files
authored
perf(coverage-v8): resolve raw coverage in main process (#1501)
1 parent 43b7cd7 commit e8f7f63

9 files changed

Lines changed: 889 additions & 95 deletions

File tree

packages/core/src/core/runTests.ts

Lines changed: 63 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { constants as osConstants } from 'node:os';
2-
import { cleanCoverageReports, createCoverageProvider } from '../coverage';
2+
import {
3+
cleanCoverageReports,
4+
createCoverageProvider,
5+
resolveAndMergeRawCoverage,
6+
} from '../coverage';
37
import { ensureRunDependencies } from './dependencies';
48
import { createPool } from '../pool';
59
import type {
@@ -160,12 +164,38 @@ const notifyReportersOnTestRunEnd = async ({
160164

161165
const isLifecycleDebugEnabled = isDebug();
162166

167+
type LifecycleStepOptions = {
168+
slowAfter?: number;
169+
slowMessage?: string;
170+
slowDoneMessage?: string;
171+
};
172+
163173
const runLifecycleStep = async <T>(
164174
label: string,
165175
fn: () => Promise<T>,
176+
options?: LifecycleStepOptions,
166177
): Promise<T> => {
178+
const { slowAfter, slowMessage, slowDoneMessage } = options ?? {};
179+
let didShowSlowMessage = false;
180+
const slowTimer = slowMessage
181+
? setTimeout(() => {
182+
didShowSlowMessage = true;
183+
logger.info(slowMessage);
184+
}, slowAfter ?? 1000)
185+
: undefined;
186+
167187
if (!isLifecycleDebugEnabled) {
168-
return fn();
188+
try {
189+
const result = await fn();
190+
if (didShowSlowMessage && slowDoneMessage) {
191+
logger.info(slowDoneMessage);
192+
}
193+
return result;
194+
} finally {
195+
if (slowTimer) {
196+
clearTimeout(slowTimer);
197+
}
198+
}
169199
}
170200

171201
const startTime = Date.now();
@@ -174,10 +204,17 @@ const runLifecycleStep = async <T>(
174204
try {
175205
const result = await fn();
176206
logger.debug(`lifecycle: finish ${label} (${Date.now() - startTime}ms)`);
207+
if (didShowSlowMessage && slowDoneMessage) {
208+
logger.info(slowDoneMessage);
209+
}
177210
return result;
178211
} catch (error) {
179212
logger.debug(`lifecycle: fail ${label} (${Date.now() - startTime}ms)`);
180213
throw error;
214+
} finally {
215+
if (slowTimer) {
216+
clearTimeout(slowTimer);
217+
}
181218
}
182219
};
183220

@@ -573,6 +610,7 @@ export async function runTests(context: Rstest): Promise<void> {
573610
const mergedCoverageMap: CoverageMap | undefined = coverageProvider
574611
? coverageProvider.createCoverageMap()
575612
: undefined;
613+
const rawCoverageResults: unknown[] = [];
576614

577615
// Adopt the pre-allocated buffer (set above `runTests` or at the end of
578616
// the previous rerun's `finalize`) so browser events emitted before
@@ -688,6 +726,7 @@ export async function runTests(context: Rstest): Promise<void> {
688726
buildId,
689727
updateSnapshot: context.snapshotManager.options.updateSnapshot,
690728
onCoverageResult: (coverage) => mergedCoverageMap?.merge(coverage),
729+
onRawCoverageResult: (coverage) => rawCoverageResults.push(coverage),
691730
onTraceEvents: traceRun.onEvents,
692731
traceSpan: span,
693732
});
@@ -704,8 +743,6 @@ export async function runTests(context: Rstest): Promise<void> {
704743
testStart ??= buildStart;
705744
const buildTime = testStart - buildStart;
706745

707-
const testTime = Date.now() - testStart;
708-
709746
// Wait for browser tests to complete if running in parallel
710747
const browserResult = browserResultPromise
711748
? await browserResultPromise
@@ -746,21 +783,6 @@ export async function runTests(context: Rstest): Promise<void> {
746783
// from the browser results to avoid reporter/state cache bloat (mirrors
747784
// the node-side pool layer's `delete result.coverage`).
748785

749-
// When unifying reporter output, combine browser and node durations
750-
const duration: Duration =
751-
shouldUnifyReporter && browserResult
752-
? {
753-
totalTime:
754-
testTime + buildTime + browserResult.duration.totalTime,
755-
buildTime: buildTime + browserResult.duration.buildTime,
756-
testTime: testTime + browserResult.duration.testTime,
757-
}
758-
: {
759-
totalTime: testTime + buildTime,
760-
buildTime,
761-
testTime,
762-
};
763-
764786
const results = returns.flatMap((r) => r.results);
765787
const testResults = returns.flatMap((r) => r.testResults);
766788
const errors = returns.flatMap((r) => r.errors || []);
@@ -786,6 +808,28 @@ export async function runTests(context: Rstest): Promise<void> {
786808
errors.push(...browserResult.unhandledErrors);
787809
}
788810

811+
await resolveAndMergeRawCoverage({
812+
coverageProvider,
813+
mergedCoverageMap,
814+
rawCoverageResults,
815+
runCoverageStep: runLifecycleStep,
816+
});
817+
818+
const testTime = Date.now() - testStart;
819+
const duration: Duration =
820+
shouldUnifyReporter && browserResult
821+
? {
822+
totalTime:
823+
testTime + buildTime + browserResult.duration.totalTime,
824+
buildTime: buildTime + browserResult.duration.buildTime,
825+
testTime: testTime + browserResult.duration.testTime,
826+
}
827+
: {
828+
totalTime: testTime + buildTime,
829+
buildTime,
830+
testTime,
831+
};
832+
789833
// Check for failures including browser results when unified
790834
const nodeHasFailure =
791835
results.some((r) => r.status === 'fail') || errors.length;

packages/core/src/coverage/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
getCoverageProviderModuleName,
1414
} from './install';
1515
export { ensureCoverageProviderInstalled } from './install';
16+
export { resolveAndMergeRawCoverage } from './resolveRawCoverage';
1617

1718
export const loadCoverageProvider = async (
1819
options: CoverageOptions,
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import type { CoverageMap, CoverageProvider } from '../types/coverage';
2+
3+
type RunCoverageStep = <T>(
4+
label: string,
5+
fn: () => Promise<T>,
6+
options?: {
7+
slowMessage?: string;
8+
slowDoneMessage?: string;
9+
},
10+
) => Promise<T>;
11+
12+
export const resolveAndMergeRawCoverage = async ({
13+
coverageProvider,
14+
mergedCoverageMap,
15+
rawCoverageResults,
16+
runCoverageStep,
17+
}: {
18+
coverageProvider: CoverageProvider | null;
19+
mergedCoverageMap?: CoverageMap;
20+
rawCoverageResults: unknown[];
21+
runCoverageStep: RunCoverageStep;
22+
}): Promise<void> => {
23+
const resolveRawCoverage =
24+
coverageProvider?.resolveRawCoverage?.bind(coverageProvider);
25+
if (!rawCoverageResults.length || !resolveRawCoverage) {
26+
return;
27+
}
28+
29+
const rawCoverageMap = await runCoverageStep(
30+
'coverage collect',
31+
async () => resolveRawCoverage(rawCoverageResults),
32+
{
33+
slowMessage: 'processing coverage results...',
34+
slowDoneMessage: 'coverage results processed.',
35+
},
36+
);
37+
if (rawCoverageMap) {
38+
mergedCoverageMap?.merge(rawCoverageMap);
39+
}
40+
};

packages/core/src/pool/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,7 @@ export const createPool = async ({
284284
buildId?: number;
285285
/** When provided, coverage data is passed to this callback immediately for caller-owned merging. */
286286
onCoverageResult?: (coverage: CoverageMapData) => void;
287+
onRawCoverageResult?: (coverage: unknown) => void;
287288
/** Perfetto trace events forwarded for caller-owned dumping. */
288289
onTraceEvents?: (events: TraceEvent[]) => void;
289290
/** Records host-side pool slices in the caller-owned Perfetto trace. */
@@ -479,6 +480,7 @@ export const createPool = async ({
479480
updateSnapshot,
480481
buildId,
481482
onCoverageResult,
483+
onRawCoverageResult,
482484
onTraceEvents,
483485
traceSpan,
484486
}) => {
@@ -538,6 +540,10 @@ export const createPool = async ({
538540
onCoverageResult?.(result.coverage);
539541
delete result.coverage;
540542
}
543+
if (result.coverageRaw != null) {
544+
onRawCoverageResult?.(result.coverageRaw);
545+
delete result.coverageRaw;
546+
}
541547
if (result.traceEvents) {
542548
onTraceEvents?.(result.traceEvents);
543549
delete result.traceEvents;

packages/core/src/runtime/worker/runInPool.ts

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -686,20 +686,35 @@ export const runInPool = async (
686686

687687
// Collect coverage data after test file completes
688688
if (coverageProvider) {
689+
const provider = coverageProvider;
689690
tracker.transition('coverage');
690-
const coverageMap = await coverageProvider.collect({
691+
const collectOptions = {
691692
assetFiles,
692693
sourceMaps,
693694
outputModule: options.context.outputModule,
694-
});
695-
if (coverageMap) {
696-
// Attach coverage data to test result
697-
results.coverage = {};
698-
Object.entries(coverageMap.toJSON()).forEach(([key, value]) => {
699-
if ('toJSON' in value)
700-
results.coverage![key] = value.toJSON() as FileCoverageData;
701-
else results.coverage![key] = value;
702-
});
695+
};
696+
697+
const collectCoverage = async () => {
698+
const coverageMap = await provider.collect(collectOptions);
699+
if (coverageMap) {
700+
results.coverage = {};
701+
Object.entries(coverageMap.toJSON()).forEach(([key, value]) => {
702+
if ('toJSON' in value)
703+
results.coverage![key] = value.toJSON() as FileCoverageData;
704+
else results.coverage![key] = value;
705+
});
706+
}
707+
};
708+
709+
if (provider.collectRaw && provider.resolveRawCoverage) {
710+
const rawCoverage = await provider.collectRaw(collectOptions);
711+
if (rawCoverage != null) {
712+
results.coverageRaw = rawCoverage;
713+
} else {
714+
await collectCoverage();
715+
}
716+
} else {
717+
await collectCoverage();
703718
}
704719
}
705720

packages/core/src/types/coverage.ts

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,12 @@ export type NormalizedCoverageOptions = Required<
153153
changed?: boolean | string;
154154
};
155155

156+
export type CoverageCollectOptions = {
157+
assetFiles?: Record<string, string>;
158+
sourceMaps?: Record<string, string>;
159+
outputModule?: boolean;
160+
};
161+
156162
export declare class CoverageProvider {
157163
constructor(options: NormalizedCoverageOptions, root?: string);
158164
/**
@@ -161,13 +167,35 @@ export declare class CoverageProvider {
161167
init(): void | Promise<void>;
162168

163169
/**
164-
* Collect coverage data from global coverage object
170+
* Collect coverage data into an Istanbul coverage map.
165171
*/
166-
collect(options?: {
167-
assetFiles?: Record<string, string>;
168-
sourceMaps?: Record<string, string>;
169-
outputModule?: boolean;
170-
}): CoverageMap | null | Promise<CoverageMap | null>;
172+
collect(
173+
options?: CoverageCollectOptions,
174+
): CoverageMap | null | Promise<CoverageMap | null>;
175+
176+
/**
177+
* Collect lightweight, serializable raw coverage payloads in workers.
178+
*
179+
* Providers may implement this with `resolveRawCoverage` to defer expensive
180+
* conversion work to the main process. Return `null` to indicate that no raw
181+
* coverage was collected and the runner should not call `resolveRawCoverage`
182+
* for that worker result.
183+
*/
184+
collectRaw?(
185+
options?: CoverageCollectOptions,
186+
): unknown | null | Promise<unknown | null>;
187+
188+
/**
189+
* Resolve raw payloads produced by `collectRaw` into an Istanbul coverage map.
190+
*
191+
* The runner passes only non-null payloads returned by the same provider.
192+
* Implementations should ignore malformed payloads only when they can still
193+
* produce a valid partial report; otherwise they may reject to fail coverage
194+
* finalization.
195+
*/
196+
resolveRawCoverage?(
197+
payloads: unknown[],
198+
): CoverageMap | null | Promise<CoverageMap | null>;
171199

172200
/**
173201
* Create a new coverage map

packages/core/src/types/testSuite.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,13 @@ export type TestFileResult = TestResult & {
182182
results: TestResult[];
183183
snapshotResult?: SnapshotResult;
184184
coverage?: Record<string, FileCoverageData>;
185+
/**
186+
* Raw coverage payload used internally between workers and the pool.
187+
* Stripped at the pool boundary before results are exposed to reporters.
188+
*
189+
* @internal
190+
*/
191+
coverageRaw?: unknown;
185192
/**
186193
* Perfetto-compatible trace events. Stripped at the pool boundary.
187194
*

0 commit comments

Comments
 (0)