node/lib/internal/test_runner/reporter/spec.js
Matteo Collina 7215ea428c
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: https://github.com/nodejs/node/pull/61676
Reviewed-By: Moshe Atlow <moshe@atlow.co.il>
Reviewed-By: Chemi Atlow <chemi@atlow.co.il>
2026-02-14 16:29:52 +00:00

142 lines
4.6 KiB
JavaScript

'use strict';
const {
ArrayPrototypeJoin,
ArrayPrototypePop,
ArrayPrototypePush,
ArrayPrototypeShift,
ArrayPrototypeUnshift,
Date,
DatePrototypeToLocaleString,
} = primordials;
const assert = require('assert');
const Transform = require('internal/streams/transform');
const colors = require('internal/util/colors');
const { kSubtestsFailed } = require('internal/test_runner/test');
const { getCoverageReport } = require('internal/test_runner/utils');
const { relative } = require('path');
const {
formatTestReport,
indent,
reporterColorMap,
reporterUnicodeSymbolMap,
} = require('internal/test_runner/reporter/utils');
class SpecReporter extends Transform {
#stack = [];
#reported = [];
#failedTests = [];
#cwd = process.cwd();
constructor() {
super({ __proto__: null, writableObjectMode: true });
colors.refresh();
}
#formatFailedTestResults() {
if (this.#failedTests.length === 0) {
return '';
}
const results = [
`\n${reporterColorMap['test:fail']}${reporterUnicodeSymbolMap['test:fail']}failing tests:${colors.white}\n`,
];
for (let i = 0; i < this.#failedTests.length; i++) {
const test = this.#failedTests[i];
const formattedErr = formatTestReport('test:fail', test);
if (test.file) {
const relPath = relative(this.#cwd, test.file);
const location = `test at ${relPath}:${test.line}:${test.column}`;
ArrayPrototypePush(results, location);
}
ArrayPrototypePush(results, formattedErr);
}
this.#failedTests = []; // Clean up the failed tests
return ArrayPrototypeJoin(results, '\n');
}
#handleTestReportEvent(type, data) {
const subtest = ArrayPrototypeShift(this.#stack); // This is the matching `test:start` event
if (subtest) {
assert(subtest.type === 'test:start');
assert(subtest.data.nesting === data.nesting);
assert(subtest.data.name === data.name);
}
let prefix = '';
while (this.#stack.length) {
// Report all the parent `test:start` events
const parent = ArrayPrototypePop(this.#stack);
assert(parent.type === 'test:start');
const msg = parent.data;
ArrayPrototypeUnshift(this.#reported, msg);
prefix += `${indent(msg.nesting)}${reporterUnicodeSymbolMap['arrow:right']}${msg.name}\n`;
}
const indentation = indent(data.nesting);
return `${formatTestReport(type, data, false, prefix, indentation)}\n`;
}
#handleEvent({ type, data }) {
switch (type) {
case 'test:fail':
if (data.details?.error?.failureType !== kSubtestsFailed) {
ArrayPrototypePush(this.#failedTests, data);
}
return this.#handleTestReportEvent(type, data);
case 'test:pass':
return this.#handleTestReportEvent(type, data);
case 'test:start':
ArrayPrototypeUnshift(this.#stack, { __proto__: null, data, type });
break;
case 'test:stderr':
case 'test:stdout':
return data.message;
case 'test:diagnostic':{
const diagnosticColor = reporterColorMap[data.level] || reporterColorMap['test:diagnostic'];
return `${diagnosticColor}${indent(data.nesting)}${reporterUnicodeSymbolMap[type]}${data.message}${colors.white}\n`;
}
case 'test:coverage':
return getCoverageReport(indent(data.nesting), data.summary,
reporterUnicodeSymbolMap['test:coverage'], colors.blue, true);
case 'test:summary':
// We report only the root test summary
if (data.file === undefined) {
return this.#formatFailedTestResults();
}
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 }));
}
_flush(callback) {
callback(null, this.#formatFailedTestResults());
}
}
module.exports = SpecReporter;