Skip to content

Commit 0bfc0f9

Browse files
committed
test_runner: add handleSignals option to run()
Signed-off-by: mete0rfish <sungwon326@naver.com>
1 parent ea60060 commit 0bfc0f9

5 files changed

Lines changed: 132 additions & 3 deletions

File tree

doc/api/test.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1795,6 +1795,11 @@ changes:
17951795
This option is not compatible with `isolation='none'`. These variables will override
17961796
those from the main process, and are not merged with `process.env`.
17971797
**Default:** `process.env`.
1798+
* `handleSignals` {boolean} Configures the test runner to handle
1799+
`SIGINT`
1800+
and `SIGTERM` signals, reporting interrupted tests before exiting.
1801+
**Default:** `false` for the programmatic `run()` API. The built-in
1802+
command-line test runner handles theses signals by default.
17981803
* Returns: {TestsStream}
17991804

18001805
**Note:** `shard` is used to horizontally parallelize test running across

lib/internal/test_runner/harness.js

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,9 @@ function setupProcessState(root, globalOptions) {
259259

260260
setupFailureStateFile(root, globalOptions);
261261

262+
const shouldHandleSignals =
263+
globalOptions.handleSignals ?? globalOptions.isTestRunner;
264+
262265
const exitHandler = async (kill) => {
263266
if (root.subtests.length === 0 && (root.hooks.before.length > 0 || root.hooks.after.length > 0)) {
264267
// Run global before/after hooks in case there are no tests
@@ -286,7 +289,7 @@ function setupProcessState(root, globalOptions) {
286289
process.removeListener('uncaughtException', exceptionHandler);
287290
process.removeListener('unhandledRejection', rejectionHandler);
288291
process.removeListener('beforeExit', exitHandler);
289-
if (globalOptions.isTestRunner) {
292+
if (shouldHandleSignals) {
290293
process.removeListener('SIGINT', terminationHandler);
291294
process.removeListener('SIGTERM', terminationHandler);
292295
}
@@ -312,7 +315,14 @@ function setupProcessState(root, globalOptions) {
312315
return running;
313316
};
314317

318+
let terminationInProgress = false;
315319
const terminationHandler = async () => {
320+
if (terminationInProgress) {
321+
return;
322+
}
323+
terminationInProgress = true;
324+
root.harness.success = false;
325+
process.exitCode = kGenericUserError;
316326
const runningTests = findRunningTests(root);
317327
if (runningTests.length > 0) {
318328
root.reporter.interrupted(runningTests);
@@ -326,8 +336,7 @@ function setupProcessState(root, globalOptions) {
326336
process.on('uncaughtException', exceptionHandler);
327337
process.on('unhandledRejection', rejectionHandler);
328338
process.on('beforeExit', exitHandler);
329-
// TODO(MoLow): Make it configurable to hook when isTestRunner === false.
330-
if (globalOptions.isTestRunner) {
339+
if (shouldHandleSignals) {
331340
process.on('SIGINT', terminationHandler);
332341
process.on('SIGTERM', terminationHandler);
333342
}

lib/internal/test_runner/runner.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -715,6 +715,7 @@ function run(options = kEmptyObject) {
715715
isolation = 'process',
716716
watch,
717717
setup,
718+
handleSignals,
718719
globalSetupPath,
719720
only,
720721
globPatterns,
@@ -737,6 +738,9 @@ function run(options = kEmptyObject) {
737738
if (watch != null) {
738739
validateBoolean(watch, 'options.watch');
739740
}
741+
if (handleSignals != null) {
742+
validateBoolean(handleSignals, 'options.handleSignals');
743+
}
740744
if (forceExit != null) {
741745
validateBoolean(forceExit, 'options.forceExit');
742746

@@ -930,6 +934,7 @@ function run(options = kEmptyObject) {
930934
randomize,
931935
randomSeed,
932936
testTagFilters,
937+
handleSignals,
933938
};
934939

935940
const root = createTestTree(rootTestOptions, globalOptions);
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { run } from 'node:test';
2+
import { tap } from 'node:test/reporters';
3+
4+
const mode = process.argv[2];
5+
const testFile = process.argv[3];
6+
7+
let handleSignals;
8+
9+
if (mode === 'handle-signals') {
10+
handleSignals = true;
11+
} else if (mode === 'no-handle-signals') {
12+
handleSignals = false;
13+
}
14+
15+
if (mode === 'default' || mode === 'no-handle-signals') {
16+
process.on('SIGINT', () => {
17+
process.stdout.write('user SIGINT handler\n', () => {
18+
process.exit(0);
19+
});
20+
});
21+
}
22+
23+
const stream = run({
24+
files: [testFile],
25+
handleSignals,
26+
isolation: 'none',
27+
});
28+
29+
stream.once('test:dequeue', () => {
30+
console.log('READY');
31+
});
32+
33+
stream.compose(tap).pipe(process.stdout);

test/parallel/test-runner-run.mjs

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import * as common from '../common/index.mjs';
22
import * as fixtures from '../common/fixtures.mjs';
33
import { basename, join } from 'node:path';
4+
import { spawn } from 'node:child_process';
5+
import { once } from 'node:events';
6+
import { finished } from 'node:stream/promises';
47
import { describe, it, run } from 'node:test';
58
import { dot, spec, tap } from 'node:test/reporters';
69
import consumers from 'node:stream/consumers';
@@ -590,6 +593,12 @@ describe('require(\'node:test\').run', { concurrency: true }, () => {
590593
assert.deepStrictEqual(executedTestFiles.sort(), [...shardsTestsFiles].sort());
591594
});
592595

596+
it('should only allow boolean in options,handleSignals', () => {
597+
[Symbol(), {}, [], () => {}, 0, 1, 0n, 1n, '', '1', Promise.resolve([])]
598+
.forEach((handleSignals) => assert.throws(() => run({ handleSignals }), {
599+
code: 'ERR_INVALID_ARG_TYPE',
600+
}));
601+
});
593602
});
594603

595604
describe('randomization', () => {
@@ -819,6 +828,74 @@ describe('require(\'node:test\').run', { concurrency: true }, () => {
819828
assert.strictEqual(diagnostics.includes(entry), true);
820829
}
821830
});
831+
832+
async function runHandleSignalsFixture(mode) {
833+
if (common.isWindows) {
834+
common.printSkipMessage('signals are not supported on Windows');
835+
return null;
836+
}
837+
838+
let stdout = '';
839+
let sentSignal = false;
840+
841+
const child = spawn(process.execPath, [
842+
fixtures.path('test-runner', 'run-handle-signals.mjs'),
843+
mode,
844+
fixtures.path('test-runner', 'never_ending_async.js'),
845+
]);
846+
847+
const timeout = setTimeout(() => {
848+
child.kill('SIGINT');
849+
}, common.platformTimeout(5000));
850+
851+
child.stdout.setEncoding('utf8');
852+
child.stdout.on('data', (chunk) => {
853+
stdout += chunk;
854+
855+
if (!sentSignal && stdout.includes('READY')) {
856+
sentSignal = true;
857+
child.kill('SIGINT');
858+
}
859+
});
860+
861+
const [code, signal] = await once(child, 'exit');
862+
clearTimeout(timeout);
863+
await finished(child.stdout);
864+
865+
return { code, signal, stdout };
866+
}
867+
868+
describe('handleSignals', () => {
869+
it('should handle SIGINT when handleSignals is true', async () => {
870+
const result = await runHandleSignalsFixture('handle-signals');
871+
if (result === null) return;
872+
873+
assert.strictEqual(result.signal, null);
874+
assert.strictEqual(result.code, 1);
875+
assert.match(result.stdout, /Interrupted while running:/);
876+
assert.match(result.stdout, /never_ending_async\.js/);
877+
});
878+
879+
it('should not handle SIGINT by default in run() API', async () => {
880+
const result = await runHandleSignalsFixture('default');
881+
if (result === null) return;
882+
883+
assert.strictEqual(result.signal, null);
884+
assert.strictEqual(result.code, 0);
885+
assert.match(result.stdout, /user SIGINT handler/);
886+
assert.doesNotMatch(result.stdout, /Interrupted while running/);
887+
});
888+
889+
it('should not handle SIGINT when handleSignals is false', async () => {
890+
const result = await runHandleSignalsFixture('no-handle-signals');
891+
if (result === null) return;
892+
893+
assert.strictEqual(result.signal, null);
894+
assert.strictEqual(result.code, 0);
895+
assert.match(result.stdout, /user SIGINT handler/);
896+
assert.doesNotMatch(result.stdout, /Interrupted while running/);
897+
});
898+
});
822899
});
823900

824901
describe('env', () => {

0 commit comments

Comments
 (0)