Skip to content

Commit 74f8d27

Browse files
committed
feat(core): add phase-level Duration breakdown
1 parent 0129db1 commit 74f8d27

15 files changed

Lines changed: 215 additions & 24 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
.DS_Store
22
*.log*
33
*.cpuprofile
4+
*.dump
45
node_modules/
56

67
.cache/

e2e/globalSetup/index.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ describe('globalSetup', async () => {
2323
});
2424

2525
await expectExecSuccess();
26-
const logs = cli.stdout.split('\n').filter((log) => log.includes('['));
26+
const logs = cli.stdout.split('\n').filter((log) => log.startsWith('['));
2727

2828
expect(logs).toMatchInlineSnapshot(`
2929
[

e2e/reporter/githubActions.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ it.skipIf(!process.env.CI)('github-actions', async () => {
5555
expect(stepSummary).toContain('| **Test Files** | ❌ 1 failed |');
5656
expect(stepSummary).toContain('| **Tests** | ❌ 2 failed |');
5757
expect(stepSummary).toMatch(
58-
/\| \*\*Duration\*\* \| \d+ms \(build \d+ms, tests \d+ms\) \|/,
58+
/\| \*\*Duration\*\* \| \d+ms \(build \d+ms; worker aggregate \[setup \d+ms, load \d+ms, collect \d+ms, tests \d+ms\]\) \|/,
5959
);
6060
expect(stepSummary).toContain('## Failures');
6161
expect(stepSummary).toContain(
@@ -174,7 +174,7 @@ it.skipIf(!process.env.CI)('github-actions summary on pass', async () => {
174174
'| **Tests** | ✅ 13 passed \\| 1 skipped (14) |',
175175
);
176176
expect(stepSummary).toMatch(
177-
/\| \*\*Duration\*\* \| \d+ms \(build \d+ms, tests \d+ms\) \|/,
177+
/\| \*\*Duration\*\* \| \d+ms \(build \d+ms; worker aggregate \[setup \d+ms, load \d+ms, collect \d+ms, tests \d+ms\]\) \|/,
178178
);
179179
expect(stepSummary).not.toContain('## Failures');
180180

packages/core/src/core/mergeReports.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,13 @@ import type {
1010
TestResult,
1111
} from '../types';
1212
import type { CoverageMap } from '../types/coverage';
13-
import { color, logger, prettyTime } from '../utils';
13+
import {
14+
color,
15+
formatDurationParts,
16+
logger,
17+
mergePhaseTimings,
18+
prettyTime,
19+
} from '../utils';
1420
import type { Rstest } from './rstest';
1521

1622
const DEFAULT_BLOB_DIR = '.rstest-reports';
@@ -86,14 +92,16 @@ function mergeDurations(durations: Duration[]): Duration {
8692
let totalTime = 0;
8793
let buildTime = 0;
8894
let testTime = 0;
95+
let phaseTimings: Duration['phaseTimings'];
8996

9097
for (const d of durations) {
9198
totalTime += d.totalTime;
9299
buildTime += d.buildTime;
93100
testTime += d.testTime;
101+
phaseTimings = mergePhaseTimings(phaseTimings, d.phaseTimings);
94102
}
95103

96-
return { totalTime, buildTime, testTime };
104+
return { totalTime, buildTime, testTime, phaseTimings };
97105
}
98106

99107
function mergeBlobCoverage(blob: BlobData, coverageMap: CoverageMap): boolean {
@@ -191,7 +199,7 @@ export async function mergeReports(
191199
for (const { label, duration } of shardDurations) {
192200
logger.log(
193201
color.gray(
194-
` ${label}: ${prettyTime(duration.totalTime)} (build ${prettyTime(duration.buildTime)}, tests ${prettyTime(duration.testTime)})`,
202+
` ${label}: ${prettyTime(duration.totalTime)} (${formatDurationParts(duration)})`,
195203
),
196204
);
197205
}

packages/core/src/core/runTests.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,20 @@
11
import { constants as osConstants } from 'node:os';
22
import { cleanCoverageReports, createCoverageProvider } from '../coverage';
33
import { createPool } from '../pool';
4-
import type { EntryInfo, ProjectEntries, SourceMapInput } from '../types';
4+
import type {
5+
Duration,
6+
EntryInfo,
7+
PhaseTimings,
8+
ProjectEntries,
9+
SourceMapInput,
10+
} from '../types';
511
import type { CoverageMap } from '../types/coverage';
612
import {
713
clearScreen,
814
color,
915
getTestEntries,
1016
logger,
17+
mergePhaseTimings,
1118
resolveShardedEntries,
1219
} from '../utils';
1320
import {
@@ -110,11 +117,7 @@ const notifyReportersOnTestRunEnd = async ({
110117
}: {
111118
context: Rstest;
112119
coverage?: CoverageMap;
113-
duration: {
114-
totalTime: number;
115-
buildTime: number;
116-
testTime: number;
117-
};
120+
duration: Duration;
118121
getSourcemap: (sourcePath: string) => Promise<SourceMapInput | null>;
119122
unhandledErrors?: Error[];
120123
filterRerunTestPaths?: string[];
@@ -460,6 +463,8 @@ export async function runTests(context: Rstest): Promise<void> {
460463
? coverageProvider.createCoverageMap()
461464
: undefined;
462465

466+
let phaseTimings: PhaseTimings | undefined;
467+
463468
const returns = await Promise.all(
464469
projects.map(async (p) => {
465470
const {
@@ -548,6 +553,9 @@ export async function runTests(context: Rstest): Promise<void> {
548553
project: p,
549554
updateSnapshot: context.snapshotManager.options.updateSnapshot,
550555
onCoverageResult: (coverage) => mergedCoverageMap?.merge(coverage),
556+
onPhaseTimings: (timings) => {
557+
phaseTimings = mergePhaseTimings(phaseTimings, timings);
558+
},
551559
});
552560

553561
return {
@@ -599,7 +607,7 @@ export async function runTests(context: Rstest): Promise<void> {
599607
};
600608

601609
// When unifying reporter output, combine browser and node durations
602-
const duration =
610+
const base =
603611
shouldUnifyReporter && browserResult
604612
? {
605613
totalTime:
@@ -612,6 +620,7 @@ export async function runTests(context: Rstest): Promise<void> {
612620
buildTime,
613621
testTime,
614622
};
623+
const duration: Duration = { ...base, phaseTimings };
615624

616625
const results = returns.flatMap((r) => r.results);
617626
const testResults = returns.flatMap((r) => r.testResults);

packages/core/src/pool/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type {
77
CoverageMapData,
88
EntryInfo,
99
FormattedError,
10+
PhaseTimings,
1011
ProjectContext,
1112
RstestContext,
1213
RuntimeConfig,
@@ -267,6 +268,8 @@ export const createPool = async ({
267268
project: ProjectContext;
268269
/** When provided, coverage data is passed to this callback immediately for caller-owned merging. */
269270
onCoverageResult?: (coverage: CoverageMapData) => void;
271+
/** Per-file phase timings forwarded for caller-owned aggregation. */
272+
onPhaseTimings?: (timings: PhaseTimings) => void;
270273
}) => Promise<{
271274
results: TestFileResult[];
272275
testResults: TestResult[];
@@ -449,6 +452,7 @@ export const createPool = async ({
449452
project,
450453
updateSnapshot,
451454
onCoverageResult,
455+
onPhaseTimings,
452456
}) => {
453457
const projectName = project.name;
454458
const runtimeConfig = getRuntimeConfig(project);
@@ -488,6 +492,10 @@ export const createPool = async ({
488492
onCoverageResult?.(result.coverage);
489493
delete result.coverage;
490494
}
495+
if (result.phaseTimings) {
496+
onPhaseTimings?.(result.phaseTimings);
497+
delete result.phaseTimings;
498+
}
491499
context.stateManager.onTestFileResult(result);
492500
reporters.map((reporter) => reporter.onTestFileResult?.(result));
493501
return result;

packages/core/src/reporter/githubActions.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type {
1010
TestResult,
1111
} from '../types';
1212
import {
13+
formatDurationParts,
1314
getTaskNameWithPrefix,
1415
logger,
1516
prettyTime,
@@ -316,7 +317,7 @@ async function renderStepSummary({
316317
`| **Tests** | ${escapeMarkdownTableCell(getPlainSummaryStatusString(testResults))} |`,
317318
);
318319
lines.push(
319-
`| **Duration** | ${prettyTime(duration.totalTime)} (build ${prettyTime(duration.buildTime)}, tests ${prettyTime(duration.testTime)}) |`,
320+
`| **Duration** | ${prettyTime(duration.totalTime)} (${formatDurationParts(duration)}) |`,
320321
);
321322
if (flakyTests.length > 0) {
322323
lines.push(

packages/core/src/reporter/summary.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import type {
2424
import {
2525
bgColor,
2626
color,
27+
formatDurationParts,
2728
formatTestPath,
2829
getTaskNameWithPrefix,
2930
logger,
@@ -181,7 +182,7 @@ export const printSummaryLog = ({
181182
logger.log(`${TestSummaryLabel} ${getSummaryStatusString(testResults)}`);
182183

183184
logger.log(
184-
`${DurationLabel} ${prettyTime(duration.totalTime)} ${color.gray(`(build ${prettyTime(duration.buildTime)}, tests ${prettyTime(duration.testTime)})`)}`,
185+
`${DurationLabel} ${prettyTime(duration.totalTime)} ${color.gray(`(${formatDurationParts(duration)})`)}`,
185186
);
186187
logger.log('');
187188
};

packages/core/src/runtime/util.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,14 @@ export const getRealTimers = (): typeof REAL_TIMERS => {
2828
return REAL_TIMERS;
2929
};
3030

31+
/**
32+
* Stable reference to `Date.now`, captured before `@sinonjs/fake-timers`
33+
* can hijack the global. Phase boundaries straddling `tests` rely on this.
34+
*/
35+
const realNow = Date.now.bind(Date);
36+
37+
export const getRealNow = (): number => realNow();
38+
3139
export const formatTestError = async (
3240
err: any,
3341
test?: Test,
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import type { PhaseTimings } from '../../types/reporter';
2+
import { emptyPhaseTimings } from '../../utils/phaseTimings';
3+
import { getRealNow } from '../util';
4+
5+
type PhaseName = keyof PhaseTimings;
6+
7+
/**
8+
* Accumulates non-overlapping per-phase totals (ms) for a single test file.
9+
*
10+
* `transition(name)` ends the active phase and starts the next. A phase
11+
* may be re-entered (e.g. `prepare` resumes after `envSetup`); each entry
12+
* adds to the matching `PhaseTimings` field.
13+
*/
14+
export class PhaseTracker {
15+
private currentPhase: PhaseName | null = null;
16+
private currentStart = 0;
17+
private hasData = false;
18+
private readonly totals: PhaseTimings = emptyPhaseTimings();
19+
20+
transition(phase: PhaseName): void {
21+
const now = getRealNow();
22+
if (this.currentPhase) {
23+
this.totals[this.currentPhase] += now - this.currentStart;
24+
this.hasData = true;
25+
}
26+
this.currentPhase = phase;
27+
this.currentStart = now;
28+
}
29+
30+
end(): void {
31+
if (this.currentPhase) {
32+
this.totals[this.currentPhase] += getRealNow() - this.currentStart;
33+
this.hasData = true;
34+
this.currentPhase = null;
35+
}
36+
}
37+
38+
getTotals(): PhaseTimings | undefined {
39+
return this.hasData ? this.totals : undefined;
40+
}
41+
}

0 commit comments

Comments
 (0)