diff --git a/doc/api/test.md b/doc/api/test.md index 692d8686545040..b8c4c3a74fa782 100644 --- a/doc/api/test.md +++ b/doc/api/test.md @@ -3347,6 +3347,28 @@ This event is guaranteed to be emitted in the same order as the tests are defined. The corresponding execution ordered event is `'test:complete'`. +### Event: `'test:interrupted'` + +* `data` {Object} + * `tests` {Array} An array of objects containing information about the + interrupted tests. + * `column` {number|undefined} The column number where the test is defined, + or `undefined` if the test was run through the REPL. + * `file` {string|undefined} The path of the test file, + `undefined` if test was run through the REPL. + * `line` {number|undefined} The line number where the test is defined, or + `undefined` if the test was run through the REPL. + * `name` {string} The test name. + * `nesting` {number} The nesting level of the test. + +Emitted when the test runner is interrupted by a `SIGINT` signal (e.g., when +pressing Ctrl+C). The event contains information about +the tests that were running at the time of interruption. + +When using process isolation (the default), the test name will be the file path +since the parent runner only knows about file-level tests. When using +`--test-isolation=none`, the actual test name is shown. + ### Event: `'test:pass'` * `data` {Object} diff --git a/lib/internal/test_runner/harness.js b/lib/internal/test_runner/harness.js index 6b3b13b2c88d65..5418a14a4410a4 100644 --- a/lib/internal/test_runner/harness.js +++ b/lib/internal/test_runner/harness.js @@ -3,6 +3,7 @@ const { ArrayPrototypeForEach, ArrayPrototypePush, FunctionPrototypeBind, + Promise, PromiseResolve, PromiseWithResolvers, SafeMap, @@ -32,7 +33,7 @@ const { PassThrough, compose } = require('stream'); const { reportReruns } = require('internal/test_runner/reporter/rerun'); const { queueMicrotask } = require('internal/process/task_queues'); const { TIMEOUT_MAX } = require('internal/timers'); -const { clearInterval, setInterval } = require('timers'); +const { clearInterval, setImmediate, setInterval } = require('timers'); const { bigint: hrtime } = process.hrtime; const testResources = new SafeMap(); let globalRoot; @@ -289,7 +290,33 @@ function setupProcessState(root, globalOptions) { } }; + const findRunningTests = (test, running = []) => { + if (test.startTime !== null && !test.finished) { + for (let i = 0; i < test.subtests.length; i++) { + findRunningTests(test.subtests[i], running); + } + // Only add leaf tests (innermost running tests) + if (test.activeSubtests === 0 && test.name !== '') { + ArrayPrototypePush(running, { + __proto__: null, + name: test.name, + nesting: test.nesting, + file: test.loc?.file, + line: test.loc?.line, + column: test.loc?.column, + }); + } + } + return running; + }; + const terminationHandler = async () => { + const runningTests = findRunningTests(root); + if (runningTests.length > 0) { + root.reporter.interrupted(runningTests); + // Allow the reporter stream to process the interrupted event + await new Promise((resolve) => setImmediate(resolve)); + } await exitHandler(true); process.exit(); }; diff --git a/lib/internal/test_runner/reporter/spec.js b/lib/internal/test_runner/reporter/spec.js index 14c447f316492f..fce0754e25061a 100644 --- a/lib/internal/test_runner/reporter/spec.js +++ b/lib/internal/test_runner/reporter/spec.js @@ -106,8 +106,31 @@ class SpecReporter extends Transform { break; case 'test:watch:restarted': return `\nRestarted at ${DatePrototypeToLocaleString(new Date())}\n`; + case 'test:interrupted': + return this.#formatInterruptedTests(data.tests); } } + #formatInterruptedTests(tests) { + if (tests.length === 0) { + return ''; + } + + const results = [ + `\n${colors.yellow}Interrupted while running:${colors.white}\n`, + ]; + + for (let i = 0; i < tests.length; i++) { + const test = tests[i]; + let msg = `${indent(test.nesting)}${reporterUnicodeSymbolMap['warning:alert']}${test.name}`; + if (test.file) { + const relPath = relative(this.#cwd, test.file); + msg += ` ${colors.gray}(${relPath}:${test.line}:${test.column})${colors.white}`; + } + ArrayPrototypePush(results, msg); + } + + return ArrayPrototypeJoin(results, '\n') + '\n'; + } _transform({ type, data }, encoding, callback) { callback(null, this.#handleEvent({ __proto__: null, type, data })); } diff --git a/lib/internal/test_runner/reporter/tap.js b/lib/internal/test_runner/reporter/tap.js index 01c698871b9134..5d25fdda15959f 100644 --- a/lib/internal/test_runner/reporter/tap.js +++ b/lib/internal/test_runner/reporter/tap.js @@ -61,6 +61,16 @@ async function * tapReporter(source) { case 'test:coverage': yield getCoverageReport(indent(data.nesting), data.summary, '# ', '', true); break; + case 'test:interrupted': + for (let i = 0; i < data.tests.length; i++) { + const test = data.tests[i]; + let msg = `Interrupted while running: ${test.name}`; + if (test.file) { + msg += ` at ${test.file}:${test.line}:${test.column}`; + } + yield `# ${tapEscape(msg)}\n`; + } + break; } } } diff --git a/lib/internal/test_runner/tests_stream.js b/lib/internal/test_runner/tests_stream.js index 7b64487696f53f..17b6890b5fc5df 100644 --- a/lib/internal/test_runner/tests_stream.js +++ b/lib/internal/test_runner/tests_stream.js @@ -149,6 +149,13 @@ class TestsStream extends Readable { }); } + interrupted(tests) { + this[kEmitMessage]('test:interrupted', { + __proto__: null, + tests, + }); + } + end() { this.#tryPush(null); } diff --git a/test/parallel/test-runner-exit-code.js b/test/parallel/test-runner-exit-code.js index 792c5f1717bd60..4024a52841bb28 100644 --- a/test/parallel/test-runner-exit-code.js +++ b/test/parallel/test-runner-exit-code.js @@ -6,7 +6,7 @@ const { spawnSync, spawn } = require('child_process'); const { once } = require('events'); const { finished } = require('stream/promises'); -async function runAndKill(file) { +async function runAndKill(file, expectedTestName) { if (common.isWindows) { common.printSkipMessage(`signals are not supported in windows, skipping ${file}`); return; @@ -21,6 +21,9 @@ async function runAndKill(file) { const [code, signal] = await once(child, 'exit'); await finished(child.stdout); assert(stdout.startsWith('TAP version 13\n')); + // Verify interrupted test message + assert(stdout.includes(`Interrupted while running: ${expectedTestName}`), + `Expected output to contain interrupted test name`); assert.strictEqual(signal, null); assert.strictEqual(code, 1); } @@ -67,6 +70,10 @@ if (process.argv[2] === 'child') { assert.strictEqual(child.status, 1); assert.strictEqual(child.signal, null); - runAndKill(fixtures.path('test-runner', 'never_ending_sync.js')).then(common.mustCall()); - runAndKill(fixtures.path('test-runner', 'never_ending_async.js')).then(common.mustCall()); + // With process isolation (default), the test name shown is the file path + // because the parent runner only knows about file-level tests + const neverEndingSync = fixtures.path('test-runner', 'never_ending_sync.js'); + const neverEndingAsync = fixtures.path('test-runner', 'never_ending_async.js'); + runAndKill(neverEndingSync, neverEndingSync).then(common.mustCall()); + runAndKill(neverEndingAsync, neverEndingAsync).then(common.mustCall()); }