diff --git a/doc/api/test.md b/doc/api/test.md index 88a602758f4f7b..1c1b6bc3b8019a 100644 --- a/doc/api/test.md +++ b/doc/api/test.md @@ -1502,6 +1502,8 @@ changes: * `functionCoverage` {number} Require a minimum percent of covered functions. If code coverage does not reach the threshold specified, the process will exit with code `1`. **Default:** `0`. + * `hookSignal` {boolean} Configures the test runner to handle termination signals like `SIGINT` and `SIGTERM`. + **Default:** `true`. * 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 bd4b35f1d64033..e1c072d6747ea9 100644 --- a/lib/internal/test_runner/harness.js +++ b/lib/internal/test_runner/harness.js @@ -297,8 +297,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 (globalOptions.isTestRunner && globalOptions.hookSignal !== true) { 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 1340b817a57e5b..ff1ebf1879f01f 100644 --- a/lib/internal/test_runner/runner.js +++ b/lib/internal/test_runner/runner.js @@ -593,6 +593,7 @@ function run(options = kEmptyObject) { argv = [], cwd = process.cwd(), rerunFailuresFilePath, + hookSignal = true, } = options; if (files != null) { @@ -708,6 +709,7 @@ function run(options = kEmptyObject) { // behavior has relied on it, so removing it must be done in a semver major. ...parseCommandLine(), setup, // This line can be removed when parseCommandLine() is removed here. + hookSignal, coverage, coverageExcludeGlobs, coverageIncludeGlobs, diff --git a/test/parallel/test-runner-exit-code.js b/test/parallel/test-runner-exit-code.js index d2f0251e5fb30c..b7a03e6acd76d8 100644 --- a/test/parallel/test-runner-exit-code.js +++ b/test/parallel/test-runner-exit-code.js @@ -2,7 +2,7 @@ const common = require('../common'); const fixtures = require('../common/fixtures'); const assert = require('assert'); -const { spawnSync, spawn } = require('child_process'); +const { spawnSync, spawn } = require('node:child_process'); const { once } = require('events'); const { finished } = require('stream/promises'); @@ -25,18 +25,35 @@ async function runAndKill(file) { assert.strictEqual(code, 1); } +async function spawnAndKill(childArgs, { code: expectedCode, signal: expectedSignal }) { + if (common.isWindows) { + common.printSkipMessage('signals are not supported on windows'); + return; + } + const child = spawn(process.execPath, childArgs); + child.stdout.once('data', () => child.kill('SIGINT')); + const [code, signal] = await once(child, 'exit'); + assert.strictEqual(signal, expectedSignal); + assert.strictEqual(code, expectedCode); +} + if (process.argv[2] === 'child') { - const test = require('node:test'); + const { test, run } = require('node:test'); if (process.argv[3] === 'pass') { test('passing test', () => { assert.strictEqual(true, true); }); } else if (process.argv[3] === 'fail') { - assert.strictEqual(process.argv[3], 'fail'); test('failing test', () => { assert.strictEqual(true, false); }); + } else if (process.argv[3] === 'run-signal-false') { + run({ files: [fixtures.path('test-runner', 'never_ending_async.js')] }); + console.log('child started'); + } else if (process.argv[3] === 'run-signal-true') { + run({ files: [fixtures.path('test-runner', 'never_ending_async.js')], hookSignal: true }); + console.log('child started'); } else assert.fail('unreachable'); } else { let child = spawnSync(process.execPath, [__filename, 'child', 'pass']); @@ -69,4 +86,15 @@ if (process.argv[2] === 'child') { runAndKill(fixtures.path('test-runner', 'never_ending_sync.js')).then(common.mustCall()); runAndKill(fixtures.path('test-runner', 'never_ending_async.js')).then(common.mustCall()); + + (async () => { + await spawnAndKill( + [__filename, 'child', 'run-signal-false'], + { signal: 'SIGINT', code: null }, + ); + await spawnAndKill( + [__filename, 'child', 'run-signal-true'], + { signal: null, code: 0 }, + ); + })().then(common.mustCall()); }