Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions doc/api/test.md
Original file line number Diff line number Diff line change
Expand Up @@ -1795,6 +1795,11 @@ changes:
This option is not compatible with `isolation='none'`. These variables will override
those from the main process, and are not merged with `process.env`.
**Default:** `process.env`.
* `handleSignals` {boolean} Configures the test runner to handle
`SIGINT`
and `SIGTERM` signals, reporting interrupted tests before exiting.
**Default:** `false` for the programmatic `run()` API. The built-in
command-line test runner handles theses signals by default.
* Returns: {TestsStream}

**Note:** `shard` is used to horizontally parallelize test running across
Expand Down
15 changes: 12 additions & 3 deletions lib/internal/test_runner/harness.js
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,9 @@ function setupProcessState(root, globalOptions) {

setupFailureStateFile(root, globalOptions);

const shouldHandleSignals =
globalOptions.handleSignals ?? globalOptions.isTestRunner;

const exitHandler = async (kill) => {
if (root.subtests.length === 0 && (root.hooks.before.length > 0 || root.hooks.after.length > 0)) {
// Run global before/after hooks in case there are no tests
Expand Down Expand Up @@ -286,7 +289,7 @@ function setupProcessState(root, globalOptions) {
process.removeListener('uncaughtException', exceptionHandler);
process.removeListener('unhandledRejection', rejectionHandler);
process.removeListener('beforeExit', exitHandler);
if (globalOptions.isTestRunner) {
if (shouldHandleSignals) {
process.removeListener('SIGINT', terminationHandler);
process.removeListener('SIGTERM', terminationHandler);
}
Expand All @@ -312,7 +315,14 @@ function setupProcessState(root, globalOptions) {
return running;
};

let terminationInProgress = false;
const terminationHandler = async () => {
if (terminationInProgress) {
return;
}
terminationInProgress = true;
root.harness.success = false;
process.exitCode = kGenericUserError;
const runningTests = findRunningTests(root);
if (runningTests.length > 0) {
root.reporter.interrupted(runningTests);
Expand All @@ -326,8 +336,7 @@ function setupProcessState(root, globalOptions) {
process.on('uncaughtException', exceptionHandler);
process.on('unhandledRejection', rejectionHandler);
process.on('beforeExit', exitHandler);
// TODO(MoLow): Make it configurable to hook when isTestRunner === false.
if (globalOptions.isTestRunner) {
if (shouldHandleSignals) {
process.on('SIGINT', terminationHandler);
process.on('SIGTERM', terminationHandler);
}
Expand Down
5 changes: 5 additions & 0 deletions lib/internal/test_runner/runner.js
Original file line number Diff line number Diff line change
Expand Up @@ -715,6 +715,7 @@ function run(options = kEmptyObject) {
isolation = 'process',
watch,
setup,
handleSignals,
globalSetupPath,
only,
globPatterns,
Expand All @@ -737,6 +738,9 @@ function run(options = kEmptyObject) {
if (watch != null) {
validateBoolean(watch, 'options.watch');
}
if (handleSignals != null) {
validateBoolean(handleSignals, 'options.handleSignals');
}
if (forceExit != null) {
validateBoolean(forceExit, 'options.forceExit');

Expand Down Expand Up @@ -930,6 +934,7 @@ function run(options = kEmptyObject) {
randomize,
randomSeed,
testTagFilters,
handleSignals,
};

const root = createTestTree(rootTestOptions, globalOptions);
Expand Down
33 changes: 33 additions & 0 deletions test/fixtures/test-runner/run-handle-signals.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { run } from 'node:test';
import { tap } from 'node:test/reporters';

const mode = process.argv[2];
const testFile = process.argv[3];

let handleSignals;

if (mode === 'handle-signals') {
handleSignals = true;
} else if (mode === 'no-handle-signals') {
handleSignals = false;
}

if (mode === 'default' || mode === 'no-handle-signals') {
process.on('SIGINT', () => {
process.stdout.write('user SIGINT handler\n', () => {
process.exit(0);
});
});
}

const stream = run({
files: [testFile],
handleSignals,
isolation: 'none',
});

stream.once('test:dequeue', () => {
console.log('READY');
});

stream.compose(tap).pipe(process.stdout);
77 changes: 77 additions & 0 deletions test/parallel/test-runner-run.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import * as common from '../common/index.mjs';
import * as fixtures from '../common/fixtures.mjs';
import { basename, join } from 'node:path';
import { spawn } from 'node:child_process';
import { once } from 'node:events';
import { finished } from 'node:stream/promises';
import { describe, it, run } from 'node:test';
import { dot, spec, tap } from 'node:test/reporters';
import consumers from 'node:stream/consumers';
Expand Down Expand Up @@ -590,6 +593,12 @@ describe('require(\'node:test\').run', { concurrency: true }, () => {
assert.deepStrictEqual(executedTestFiles.sort(), [...shardsTestsFiles].sort());
});

it('should only allow boolean in options,handleSignals', () => {
[Symbol(), {}, [], () => {}, 0, 1, 0n, 1n, '', '1', Promise.resolve([])]
.forEach((handleSignals) => assert.throws(() => run({ handleSignals }), {
code: 'ERR_INVALID_ARG_TYPE',
}));
});
});

describe('randomization', () => {
Expand Down Expand Up @@ -819,6 +828,74 @@ describe('require(\'node:test\').run', { concurrency: true }, () => {
assert.strictEqual(diagnostics.includes(entry), true);
}
});

async function runHandleSignalsFixture(mode) {
if (common.isWindows) {
common.printSkipMessage('signals are not supported on Windows');
return null;
}

let stdout = '';
let sentSignal = false;

const child = spawn(process.execPath, [
fixtures.path('test-runner', 'run-handle-signals.mjs'),
mode,
fixtures.path('test-runner', 'never_ending_async.js'),
]);

const timeout = setTimeout(() => {
child.kill('SIGINT');
}, common.platformTimeout(5000));

child.stdout.setEncoding('utf8');
child.stdout.on('data', (chunk) => {
stdout += chunk;

if (!sentSignal && stdout.includes('READY')) {
sentSignal = true;
child.kill('SIGINT');
}
});

const [code, signal] = await once(child, 'exit');
clearTimeout(timeout);
await finished(child.stdout);

return { code, signal, stdout };
}

describe('handleSignals', () => {
it('should handle SIGINT when handleSignals is true', async () => {
const result = await runHandleSignalsFixture('handle-signals');
if (result === null) return;

assert.strictEqual(result.signal, null);
assert.strictEqual(result.code, 1);
assert.match(result.stdout, /Interrupted while running:/);
assert.match(result.stdout, /never_ending_async\.js/);
});

it('should not handle SIGINT by default in run() API', async () => {
const result = await runHandleSignalsFixture('default');
if (result === null) return;

assert.strictEqual(result.signal, null);
assert.strictEqual(result.code, 0);
assert.match(result.stdout, /user SIGINT handler/);
assert.doesNotMatch(result.stdout, /Interrupted while running/);
});

it('should not handle SIGINT when handleSignals is false', async () => {
const result = await runHandleSignalsFixture('no-handle-signals');
if (result === null) return;

assert.strictEqual(result.signal, null);
assert.strictEqual(result.code, 0);
assert.match(result.stdout, /user SIGINT handler/);
assert.doesNotMatch(result.stdout, /Interrupted while running/);
});
});
});

describe('env', () => {
Expand Down
Loading