GeezerVison is showing github.com — test_runner: show interrupted test on SIGINT · nodejs/node@a433759
File treeExpand file treeCollapse file tree Open diff view settings Expand file treeCollapse file tree Open diff view settings Collapse file Copy file name to clipboardExpand all lines: doc/api/test.md +26Lines changed: 26 additions & 0 deletions - Display the source diff
- Display the rich diff
| Original file line number | Diff line number | Diff line change |
|---|
@@ -3348,6 +3348,32 @@ This event is guaranteed to be emitted in the same order as the tests are | 3348 | 3348 | defined. | 3349 | 3349 | The corresponding execution ordered event is `'test:complete'`. | 3350 | 3350 |
| | 3351 | +### Event: `'test:interrupted'` | | 3352 | + | | 3353 | +<!-- YAML | | 3354 | +added: REPLACEME | | 3355 | +--> | | 3356 | + | | 3357 | +* `data` {Object} | | 3358 | + * `tests` {Array} An array of objects containing information about the | | 3359 | + interrupted tests. | | 3360 | + * `column` {number|undefined} The column number where the test is defined, | | 3361 | + or `undefined` if the test was run through the REPL. | | 3362 | + * `file` {string|undefined} The path of the test file, | | 3363 | + `undefined` if test was run through the REPL. | | 3364 | + * `line` {number|undefined} The line number where the test is defined, or | | 3365 | + `undefined` if the test was run through the REPL. | | 3366 | + * `name` {string} The test name. | | 3367 | + * `nesting` {number} The nesting level of the test. | | 3368 | + | | 3369 | +Emitted when the test runner is interrupted by a `SIGINT` signal (e.g., when | | 3370 | +pressing <kbd>Ctrl</kbd>+<kbd>C</kbd>). The event contains information about | | 3371 | +the tests that were running at the time of interruption. | | 3372 | + | | 3373 | +When using process isolation (the default), the test name will be the file path | | 3374 | +since the parent runner only knows about file-level tests. When using | | 3375 | +`--test-isolation=none`, the actual test name is shown. | | 3376 | + | 3351 | 3377 | ### Event: `'test:pass'` | 3352 | 3378 |
| 3353 | 3379 | * `data` {Object} | |
Collapse file Copy file name to clipboardExpand all lines: lib/internal/test_runner/harness.js +28-1Lines changed: 28 additions & 1 deletion | Original file line number | Diff line number | Diff line change |
|---|
| 3 | 3 | ArrayPrototypeForEach, | 4 | 4 | ArrayPrototypePush, | 5 | 5 | FunctionPrototypeBind, | | 6 | + Promise, | 6 | 7 | PromiseResolve, | 7 | 8 | PromiseWithResolvers, | 8 | 9 | SafeMap, | @@ -32,7 +33,7 @@ const { PassThrough, compose } = require('stream'); | 32 | 33 | const { reportReruns } = require('internal/test_runner/reporter/rerun'); | 33 | 34 | const { queueMicrotask } = require('internal/process/task_queues'); | 34 | 35 | const { TIMEOUT_MAX } = require('internal/timers'); | 35 | | -const { clearInterval, setInterval } = require('timers'); | | 36 | +const { clearInterval, setImmediate, setInterval } = require('timers'); | 36 | 37 | const { bigint: hrtime } = process.hrtime; | 37 | 38 | const testResources = new SafeMap(); | 38 | 39 | let globalRoot; | @@ -289,7 +290,33 @@ function setupProcessState(root, globalOptions) { | 289 | 290 | } | 290 | 291 | }; | 291 | 292 |
| | 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 | + | 292 | 313 | 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 | + } | 293 | 320 | await exitHandler(true); | 294 | 321 | process.exit(); | 295 | 322 | }; | |
Collapse file Copy file name to clipboardExpand all lines: lib/internal/test_runner/reporter/spec.js +23Lines changed: 23 additions & 0 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -106,8 +106,31 @@ class SpecReporter extends Transform { | 106 | 106 | break; | 107 | 107 | case 'test:watch:restarted': | 108 | 108 | return `\nRestarted at ${DatePrototypeToLocaleString(new Date())}\n`; | | 109 | + case 'test:interrupted': | | 110 | + return this.#formatInterruptedTests(data.tests); | 109 | 111 | } | 110 | 112 | } | | 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 | + } | 111 | 134 | _transform({ type, data }, encoding, callback) { | 112 | 135 | callback(null, this.#handleEvent({ __proto__: null, type, data })); | 113 | 136 | } | |
Collapse file Copy file name to clipboardExpand all lines: lib/internal/test_runner/reporter/tap.js +10Lines changed: 10 additions & 0 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -61,6 +61,16 @@ async function * tapReporter(source) { | 61 | 61 | case 'test:coverage': | 62 | 62 | yield getCoverageReport(indent(data.nesting), data.summary, '# ', '', true); | 63 | 63 | 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; | 64 | 74 | } | 65 | 75 | } | 66 | 76 | } | |
Collapse file Copy file name to clipboardExpand all lines: lib/internal/test_runner/tests_stream.js +7Lines changed: 7 additions & 0 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -149,6 +149,13 @@ class TestsStream extends Readable { | 149 | 149 | }); | 150 | 150 | } | 151 | 151 |
| | 152 | + interrupted(tests) { | | 153 | + this[kEmitMessage]('test:interrupted', { | | 154 | + __proto__: null, | | 155 | + tests, | | 156 | + }); | | 157 | + } | | 158 | + | 152 | 159 | end() { | 153 | 160 | this.#tryPush(null); | 154 | 161 | } | |
Collapse file Copy file name to clipboardExpand all lines: test/parallel/test-runner-exit-code.js +10-3Lines changed: 10 additions & 3 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -6,7 +6,7 @@ const { spawnSync, spawn } = require('child_process'); | 6 | 6 | const { once } = require('events'); | 7 | 7 | const { finished } = require('stream/promises'); | 8 | 8 |
| 9 | | -async function runAndKill(file) { | | 9 | +async function runAndKill(file, expectedTestName) { | 10 | 10 | if (common.isWindows) { | 11 | 11 | common.printSkipMessage(`signals are not supported in windows, skipping ${file}`); | 12 | 12 | return; | @@ -21,6 +21,9 @@ async function runAndKill(file) { | 21 | 21 | const [code, signal] = await once(child, 'exit'); | 22 | 22 | await finished(child.stdout); | 23 | 23 | 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`); | 24 | 27 | assert.strictEqual(signal, null); | 25 | 28 | assert.strictEqual(code, 1); | 26 | 29 | } | @@ -67,6 +70,10 @@ if (process.argv[2] === 'child') { | 67 | 70 | assert.strictEqual(child.status, 1); | 68 | 71 | assert.strictEqual(child.signal, null); | 69 | 72 |
| 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()); | 72 | 79 | } |
|