From 0bfc0f958d58ab28681569450f5e09cb8fabe240 Mon Sep 17 00:00:00 2001 From: mete0rfish Date: Wed, 8 Jul 2026 21:16:58 +0900 Subject: [PATCH] test_runner: add handleSignals option to run() Signed-off-by: mete0rfish --- doc/api/test.md | 5 ++ lib/internal/test_runner/harness.js | 15 +++- lib/internal/test_runner/runner.js | 5 ++ .../test-runner/run-handle-signals.mjs | 33 ++++++++ test/parallel/test-runner-run.mjs | 77 +++++++++++++++++++ 5 files changed, 132 insertions(+), 3 deletions(-) create mode 100644 test/fixtures/test-runner/run-handle-signals.mjs diff --git a/doc/api/test.md b/doc/api/test.md index 504dcf9a99336c..6264e04358b4be 100644 --- a/doc/api/test.md +++ b/doc/api/test.md @@ -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 diff --git a/lib/internal/test_runner/harness.js b/lib/internal/test_runner/harness.js index 16f9392bf7763b..dbf2033d6ec9ad 100644 --- a/lib/internal/test_runner/harness.js +++ b/lib/internal/test_runner/harness.js @@ -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 @@ -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); } @@ -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); @@ -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); } diff --git a/lib/internal/test_runner/runner.js b/lib/internal/test_runner/runner.js index a4441ea31a9dc9..e26019ebcd82de 100644 --- a/lib/internal/test_runner/runner.js +++ b/lib/internal/test_runner/runner.js @@ -715,6 +715,7 @@ function run(options = kEmptyObject) { isolation = 'process', watch, setup, + handleSignals, globalSetupPath, only, globPatterns, @@ -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'); @@ -930,6 +934,7 @@ function run(options = kEmptyObject) { randomize, randomSeed, testTagFilters, + handleSignals, }; const root = createTestTree(rootTestOptions, globalOptions); diff --git a/test/fixtures/test-runner/run-handle-signals.mjs b/test/fixtures/test-runner/run-handle-signals.mjs new file mode 100644 index 00000000000000..1f1d01ea3ee8e0 --- /dev/null +++ b/test/fixtures/test-runner/run-handle-signals.mjs @@ -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); diff --git a/test/parallel/test-runner-run.mjs b/test/parallel/test-runner-run.mjs index b6eb6b6af51877..5571f88c923ba4 100644 --- a/test/parallel/test-runner-run.mjs +++ b/test/parallel/test-runner-run.mjs @@ -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'; @@ -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', () => { @@ -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', () => {