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());
}