Skip to content

Commit 024e550

Browse files
authored
feat(core): trace coverage pipeline (#1297)
1 parent 1dff40f commit 024e550

4 files changed

Lines changed: 314 additions & 77 deletions

File tree

packages/core/src/core/runTests.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -267,7 +267,12 @@ export async function runTests(context: Rstest): Promise<void> {
267267
}
268268
}
269269
const { generateCoverage } = await import('../coverage/generate');
270-
await generateCoverage(context, browserCoverageMap, coverageProvider);
270+
await generateCoverage(
271+
context,
272+
browserCoverageMap,
273+
coverageProvider,
274+
traceRun.span,
275+
);
271276
}
272277
}
273278

@@ -773,7 +778,12 @@ export async function runTests(context: Rstest): Promise<void> {
773778
const { generateCoverage } = await import('../coverage/generate');
774779

775780
await runLifecycleStep('coverage report generation', () =>
776-
generateCoverage(context, mergedCoverageMap!, coverageProvider),
781+
generateCoverage(
782+
context,
783+
mergedCoverageMap!,
784+
coverageProvider,
785+
traceRun.span,
786+
),
777787
);
778788
}
779789

packages/core/src/coverage/generate.ts

Lines changed: 148 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ import type {
88
CoverageOptions,
99
CoverageProvider,
1010
} from '../types/coverage';
11-
import { logger } from '../utils';
11+
import { logger, type TraceSpan } from '../utils';
12+
13+
const traceNoop: TraceSpan = async (_name, _cat, fn) => fn();
1214

1315
export const getIncludedFiles = async (
1416
coverage: CoverageOptions,
@@ -179,6 +181,7 @@ export async function generateCoverage(
179181
context: RstestContext,
180182
coverageMap: CoverageMap,
181183
coverageProvider: CoverageProvider,
184+
traceSpan: TraceSpan = traceNoop,
182185
): Promise<void> {
183186
const {
184187
rootPath,
@@ -188,51 +191,64 @@ export async function generateCoverage(
188191
try {
189192
const finalCoverageMap = coverageMap;
190193

191-
const rawDistPathRoot = context.normalizedConfig.output?.distPath?.root;
192-
const distPathRoot = rawDistPathRoot ? normalize(rawDistPathRoot) : '';
193-
const normalizedRootPath = normalize(rootPath);
194-
const setupCoverageExcludes = getSetupCoverageExcludes(context);
195-
const absDistPathRoot = distPathRoot
196-
? normalize(
197-
isAbsolute(distPathRoot)
198-
? distPathRoot
199-
: `${normalizedRootPath}/${distPathRoot}`,
200-
)
201-
: '';
202-
finalCoverageMap.filter((filePath) => {
203-
const normalizedFile = normalize(filePath);
204-
const fileRelativeToRoot = normalize(
205-
relative(normalizedRootPath, normalizedFile),
206-
);
207-
if (
208-
(distPathRoot && isSameOrSubPath(fileRelativeToRoot, distPathRoot)) ||
209-
(absDistPathRoot && isSameOrSubPath(normalizedFile, absDistPathRoot))
210-
) {
211-
return false;
212-
}
213-
if (isRuntimeSentinelCoverageFile(normalizedFile)) {
214-
return false;
215-
}
216-
// Keep setupFiles/globalSetup out of the final report for every provider.
217-
// Istanbul already excludes them before instrumentation; V8 needs this
218-
// post-collection pruning so both providers converge on the same output.
219-
if (
220-
shouldExcludeSetupCoverageFile(
221-
normalizedFile,
222-
normalizedRootPath,
223-
setupCoverageExcludes,
224-
)
225-
) {
226-
return false;
227-
}
228-
if (!coverage.allowExternal) {
229-
return isSameOrSubPath(normalizedFile, normalize(rootPath));
230-
}
231-
return true;
232-
});
194+
await traceSpan(
195+
'coverage:filter-files',
196+
'coverage',
197+
() => {
198+
const rawDistPathRoot = context.normalizedConfig.output?.distPath?.root;
199+
const distPathRoot = rawDistPathRoot ? normalize(rawDistPathRoot) : '';
200+
const normalizedRootPath = normalize(rootPath);
201+
const setupCoverageExcludes = getSetupCoverageExcludes(context);
202+
const absDistPathRoot = distPathRoot
203+
? normalize(
204+
isAbsolute(distPathRoot)
205+
? distPathRoot
206+
: `${normalizedRootPath}/${distPathRoot}`,
207+
)
208+
: '';
209+
finalCoverageMap.filter((filePath) => {
210+
const normalizedFile = normalize(filePath);
211+
const fileRelativeToRoot = normalize(
212+
relative(normalizedRootPath, normalizedFile),
213+
);
214+
if (
215+
(distPathRoot &&
216+
isSameOrSubPath(fileRelativeToRoot, distPathRoot)) ||
217+
(absDistPathRoot &&
218+
isSameOrSubPath(normalizedFile, absDistPathRoot))
219+
) {
220+
return false;
221+
}
222+
if (isRuntimeSentinelCoverageFile(normalizedFile)) {
223+
return false;
224+
}
225+
// Keep setupFiles/globalSetup out of the final report for every provider.
226+
// Istanbul already excludes them before instrumentation; V8 needs this
227+
// post-collection pruning so both providers converge on the same output.
228+
if (
229+
shouldExcludeSetupCoverageFile(
230+
normalizedFile,
231+
normalizedRootPath,
232+
setupCoverageExcludes,
233+
)
234+
) {
235+
return false;
236+
}
237+
if (!coverage.allowExternal) {
238+
return isSameOrSubPath(normalizedFile, normalizedRootPath);
239+
}
240+
return true;
241+
});
242+
},
243+
{ allowExternal: coverage.allowExternal },
244+
);
233245

234246
if (coverage.include?.length) {
235-
const coveredFilesSet = new Set(finalCoverageMap.files().map(normalize));
247+
const coveredFilesSet = await traceSpan(
248+
'coverage:collect-covered-files',
249+
'coverage',
250+
() => new Set(finalCoverageMap.files().map(normalize)),
251+
);
236252

237253
let isTimeout = false;
238254

@@ -247,14 +263,23 @@ export async function generateCoverage(
247263
// intermediate data be GC'd before the next one starts.
248264
const allFiles: string[] = [];
249265
for (const p of projects) {
250-
const includedFiles = filterChangedFiles(
251-
filterExternalFiles(
252-
await getIncludedFiles(coverage, p.rootPath),
253-
p.rootPath,
254-
coverage.allowExternal,
255-
),
256-
context.changedCoverageFilters,
257-
p.rootPath,
266+
const includedFiles = await traceSpan(
267+
'coverage:collect-included-files',
268+
'coverage',
269+
async () =>
270+
filterChangedFiles(
271+
filterExternalFiles(
272+
await getIncludedFiles(coverage, p.rootPath),
273+
p.rootPath,
274+
coverage.allowExternal,
275+
),
276+
context.changedCoverageFilters,
277+
p.rootPath,
278+
),
279+
{
280+
project: p.environmentName,
281+
changedOnly: Boolean(context.changedCoverageFilters?.length),
282+
},
258283
);
259284
allFiles.push(...includedFiles);
260285

@@ -263,11 +288,21 @@ export async function generateCoverage(
263288
);
264289

265290
if (uncoveredFiles.length) {
266-
await generateCoverageForUntestedFiles(
267-
p.environmentName,
268-
uncoveredFiles,
269-
finalCoverageMap,
270-
coverageProvider,
291+
await traceSpan(
292+
'coverage:generate-untested-files',
293+
'coverage',
294+
() =>
295+
generateCoverageForUntestedFiles(
296+
p.environmentName,
297+
uncoveredFiles,
298+
finalCoverageMap,
299+
coverageProvider,
300+
traceSpan,
301+
),
302+
{
303+
project: p.environmentName,
304+
fileCount: uncoveredFiles.length,
305+
},
271306
);
272307
}
273308
}
@@ -280,26 +315,53 @@ export async function generateCoverage(
280315

281316
// should be better to filter files before swc coverage is processed
282317
const allFilesSet = new Set(allFiles.map(normalize));
283-
finalCoverageMap.filter((file) => allFilesSet.has(normalize(file)));
318+
await traceSpan(
319+
'coverage:filter-included-files',
320+
'coverage',
321+
() => {
322+
finalCoverageMap.filter((file) => allFilesSet.has(normalize(file)));
323+
},
324+
{ fileCount: allFilesSet.size },
325+
);
284326
} else if (context.changedCoverageFilters?.length) {
285-
finalCoverageMap.filter(
286-
(file) =>
287-
filterChangedFiles([file], context.changedCoverageFilters, rootPath)
288-
.length > 0,
327+
await traceSpan(
328+
'coverage:filter-changed-files',
329+
'coverage',
330+
() => {
331+
finalCoverageMap.filter(
332+
(file) =>
333+
filterChangedFiles(
334+
[file],
335+
context.changedCoverageFilters,
336+
rootPath,
337+
).length > 0,
338+
);
339+
},
340+
{ filterCount: context.changedCoverageFilters.length },
289341
);
290342
}
291343

292344
// Generate coverage reports
293-
await coverageProvider.generateReports(finalCoverageMap, coverage);
345+
await traceSpan('coverage:generate-reports', 'coverage', () =>
346+
coverageProvider.generateReports(finalCoverageMap, coverage),
347+
);
294348

295349
if (coverage.thresholds) {
296-
const { checkThresholds } = await import('../coverage/checkThresholds');
297-
const thresholdResult = checkThresholds({
298-
coverageMap: finalCoverageMap,
299-
coverageProvider,
300-
rootPath,
301-
thresholds: coverage.thresholds,
302-
});
350+
const { thresholds } = coverage;
351+
const thresholdResult = await traceSpan(
352+
'coverage:check-thresholds',
353+
'coverage',
354+
async () => {
355+
const { checkThresholds } =
356+
await import('../coverage/checkThresholds');
357+
return checkThresholds({
358+
coverageMap: finalCoverageMap,
359+
coverageProvider,
360+
rootPath,
361+
thresholds,
362+
});
363+
},
364+
);
303365
if (!thresholdResult.success) {
304366
logger.log('');
305367
logger.stderr(thresholdResult.message);
@@ -317,6 +379,7 @@ async function generateCoverageForUntestedFiles(
317379
uncoveredFiles: string[],
318380
coverageMap: CoverageMap,
319381
coverageProvider: CoverageProvider,
382+
traceSpan: TraceSpan = traceNoop,
320383
): Promise<void> {
321384
if (!coverageProvider.generateCoverageForUntestedFiles) {
322385
logger.warn(
@@ -334,10 +397,21 @@ async function generateCoverageForUntestedFiles(
334397
const batchSize = 25;
335398

336399
for (let index = 0; index < uncoveredFiles.length; index += batchSize) {
337-
const coverages = await coverageProvider.generateCoverageForUntestedFiles({
338-
environmentName,
339-
files: uncoveredFiles.slice(index, index + batchSize),
340-
});
400+
const files = uncoveredFiles.slice(index, index + batchSize);
401+
const coverages = await traceSpan(
402+
'coverage:generate-untested-files-batch',
403+
'coverage',
404+
() =>
405+
coverageProvider.generateCoverageForUntestedFiles!({
406+
environmentName,
407+
files,
408+
}),
409+
{
410+
environmentName,
411+
batchIndex: Math.floor(index / batchSize),
412+
fileCount: files.length,
413+
},
414+
);
341415

342416
coverages.forEach((coverageData) => {
343417
coverageMap.addFileCoverage(coverageData);

packages/core/src/utils/trace.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,13 @@ export type TraceEvent = {
2828
args?: Record<string, string | number | boolean | undefined>;
2929
};
3030

31+
export type TraceSpan = <T>(
32+
name: string,
33+
cat: string,
34+
fn: () => T | Promise<T>,
35+
args?: TraceEvent['args'],
36+
) => Promise<T>;
37+
3138
// ---------------------------------------------------------------------------
3239
// File output
3340
// ---------------------------------------------------------------------------
@@ -242,6 +249,8 @@ export interface TraceRun {
242249
* disabled, so the pool layer skips collecting events entirely.
243250
*/
244251
onEvents: ((events: TraceEvent[]) => void) | undefined;
252+
/** Record a host-side Perfetto slice in the current run. */
253+
span: TraceSpan;
245254
/**
246255
* Write the buffered events for this run to disk and (lazily) start or
247256
* refresh the Perfetto helper server. No-op when nothing was collected.
@@ -288,9 +297,35 @@ export const createTraceController = (options: {
288297

289298
const beginRun = (): TraceRun => {
290299
if (!enabled) {
291-
return { onEvents: undefined, finalize: async () => {} };
300+
return {
301+
onEvents: undefined,
302+
span: async (_name, _cat, fn) => fn(),
303+
finalize: async () => {},
304+
};
292305
}
293306
const events: TraceEvent[] = [];
307+
const pushHostSlice: TraceSpan = async (name, cat, fn, args) => {
308+
const start = Date.now();
309+
try {
310+
return await fn();
311+
} finally {
312+
const end = Date.now();
313+
events.push({
314+
name,
315+
cat,
316+
ph: 'X',
317+
ts: start * 1000,
318+
dur: (end - start) * 1000,
319+
pid: process.pid,
320+
tid: 0,
321+
args: {
322+
testPath: '<coverage>',
323+
project: 'host',
324+
...args,
325+
},
326+
});
327+
}
328+
};
294329
return {
295330
// Iterate instead of spreading: a single file with thousands of cases
296331
// can hand `chunk` a very large array, and `push(...chunk)` would then
@@ -299,6 +334,7 @@ export const createTraceController = (options: {
299334
onEvents: (chunk) => {
300335
for (const ev of chunk) events.push(ev);
301336
},
337+
span: pushHostSlice,
302338
finalize: async () => {
303339
if (!events.length) return;
304340
const tracePath = getTraceOutputPath(rootPath);

0 commit comments

Comments
 (0)