diff --git a/lib/internal/test_runner/test.js b/lib/internal/test_runner/test.js index 38ce54e4ea9b..6fcb54cd74e6 100644 --- a/lib/internal/test_runner/test.js +++ b/lib/internal/test_runner/test.js @@ -1484,6 +1484,12 @@ class Test extends AsyncResource { this.harness.teardown(); await SafePromiseAllReturnVoid(promises); + // Allow the event loop to drain any remaining write operations + // on the reporter destinations (e.g., child→parent report streams + // that are still flushing) before calling process.exit(). + // Without this, --test-force-exit with --test-concurrency can + // silently lose test verdicts. See nodejs/node#64833. + await new Promise((resolve) => setImmediate(resolve)); process.exit(); } } diff --git a/test/parallel/test-runner-force-exit-concurrency.mjs b/test/parallel/test-runner-force-exit-concurrency.mjs new file mode 100644 index 000000000000..1dea0963d87d --- /dev/null +++ b/test/parallel/test-runner-force-exit-concurrency.mjs @@ -0,0 +1,81 @@ +'use strict'; +// This test verifies that --test-force-exit with --test-concurrency > 1 +// does not silently lose test verdicts from child processes. +// Regression test for: https://github.com/nodejs/node/issues/64833 + +require('../common'); +const fixtures = require('../common/fixtures'); +const tmpdir = require('../common/tmpdir'); +const assert = require('node:assert'); +const { spawnSync } = require('node:child_process'); +const { readFileSync, writeFileSync, mkdirSync, rmSync } = require('node:fs'); +const { resolve } = require('node:path'); +const { test } = require('node:test'); + +tmpdir.refresh(); + +// Generate test files: N files × M tests each. +// We use a modest number to keep the test quick while still exercising +// the concurrent process-isolation path with force-exit. +const NUM_FILES = 8; +const TESTS_PER_FILE = 10; +const EXPECTED_TOTAL = NUM_FILES * TESTS_PER_FILE; + +const testDir = tmpdir.resolve('force-exit-concurrency-tests'); +rmSync(testDir, { recursive: true, force: true }); +mkdirSync(testDir); + +for (let f = 0; f < NUM_FILES; f++) { + const lines = ["import { test } from 'node:test';"]; + for (let t = 0; t < TESTS_PER_FILE; t++) { + lines.push( + `test('file ${f} test ${t} — concurrency force-exit regression test', () => {});` + ); + } + writeFileSync(`${testDir}/f${f}.test.mjs`, lines.join('\n')); +} + +// Custom reporter that counts leaf test verdicts. +// Uses synchronous writes to rule out reporter-side buffering. +const countReporter = tmpdir.resolve('count-reporter.mjs'); +writeFileSync(countReporter, ` +import fs from 'node:fs'; +export default async function* countReporter(source) { + let pass = 0, fail = 0; + for await (const event of source) { + if (event.type === 'test:pass' || event.type === 'test:fail') { + if (event.data.details?.type === 'test') { + if (event.type === 'test:pass') pass++; else fail++; + } + } + } + fs.writeSync(process.stderr.fd, JSON.stringify({ pass, fail, total: pass + fail }) + '\\n'); +} +`); + +test('--test-force-exit with --test-concurrency > 1 reports all tests', () => { + const args = [ + '--test', + '--test-force-exit', + '--test-concurrency=4', + '--test-reporter', countReporter, + '--test-reporter-destination', 'stderr', + `${testDir}/*.test.mjs`, + ]; + + // Run multiple times to catch the race. + for (let run = 0; run < 5; run++) { + const child = spawnSync(process.execPath, args, { encoding: 'utf8' }); + const stderr = child.stderr.toString(); + const match = stderr.match(/\{"pass":(\d+),"fail":(\d+),"total":(\d+)\}/); + assert.ok(match, `Run ${run}: count-reporter did not produce output. stderr: ${stderr}`); + const total = parseInt(match[3], 10); + + assert.strictEqual( + total, + EXPECTED_TOTAL, + `Run ${run}: expected ${EXPECTED_TOTAL} tests reported, got ${total}. ` + + `stderr: ${stderr.slice(0, 500)}` + ); + } +});