Skip to content

Commit ea597d8

Browse files
Detect process.exit() called from tests
Fixes #861. Co-authored-by: Mark Wubben <[email protected]>
1 parent 4b03662 commit ea597d8

File tree

11 files changed

+130
-13
lines changed

11 files changed

+130
-13
lines changed

lib/reporters/default.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,16 @@ export default class Reporter {
233233
break;
234234
}
235235

236+
case 'process-exit': {
237+
this.write(colors.error(`${figures.cross} Exiting due to process.exit() when running ${this.relativeFile(event.testFile)}`));
238+
239+
this.lineWriter.writeLine();
240+
this.lineWriter.writeLine(colors.errorStack(event.stack));
241+
this.lineWriter.writeLine();
242+
243+
break;
244+
}
245+
236246
case 'hook-finished': {
237247
if (event.logs.length > 0) {
238248
this.lineWriter.writeLine(` ${this.prefixTitle(event.testFile, event.title)}`);

lib/reporters/tap.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,17 @@ export default class TapReporter {
128128
}
129129
}
130130

131+
writeProcessExit(evt) {
132+
const error = new Error(`Exiting due to process.exit() when running ${this.relativeFile(evt.testFile)}`);
133+
error.stack = evt.stack;
134+
135+
for (const [testFile, tests] of evt.pendingTests) {
136+
for (const title of tests) {
137+
this.writeTest({testFile, title, err: error}, {passed: false, todo: false, skip: false});
138+
}
139+
}
140+
}
141+
131142
writeTimeout(evt) {
132143
const error = new Error(`Exited because no new tests completed within the last ${evt.period}ms of inactivity`);
133144

@@ -158,6 +169,9 @@ export default class TapReporter {
158169
this.filesWithMissingAvaImports.add(evt.testFile);
159170
this.writeCrash(evt, `No tests found in ${this.relativeFile(evt.testFile)}, make sure to import "ava" at the top of your test file`);
160171
break;
172+
case 'process-exit':
173+
this.writeProcessExit(evt);
174+
break;
161175
case 'selected-test':
162176
if (evt.skip) {
163177
this.writeTest(evt, {passed: true, todo: false, skip: true});

lib/run-status.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ export default class RunStatus extends Emittery {
6262
worker.onStateChange(data => this.emitStateChange(data));
6363
}
6464

65+
// eslint-disable-next-line complexity
6566
emitStateChange(event) {
6667
const {stats} = this;
6768
const fileStats = stats.byFile.get(event.testFile);
@@ -134,6 +135,10 @@ export default class RunStatus extends Emittery {
134135
event.pendingTests = this.pendingTests;
135136
this.pendingTests = new Map();
136137
break;
138+
case 'process-exit':
139+
event.pendingTests = this.pendingTests;
140+
this.pendingTests = new Map();
141+
break;
137142
case 'uncaught-exception':
138143
stats.uncaughtExceptions++;
139144
fileStats.uncaughtExceptions++;
@@ -175,6 +180,7 @@ export default class RunStatus extends Emittery {
175180
|| this.stats.failedHooks > 0
176181
|| this.stats.failedTests > 0
177182
|| this.stats.failedWorkers > 0
183+
|| this.stats.remainingTests > 0
178184
|| this.stats.sharedWorkerErrors > 0
179185
|| this.stats.timeouts > 0
180186
|| this.stats.uncaughtExceptions > 0

lib/watcher.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,7 @@ export default class Watcher {
294294
switch (evt.type) {
295295
case 'hook-failed':
296296
case 'internal-error':
297+
case 'process-exit':
297298
case 'test-failed':
298299
case 'uncaught-exception':
299300
case 'unhandled-rejection':

lib/worker/base.js

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,41 @@ import {flags, refs, sharedWorkerTeardowns} from './state.cjs';
1919
import {isRunningInThread, isRunningInChildProcess} from './utils.cjs';
2020

2121
const currentlyUnhandled = setUpCurrentlyUnhandled();
22+
let runner;
23+
24+
// Override process.exit with an undetectable replacement
25+
// to report when it is called from a test (which it should never be).
26+
const {apply} = Reflect;
27+
const realExit = process.exit;
28+
29+
async function exit(code, forceSync = false) {
30+
dependencyTracking.flush();
31+
const flushing = channel.flush();
32+
if (!forceSync) {
33+
await flushing;
34+
}
35+
36+
apply(realExit, process, [code]);
37+
}
38+
39+
const handleProcessExit = (fn, receiver, args) => {
40+
const error = new Error('Unexpected process.exit()');
41+
Error.captureStackTrace(error, handleProcessExit);
42+
const {stack} = serializeError('', true, error);
43+
channel.send({type: 'process-exit', stack});
44+
45+
// Make sure to extract the code only from `args` rather than e.g. `Array.prototype`.
46+
// This level of paranoia is usually unwarranted, but we're dealing with test code
47+
// that has already colored outside the lines.
48+
const code = args.length > 0 ? args[0] : undefined;
49+
50+
// Force a synchronous exit as guaranteed by the real process.exit().
51+
exit(code, true);
52+
};
53+
54+
process.exit = new Proxy(realExit, {
55+
apply: handleProcessExit,
56+
});
2257

2358
const run = async options => {
2459
setOptions(options);
@@ -29,16 +64,6 @@ const run = async options => {
2964
global.console = Object.assign(global.console, new console.Console({stdout, stderr, colorMode: true}));
3065
}
3166

32-
async function exit(code) {
33-
if (!process.exitCode) {
34-
process.exitCode = code;
35-
}
36-
37-
dependencyTracking.flush();
38-
await channel.flush();
39-
process.exit(); // eslint-disable-line unicorn/no-process-exit
40-
}
41-
4267
let checkSelectedByLineNumbers;
4368
try {
4469
checkSelectedByLineNumbers = lineNumberSelection({
@@ -50,7 +75,7 @@ const run = async options => {
5075
checkSelectedByLineNumbers = () => false;
5176
}
5277

53-
const runner = new Runner({
78+
runner = new Runner({
5479
checkSelectedByLineNumbers,
5580
experiments: options.experiments,
5681
failFast: options.failFast,
Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,20 @@
11
const test = require('../../../../entrypoints/main.cjs');
22

3+
// Allow some time for all workers to launch.
4+
const grace = new Promise(resolve => {
5+
setTimeout(resolve, 500);
6+
});
7+
38
test('first pass', t => {
49
t.pass();
510
});
611

7-
test('second fail', t => {
12+
test('second fail', async t => {
13+
await grace;
814
t.fail();
915
});
1016

11-
test('third pass', t => {
17+
test('third pass', async t => {
18+
await grace;
1219
t.pass();
1320
});

test/helpers/exec.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,10 @@ export const fixture = async (args, options = {}) => {
6969
const stats = {
7070
failed: [],
7171
failedHooks: [],
72+
internalErrors: [],
73+
processExits: [],
7274
passed: [],
75+
selectedTestCount: 0,
7376
sharedWorkerErrors: [],
7477
skipped: [],
7578
todo: [],
@@ -92,7 +95,23 @@ export const fixture = async (args, options = {}) => {
9295
break;
9396
}
9497

98+
case 'internal-error': {
99+
const {testFile} = statusEvent;
100+
const statObject = {file: normalizePath(workingDir, testFile)};
101+
errors.set(statObject, statusEvent.err);
102+
stats.internalErrors.push(statObject);
103+
break;
104+
}
105+
106+
case 'process-exit': {
107+
const {testFile} = statusEvent;
108+
const statObject = {file: normalizePath(workingDir, testFile)};
109+
stats.processExits.push(statObject);
110+
break;
111+
}
112+
95113
case 'selected-test': {
114+
stats.selectedTestCount++;
96115
if (statusEvent.skip) {
97116
const {title, testFile} = statusEvent;
98117
stats.skipped.push({title, file: normalizePath(workingDir, testFile)});
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export default {
2+
files: ['*.js'],
3+
};
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"type": "module"
3+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import test from 'ava';
2+
3+
test('good', t => {
4+
t.pass();
5+
});
6+
7+
test('process.exit', async t => {
8+
t.pass();
9+
await new Promise(resolve => {
10+
setImmediate(resolve);
11+
});
12+
process.exit(0); // eslint-disable-line unicorn/no-process-exit
13+
});
14+
15+
test('still good', t => {
16+
t.pass();
17+
});

test/test-process-exit/test.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import test from '@ava/test';
2+
3+
import {fixture} from '../helpers/exec.js';
4+
5+
test('process.exit is intercepted', async t => {
6+
const result = await t.throwsAsync(fixture(['process-exit.js']));
7+
t.true(result.failed);
8+
t.like(result, {timedOut: false, isCanceled: false, killed: false});
9+
t.is(result.stats.selectedTestCount, 3);
10+
t.is(result.stats.passed.length, 2);
11+
t.is(result.stats.processExits.length, 1);
12+
});

0 commit comments

Comments
 (0)