Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions doc/api/test.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <kbd>Ctrl</kbd>+<kbd>C</kbd>). 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}
Expand Down
29 changes: 28 additions & 1 deletion lib/internal/test_runner/harness.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ const {
ArrayPrototypeForEach,
ArrayPrototypePush,
FunctionPrototypeBind,
Promise,
PromiseResolve,
PromiseWithResolvers,
SafeMap,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 !== '<root>') {
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();
};
Expand Down
23 changes: 23 additions & 0 deletions lib/internal/test_runner/reporter/spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }));
}
Expand Down
10 changes: 10 additions & 0 deletions lib/internal/test_runner/reporter/tap.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
}
Expand Down
7 changes: 7 additions & 0 deletions lib/internal/test_runner/tests_stream.js
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,13 @@ class TestsStream extends Readable {
});
}

interrupted(tests) {
this[kEmitMessage]('test:interrupted', {
__proto__: null,
tests,
});
}

end() {
this.#tryPush(null);
}
Expand Down
13 changes: 10 additions & 3 deletions test/parallel/test-runner-exit-code.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
}
Expand Down Expand Up @@ -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());
}
Loading