Skip to content

Commit ad00a26

Browse files
committed
test_runner: show interrupted test on SIGINT
When the test runner process is killed with SIGINT (Ctrl+C), display which test was running at the time of interruption. This makes it easier to identify tests that hang or take too long. - Add `test:interrupted` event emitted when SIGINT is received - Add `interrupted()` method to TestsStream - Handle the event in both TAP and spec reporters - TAP outputs: `# Interrupted while running: <test>` - Spec outputs with yellow header and warning symbol - Use setImmediate to allow reporter stream to flush before exit With process isolation (default), shows the file path since the parent runner only knows about file-level tests. With --test-isolation=none, shows the actual test name.
1 parent f77a709 commit ad00a26

File tree

6 files changed

+100
-4
lines changed

6 files changed

+100
-4
lines changed

doc/api/test.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3347,6 +3347,28 @@ This event is guaranteed to be emitted in the same order as the tests are
33473347
defined.
33483348
The corresponding execution ordered event is `'test:complete'`.
33493349

3350+
### Event: `'test:interrupted'`
3351+
3352+
* `data` {Object}
3353+
* `tests` {Array} An array of objects containing information about the
3354+
interrupted tests.
3355+
* `column` {number|undefined} The column number where the test is defined,
3356+
or `undefined` if the test was run through the REPL.
3357+
* `file` {string|undefined} The path of the test file,
3358+
`undefined` if test was run through the REPL.
3359+
* `line` {number|undefined} The line number where the test is defined, or
3360+
`undefined` if the test was run through the REPL.
3361+
* `name` {string} The test name.
3362+
* `nesting` {number} The nesting level of the test.
3363+
3364+
Emitted when the test runner is interrupted by a `SIGINT` signal (e.g., when
3365+
pressing <kbd>Ctrl</kbd>+<kbd>C</kbd>). The event contains information about
3366+
the tests that were running at the time of interruption.
3367+
3368+
When using process isolation (the default), the test name will be the file path
3369+
since the parent runner only knows about file-level tests. When using
3370+
`--test-isolation=none`, the actual test name is shown.
3371+
33503372
### Event: `'test:pass'`
33513373

33523374
* `data` {Object}

lib/internal/test_runner/harness.js

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ const {
33
ArrayPrototypeForEach,
44
ArrayPrototypePush,
55
FunctionPrototypeBind,
6+
Promise,
67
PromiseResolve,
78
PromiseWithResolvers,
89
SafeMap,
@@ -32,7 +33,7 @@ const { PassThrough, compose } = require('stream');
3233
const { reportReruns } = require('internal/test_runner/reporter/rerun');
3334
const { queueMicrotask } = require('internal/process/task_queues');
3435
const { TIMEOUT_MAX } = require('internal/timers');
35-
const { clearInterval, setInterval } = require('timers');
36+
const { clearInterval, setImmediate, setInterval } = require('timers');
3637
const { bigint: hrtime } = process.hrtime;
3738
const testResources = new SafeMap();
3839
let globalRoot;
@@ -289,7 +290,33 @@ function setupProcessState(root, globalOptions) {
289290
}
290291
};
291292

293+
const findRunningTests = (test, running = []) => {
294+
if (test.startTime !== null && !test.finished) {
295+
for (let i = 0; i < test.subtests.length; i++) {
296+
findRunningTests(test.subtests[i], running);
297+
}
298+
// Only add leaf tests (innermost running tests)
299+
if (test.activeSubtests === 0 && test.name !== '<root>') {
300+
ArrayPrototypePush(running, {
301+
__proto__: null,
302+
name: test.name,
303+
nesting: test.nesting,
304+
file: test.loc?.file,
305+
line: test.loc?.line,
306+
column: test.loc?.column,
307+
});
308+
}
309+
}
310+
return running;
311+
};
312+
292313
const terminationHandler = async () => {
314+
const runningTests = findRunningTests(root);
315+
if (runningTests.length > 0) {
316+
root.reporter.interrupted(runningTests);
317+
// Allow the reporter stream to process the interrupted event
318+
await new Promise((resolve) => setImmediate(resolve));
319+
}
293320
await exitHandler(true);
294321
process.exit();
295322
};

lib/internal/test_runner/reporter/spec.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,8 +106,31 @@ class SpecReporter extends Transform {
106106
break;
107107
case 'test:watch:restarted':
108108
return `\nRestarted at ${DatePrototypeToLocaleString(new Date())}\n`;
109+
case 'test:interrupted':
110+
return this.#formatInterruptedTests(data.tests);
109111
}
110112
}
113+
#formatInterruptedTests(tests) {
114+
if (tests.length === 0) {
115+
return '';
116+
}
117+
118+
const results = [
119+
`\n${colors.yellow}Interrupted while running:${colors.white}\n`,
120+
];
121+
122+
for (let i = 0; i < tests.length; i++) {
123+
const test = tests[i];
124+
let msg = `${indent(test.nesting)}${reporterUnicodeSymbolMap['warning:alert']}${test.name}`;
125+
if (test.file) {
126+
const relPath = relative(this.#cwd, test.file);
127+
msg += ` ${colors.gray}(${relPath}:${test.line}:${test.column})${colors.white}`;
128+
}
129+
ArrayPrototypePush(results, msg);
130+
}
131+
132+
return ArrayPrototypeJoin(results, '\n') + '\n';
133+
}
111134
_transform({ type, data }, encoding, callback) {
112135
callback(null, this.#handleEvent({ __proto__: null, type, data }));
113136
}

lib/internal/test_runner/reporter/tap.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,16 @@ async function * tapReporter(source) {
6161
case 'test:coverage':
6262
yield getCoverageReport(indent(data.nesting), data.summary, '# ', '', true);
6363
break;
64+
case 'test:interrupted':
65+
for (let i = 0; i < data.tests.length; i++) {
66+
const test = data.tests[i];
67+
let msg = `Interrupted while running: ${test.name}`;
68+
if (test.file) {
69+
msg += ` at ${test.file}:${test.line}:${test.column}`;
70+
}
71+
yield `# ${tapEscape(msg)}\n`;
72+
}
73+
break;
6474
}
6575
}
6676
}

lib/internal/test_runner/tests_stream.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,13 @@ class TestsStream extends Readable {
149149
});
150150
}
151151

152+
interrupted(tests) {
153+
this[kEmitMessage]('test:interrupted', {
154+
__proto__: null,
155+
tests,
156+
});
157+
}
158+
152159
end() {
153160
this.#tryPush(null);
154161
}

test/parallel/test-runner-exit-code.js

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ const { spawnSync, spawn } = require('child_process');
66
const { once } = require('events');
77
const { finished } = require('stream/promises');
88

9-
async function runAndKill(file) {
9+
async function runAndKill(file, expectedTestName) {
1010
if (common.isWindows) {
1111
common.printSkipMessage(`signals are not supported in windows, skipping ${file}`);
1212
return;
@@ -21,6 +21,9 @@ async function runAndKill(file) {
2121
const [code, signal] = await once(child, 'exit');
2222
await finished(child.stdout);
2323
assert(stdout.startsWith('TAP version 13\n'));
24+
// Verify interrupted test message
25+
assert(stdout.includes(`Interrupted while running: ${expectedTestName}`),
26+
`Expected output to contain interrupted test name`);
2427
assert.strictEqual(signal, null);
2528
assert.strictEqual(code, 1);
2629
}
@@ -67,6 +70,10 @@ if (process.argv[2] === 'child') {
6770
assert.strictEqual(child.status, 1);
6871
assert.strictEqual(child.signal, null);
6972

70-
runAndKill(fixtures.path('test-runner', 'never_ending_sync.js')).then(common.mustCall());
71-
runAndKill(fixtures.path('test-runner', 'never_ending_async.js')).then(common.mustCall());
73+
// With process isolation (default), the test name shown is the file path
74+
// because the parent runner only knows about file-level tests
75+
const neverEndingSync = fixtures.path('test-runner', 'never_ending_sync.js');
76+
const neverEndingAsync = fixtures.path('test-runner', 'never_ending_async.js');
77+
runAndKill(neverEndingSync, neverEndingSync).then(common.mustCall());
78+
runAndKill(neverEndingAsync, neverEndingAsync).then(common.mustCall());
7279
}

0 commit comments

Comments
 (0)