diff --git a/lib/reporters/default.js b/lib/reporters/default.js index 8a612deef..61430b0e5 100644 --- a/lib/reporters/default.js +++ b/lib/reporters/default.js @@ -233,6 +233,16 @@ export default class Reporter { break; } + case 'process-exit': { + this.write(colors.error(`${figures.cross} Exiting due to process.exit() when running ${this.relativeFile(event.testFile)}`)); + + this.lineWriter.writeLine(); + this.lineWriter.writeLine(colors.errorStack(event.stack)); + this.lineWriter.writeLine(); + + break; + } + case 'hook-finished': { if (event.logs.length > 0) { this.lineWriter.writeLine(` ${this.prefixTitle(event.testFile, event.title)}`); diff --git a/lib/reporters/tap.js b/lib/reporters/tap.js index 51a55cdb6..089a7431e 100644 --- a/lib/reporters/tap.js +++ b/lib/reporters/tap.js @@ -128,6 +128,17 @@ export default class TapReporter { } } + writeProcessExit(evt) { + const error = new Error(`Exiting due to process.exit() when running ${this.relativeFile(evt.testFile)}`); + error.stack = evt.stack; + + for (const [testFile, tests] of evt.pendingTests) { + for (const title of tests) { + this.writeTest({testFile, title, err: error}, {passed: false, todo: false, skip: false}); + } + } + } + writeTimeout(evt) { const error = new Error(`Exited because no new tests completed within the last ${evt.period}ms of inactivity`); @@ -158,6 +169,9 @@ export default class TapReporter { this.filesWithMissingAvaImports.add(evt.testFile); this.writeCrash(evt, `No tests found in ${this.relativeFile(evt.testFile)}, make sure to import "ava" at the top of your test file`); break; + case 'process-exit': + this.writeProcessExit(evt); + break; case 'selected-test': if (evt.skip) { this.writeTest(evt, {passed: true, todo: false, skip: true}); diff --git a/lib/run-status.js b/lib/run-status.js index 7d6b26c01..6867cb03f 100644 --- a/lib/run-status.js +++ b/lib/run-status.js @@ -62,6 +62,7 @@ export default class RunStatus extends Emittery { worker.onStateChange(data => this.emitStateChange(data)); } + // eslint-disable-next-line complexity emitStateChange(event) { const {stats} = this; const fileStats = stats.byFile.get(event.testFile); @@ -134,6 +135,10 @@ export default class RunStatus extends Emittery { event.pendingTests = this.pendingTests; this.pendingTests = new Map(); break; + case 'process-exit': + event.pendingTests = this.pendingTests; + this.pendingTests = new Map(); + break; case 'uncaught-exception': stats.uncaughtExceptions++; fileStats.uncaughtExceptions++; @@ -175,6 +180,7 @@ export default class RunStatus extends Emittery { || this.stats.failedHooks > 0 || this.stats.failedTests > 0 || this.stats.failedWorkers > 0 + || this.stats.remainingTests > 0 || this.stats.sharedWorkerErrors > 0 || this.stats.timeouts > 0 || this.stats.uncaughtExceptions > 0 diff --git a/lib/watcher.js b/lib/watcher.js index 58d59e245..00784e315 100644 --- a/lib/watcher.js +++ b/lib/watcher.js @@ -294,6 +294,7 @@ export default class Watcher { switch (evt.type) { case 'hook-failed': case 'internal-error': + case 'process-exit': case 'test-failed': case 'uncaught-exception': case 'unhandled-rejection': diff --git a/lib/worker/base.js b/lib/worker/base.js index a9ed9e4a3..bf4302e2e 100644 --- a/lib/worker/base.js +++ b/lib/worker/base.js @@ -19,6 +19,41 @@ import {flags, refs, sharedWorkerTeardowns} from './state.cjs'; import {isRunningInThread, isRunningInChildProcess} from './utils.cjs'; const currentlyUnhandled = setUpCurrentlyUnhandled(); +let runner; + +// Override process.exit with an undetectable replacement +// to report when it is called from a test (which it should never be). +const {apply} = Reflect; +const realExit = process.exit; + +async function exit(code, forceSync = false) { + dependencyTracking.flush(); + const flushing = channel.flush(); + if (!forceSync) { + await flushing; + } + + apply(realExit, process, [code]); +} + +const handleProcessExit = (fn, receiver, args) => { + const error = new Error('Unexpected process.exit()'); + Error.captureStackTrace(error, handleProcessExit); + const {stack} = serializeError('', true, error); + channel.send({type: 'process-exit', stack}); + + // Make sure to extract the code only from `args` rather than e.g. `Array.prototype`. + // This level of paranoia is usually unwarranted, but we're dealing with test code + // that has already colored outside the lines. + const code = args.length > 0 ? args[0] : undefined; + + // Force a synchronous exit as guaranteed by the real process.exit(). + exit(code, true); +}; + +process.exit = new Proxy(realExit, { + apply: handleProcessExit, +}); const run = async options => { setOptions(options); @@ -29,16 +64,6 @@ const run = async options => { global.console = Object.assign(global.console, new console.Console({stdout, stderr, colorMode: true})); } - async function exit(code) { - if (!process.exitCode) { - process.exitCode = code; - } - - dependencyTracking.flush(); - await channel.flush(); - process.exit(); // eslint-disable-line unicorn/no-process-exit - } - let checkSelectedByLineNumbers; try { checkSelectedByLineNumbers = lineNumberSelection({ @@ -50,7 +75,7 @@ const run = async options => { checkSelectedByLineNumbers = () => false; } - const runner = new Runner({ + runner = new Runner({ checkSelectedByLineNumbers, experiments: options.experiments, failFast: options.failFast, diff --git a/test-tap/fixture/fail-fast/multiple-files/fails.cjs b/test-tap/fixture/fail-fast/multiple-files/fails.cjs index f405ec691..c43c6c708 100644 --- a/test-tap/fixture/fail-fast/multiple-files/fails.cjs +++ b/test-tap/fixture/fail-fast/multiple-files/fails.cjs @@ -1,13 +1,20 @@ const test = require('../../../../entrypoints/main.cjs'); +// Allow some time for all workers to launch. +const grace = new Promise(resolve => { + setTimeout(resolve, 500); +}); + test('first pass', t => { t.pass(); }); -test('second fail', t => { +test('second fail', async t => { + await grace; t.fail(); }); -test('third pass', t => { +test('third pass', async t => { + await grace; t.pass(); }); diff --git a/test/helpers/exec.js b/test/helpers/exec.js index 01c1300a5..4e4dbd7ec 100644 --- a/test/helpers/exec.js +++ b/test/helpers/exec.js @@ -69,7 +69,10 @@ export const fixture = async (args, options = {}) => { const stats = { failed: [], failedHooks: [], + internalErrors: [], + processExits: [], passed: [], + selectedTestCount: 0, sharedWorkerErrors: [], skipped: [], todo: [], @@ -92,7 +95,23 @@ export const fixture = async (args, options = {}) => { break; } + case 'internal-error': { + const {testFile} = statusEvent; + const statObject = {file: normalizePath(workingDir, testFile)}; + errors.set(statObject, statusEvent.err); + stats.internalErrors.push(statObject); + break; + } + + case 'process-exit': { + const {testFile} = statusEvent; + const statObject = {file: normalizePath(workingDir, testFile)}; + stats.processExits.push(statObject); + break; + } + case 'selected-test': { + stats.selectedTestCount++; if (statusEvent.skip) { const {title, testFile} = statusEvent; stats.skipped.push({title, file: normalizePath(workingDir, testFile)}); diff --git a/test/test-process-exit/fixtures/ava.config.js b/test/test-process-exit/fixtures/ava.config.js new file mode 100644 index 000000000..1669aaefe --- /dev/null +++ b/test/test-process-exit/fixtures/ava.config.js @@ -0,0 +1,3 @@ +export default { + files: ['*.js'], +}; diff --git a/test/test-process-exit/fixtures/package.json b/test/test-process-exit/fixtures/package.json new file mode 100644 index 000000000..bedb411a9 --- /dev/null +++ b/test/test-process-exit/fixtures/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/test/test-process-exit/fixtures/process-exit.js b/test/test-process-exit/fixtures/process-exit.js new file mode 100644 index 000000000..dd9e77b7a --- /dev/null +++ b/test/test-process-exit/fixtures/process-exit.js @@ -0,0 +1,17 @@ +import test from 'ava'; + +test('good', t => { + t.pass(); +}); + +test('process.exit', async t => { + t.pass(); + await new Promise(resolve => { + setImmediate(resolve); + }); + process.exit(0); // eslint-disable-line unicorn/no-process-exit +}); + +test('still good', t => { + t.pass(); +}); diff --git a/test/test-process-exit/test.js b/test/test-process-exit/test.js new file mode 100644 index 000000000..46cf741e9 --- /dev/null +++ b/test/test-process-exit/test.js @@ -0,0 +1,12 @@ +import test from '@ava/test'; + +import {fixture} from '../helpers/exec.js'; + +test('process.exit is intercepted', async t => { + const result = await t.throwsAsync(fixture(['process-exit.js'])); + t.true(result.failed); + t.like(result, {timedOut: false, isCanceled: false, killed: false}); + t.is(result.stats.selectedTestCount, 3); + t.is(result.stats.passed.length, 2); + t.is(result.stats.processExits.length, 1); +});