Skip to content

Commit 0a3dd16

Browse files
authored
fix(core): flush before final summary output (#1309)
1 parent 7f20294 commit 0a3dd16

4 files changed

Lines changed: 255 additions & 6 deletions

File tree

packages/core/src/reporter/dot.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import type {
1010
TestResult,
1111
UserConsoleLog,
1212
} from '../types';
13-
import { color } from '../utils';
13+
import { color, flushOutputStreams } from '../utils';
1414
import { printSummaryErrorLogs, printSummaryLog } from './summary';
1515
import { logUserConsoleLog } from './utils';
1616

@@ -105,7 +105,7 @@ export class DotReporter implements Reporter {
105105
return;
106106
}
107107

108-
await printSummaryErrorLogs({
108+
const hasErrorLogs = await printSummaryErrorLogs({
109109
testResults,
110110
results,
111111
unhandledErrors,
@@ -114,6 +114,10 @@ export class DotReporter implements Reporter {
114114
filterRerunTestPaths,
115115
});
116116

117+
if (hasErrorLogs && this.flushOutputStreams) {
118+
await flushOutputStreams();
119+
}
120+
117121
printSummaryLog({
118122
results,
119123
testResults,

packages/core/src/reporter/index.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import type {
1212
TestResult,
1313
UserConsoleLog,
1414
} from '../types';
15-
import { isTTY } from '../utils';
15+
import { flushOutputStreams, isTTY } from '../utils';
1616
import { NonTTYProgressNotifier } from './nonTtyProgressNotifier';
1717
import { StatusRenderer } from './statusRenderer';
1818
import { printSummaryErrorLogs, printSummaryLog } from './summary';
@@ -162,7 +162,7 @@ export class DefaultReporter implements Reporter {
162162
return;
163163
}
164164

165-
await printSummaryErrorLogs({
165+
const hasErrorLogs = await printSummaryErrorLogs({
166166
testResults,
167167
results,
168168
unhandledErrors,
@@ -171,6 +171,10 @@ export class DefaultReporter implements Reporter {
171171
filterRerunTestPaths,
172172
});
173173

174+
if (hasErrorLogs && this.flushOutputStreams) {
175+
await flushOutputStreams();
176+
}
177+
174178
printSummaryLog({
175179
results,
176180
testResults,

packages/core/src/reporter/summary.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,7 @@ export const printSummaryErrorLogs = async ({
200200
getSourcemap: GetSourcemap;
201201
filterRerunTestPaths?: string[];
202202
unhandledErrors?: Error[];
203-
}): Promise<void> => {
203+
}): Promise<boolean> => {
204204
const failedTests: TestResult[] = [
205205
...results.filter(
206206
(i) =>
@@ -220,7 +220,7 @@ export const printSummaryErrorLogs = async ({
220220
];
221221

222222
if (failedTests.length === 0 && !unhandledErrors?.length) {
223-
return;
223+
return false;
224224
}
225225

226226
logger.stderr('');
@@ -249,4 +249,6 @@ export const printSummaryErrorLogs = async ({
249249
}
250250
}
251251
}
252+
253+
return true;
252254
};
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
import { stripVTControlCharacters } from 'node:util';
2+
import { describe, expect, it, onTestFinished, rs } from '@rstest/core';
3+
import { DefaultReporter } from '../../src/reporter/index';
4+
import type {
5+
Duration,
6+
NormalizedConfig,
7+
RstestTestState,
8+
SnapshotSummary,
9+
TestFileResult,
10+
TestResult,
11+
} from '../../src/types';
12+
13+
const baseConfig = {
14+
hideSkippedTestFiles: false,
15+
hideSkippedTests: false,
16+
slowTestThreshold: 300,
17+
} as NormalizedConfig;
18+
19+
const emptySnapshotSummary: SnapshotSummary = {
20+
added: 0,
21+
didUpdate: false,
22+
failure: false,
23+
filesAdded: 0,
24+
filesRemoved: 0,
25+
filesRemovedList: [],
26+
filesUnmatched: 0,
27+
filesUpdated: 0,
28+
matched: 0,
29+
total: 0,
30+
unchecked: 0,
31+
uncheckedKeysByFile: [],
32+
unmatched: 0,
33+
updated: 0,
34+
};
35+
36+
const duration: Duration = {
37+
totalTime: 500,
38+
buildTime: 100,
39+
testTime: 300,
40+
};
41+
42+
const createTestState = (results: TestFileResult[]): RstestTestState => ({
43+
getRunningModules: () => new Map(),
44+
getTestModules: () => results,
45+
getTestFiles: () => results.map((result) => result.testPath),
46+
});
47+
48+
const createFailureResults = () => {
49+
const testResult: TestResult = {
50+
status: 'fail',
51+
name: 'should fail',
52+
testPath: '/test/root/example.test.ts',
53+
duration: 200,
54+
errors: [
55+
{
56+
message: 'Snapshot `example 1` mismatched',
57+
name: 'Error',
58+
diff: '- Expected\n+ Received',
59+
},
60+
],
61+
parentNames: ['suite'],
62+
project: 'default',
63+
testId: 'case-1',
64+
};
65+
66+
const fileResult: TestFileResult = {
67+
status: 'fail',
68+
name: 'example.test.ts',
69+
testPath: '/test/root/example.test.ts',
70+
duration: 300,
71+
errors: testResult.errors,
72+
results: [testResult],
73+
project: 'default',
74+
testId: 'file-1',
75+
};
76+
77+
return { fileResult, testResult };
78+
};
79+
80+
const spyOnConsole = () => {
81+
const stdout: string[] = [];
82+
const stderr: string[] = [];
83+
84+
rs.spyOn(console, 'log').mockImplementation((...args) => {
85+
stdout.push(args.join(' '));
86+
});
87+
rs.spyOn(console, 'error').mockImplementation((...args) => {
88+
stderr.push(args.join(' '));
89+
});
90+
91+
onTestFinished(() => {
92+
rs.resetAllMocks();
93+
});
94+
95+
return { stdout, stderr };
96+
};
97+
98+
describe('DefaultReporter summary streams', () => {
99+
it('flushes error output before printing the failed summary to stdout', async () => {
100+
const { fileResult, testResult } = createFailureResults();
101+
const { stdout, stderr } = spyOnConsole();
102+
const writes: string[] = [];
103+
104+
rs.spyOn(process.stderr, 'write').mockImplementation(
105+
(_chunk, _encoding, callback) => {
106+
writes.push('flush stderr');
107+
if (typeof _encoding === 'function') {
108+
_encoding();
109+
} else {
110+
callback?.();
111+
}
112+
return true;
113+
},
114+
);
115+
rs.spyOn(process.stdout, 'write').mockImplementation(
116+
(_chunk, _encoding, callback) => {
117+
writes.push('flush stdout');
118+
if (typeof _encoding === 'function') {
119+
_encoding();
120+
} else {
121+
callback?.();
122+
}
123+
return true;
124+
},
125+
);
126+
127+
const reporter = new DefaultReporter({
128+
rootPath: '/test/root',
129+
config: baseConfig,
130+
options: {},
131+
testState: createTestState([fileResult]),
132+
});
133+
134+
await reporter.onTestRunEnd({
135+
results: [fileResult],
136+
testResults: [testResult],
137+
duration,
138+
snapshotSummary: {
139+
...emptySnapshotSummary,
140+
unmatched: 1,
141+
},
142+
getSourcemap: async () => null,
143+
});
144+
145+
const stderrText = stripVTControlCharacters(stderr.join('\n'));
146+
const stdoutText = stripVTControlCharacters(stdout.join('\n'));
147+
148+
expect(writes).toEqual(['flush stderr', 'flush stdout']);
149+
expect(stderrText).toContain('Summary of all failing tests:');
150+
expect(stderrText).toContain('FAIL example.test.ts > suite > should fail');
151+
expect(stderrText).toContain('Error: Snapshot `example 1` mismatched');
152+
expect(stdoutText).toContain('Snapshots 1 failed');
153+
expect(stdoutText).toContain('Test Files 1 failed');
154+
expect(stdoutText).toContain('Tests 1 failed');
155+
expect(stdoutText).toContain('Duration 500ms (build 100ms, tests 300ms)');
156+
});
157+
158+
it('does not flush process streams when using a custom logger', async () => {
159+
const { fileResult, testResult } = createFailureResults();
160+
const { stdout, stderr } = spyOnConsole();
161+
const stdoutWrite = rs.spyOn(process.stdout, 'write');
162+
const stderrWrite = rs.spyOn(process.stderr, 'write');
163+
164+
const reporter = new DefaultReporter({
165+
rootPath: '/test/root',
166+
config: baseConfig,
167+
options: {
168+
logger: {
169+
outputStream: process.stdout,
170+
errorStream: process.stderr,
171+
getColumns: () => 80,
172+
},
173+
},
174+
testState: createTestState([fileResult]),
175+
});
176+
177+
await reporter.onTestRunEnd({
178+
results: [fileResult],
179+
testResults: [testResult],
180+
duration,
181+
snapshotSummary: emptySnapshotSummary,
182+
getSourcemap: async () => null,
183+
});
184+
185+
expect(stdoutWrite).not.toHaveBeenCalled();
186+
expect(stderrWrite).not.toHaveBeenCalled();
187+
expect(stripVTControlCharacters(stderr.join('\n'))).toContain(
188+
'Summary of all failing tests:',
189+
);
190+
expect(stripVTControlCharacters(stdout.join('\n'))).toContain(
191+
'Test Files 1 failed',
192+
);
193+
});
194+
195+
it('keeps the summary on stdout when there are no failures', async () => {
196+
const testResult: TestResult = {
197+
status: 'pass',
198+
name: 'should pass',
199+
testPath: '/test/root/example.test.ts',
200+
duration: 200,
201+
project: 'default',
202+
testId: 'case-1',
203+
};
204+
const fileResult: TestFileResult = {
205+
status: 'pass',
206+
name: 'example.test.ts',
207+
testPath: '/test/root/example.test.ts',
208+
duration: 300,
209+
results: [testResult],
210+
project: 'default',
211+
testId: 'file-1',
212+
};
213+
const { stdout, stderr } = spyOnConsole();
214+
const write = rs.spyOn(process.stdout, 'write');
215+
216+
const reporter = new DefaultReporter({
217+
rootPath: '/test/root',
218+
config: baseConfig,
219+
options: {},
220+
testState: createTestState([fileResult]),
221+
});
222+
223+
await reporter.onTestRunEnd({
224+
results: [fileResult],
225+
testResults: [testResult],
226+
duration,
227+
snapshotSummary: emptySnapshotSummary,
228+
getSourcemap: async () => null,
229+
});
230+
231+
const stdoutText = stripVTControlCharacters(stdout.join('\n'));
232+
233+
expect(stderr).toEqual([]);
234+
expect(write).not.toHaveBeenCalled();
235+
expect(stdoutText).toContain('Test Files 1 passed');
236+
expect(stdoutText).toContain('Tests 1 passed');
237+
expect(stdoutText).toContain('Duration 500ms (build 100ms, tests 300ms)');
238+
});
239+
});

0 commit comments

Comments
 (0)