Skip to content

Commit 1ca20fc

Browse files
mcollinaaduh95
authored andcommitted
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. PR-URL: #61676 Reviewed-By: Moshe Atlow <moshe@atlow.co.il> Reviewed-By: Chemi Atlow <chemi@atlow.co.il>
1 parent 5cdcba1 commit 1ca20fc

6 files changed

Lines changed: 104 additions & 4 deletions

File tree

‎doc/api/test.md‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3343,6 +3343,32 @@ This event is guaranteed to be emitted in the same order as the tests are
33433343
defined.
33443344
The corresponding execution ordered event is `'test:complete'`.
33453345

3346+
### Event: `'test:interrupted'`
3347+
3348+
<!-- YAML
3349+
added: REPLACEME
3350+
-->
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+
33463372
### Event: `'test:pass'`
33473373

33483374
*`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
consttestResources=newSafeMap();
3839
letglobalRoot;
@@ -289,7 +290,33 @@ function setupProcessState(root, globalOptions) {
289290
}
290291
};
291292

293+
constfindRunningTests=(test,running=[])=>{
294+
if(test.startTime!==null&&!test.finished){
295+
for(leti=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+
returnrunning;
311+
};
312+
292313
constterminationHandler=async()=>{
314+
construnningTests=findRunningTests(root);
315+
if(runningTests.length>0){
316+
root.reporter.interrupted(runningTests);
317+
// Allow the reporter stream to process the interrupted event
318+
awaitnewPromise((resolve)=>setImmediate(resolve));
319+
}
293320
awaitexitHandler(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(newDate())}\n`;
109+
case'test:interrupted':
110+
returnthis.#formatInterruptedTests(data.tests);
109111
}
110112
}
113+
#formatInterruptedTests(tests){
114+
if(tests.length===0){
115+
return'';
116+
}
117+
118+
constresults=[
119+
`\n${colors.yellow}Interrupted while running:${colors.white}\n`,
120+
];
121+
122+
for(leti=0;i<tests.length;i++){
123+
consttest=tests[i];
124+
letmsg=`${indent(test.nesting)}${reporterUnicodeSymbolMap['warning:alert']}${test.name}`;
125+
if(test.file){
126+
constrelPath=relative(this.#cwd,test.file);
127+
msg+=` ${colors.gray}(${relPath}:${test.line}:${test.column})${colors.white}`;
128+
}
129+
ArrayPrototypePush(results,msg);
130+
}
131+
132+
returnArrayPrototypeJoin(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
yieldgetCoverageReport(indent(data.nesting),data.summary,'# ','',true);
6363
break;
64+
case'test:interrupted':
65+
for(leti=0;i<data.tests.length;i++){
66+
consttest=data.tests[i];
67+
letmsg=`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-
asyncfunctionrunAndKill(file){
9+
asyncfunctionrunAndKill(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]=awaitonce(child,'exit');
2222
awaitfinished(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+
constneverEndingSync=fixtures.path('test-runner','never_ending_sync.js');
76+
constneverEndingAsync=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)