From a4e165b5424146e78f397d903aa3f285cead8f56 Mon Sep 17 00:00:00 2001 From: hanityx Date: Thu, 6 Aug 2026 23:29:10 +0900 Subject: [PATCH 1/2] test_runner: attribute global console output to active tests Signed-off-by: hanityx --- doc/api/test.md | 108 ++++++-- lib/internal/console/constructor.js | 31 ++- lib/internal/test_runner/harness.js | 46 ++++ lib/internal/test_runner/runner.js | 5 +- lib/internal/test_runner/tests_stream.js | 24 ++ ...onsole-output-attribution-env-mutation.mjs | 17 ++ ...sole-output-attribution-frozen-console.mjs | 5 + ...console-output-attribution-mutates-env.mjs | 8 + ...onsole-output-attribution-no-isolation.mjs | 17 ++ .../console-output-attribution.mjs | 35 +++ test/fixtures/test-runner/freeze-console.js | 2 + ...test-runner-console-output-attribution.mjs | 246 ++++++++++++++++++ 12 files changed, 520 insertions(+), 24 deletions(-) create mode 100644 test/fixtures/test-runner/console-output-attribution-env-mutation.mjs create mode 100644 test/fixtures/test-runner/console-output-attribution-frozen-console.mjs create mode 100644 test/fixtures/test-runner/console-output-attribution-mutates-env.mjs create mode 100644 test/fixtures/test-runner/console-output-attribution-no-isolation.mjs create mode 100644 test/fixtures/test-runner/console-output-attribution.mjs create mode 100644 test/fixtures/test-runner/freeze-console.js create mode 100644 test/parallel/test-runner-console-output-attribution.mjs diff --git a/doc/api/test.md b/doc/api/test.md index 8922086884a2..ffe70f936982 100644 --- a/doc/api/test.md +++ b/doc/api/test.md @@ -3484,10 +3484,14 @@ are defined, while others are emitted in the order that the tests execute. The following tables summarize all events by scope. -Test scoped events are emitted once per test or suite. Most of them come in -pairs: a declaration ordered event, buffered so that events are emitted in the -same order as the tests are defined, and one or more corresponding execution -ordered events, emitted immediately as the tests execute. +Test scoped events are associated with a test or suite. Lifecycle events are +emitted once per test or suite, while [`'test:diagnostic'`][], +[`'test:log'`][], and attributed [`'test:stdout'`][] and +[`'test:stderr'`][] events may be emitted any number of times. Most lifecycle +events come in pairs: a declaration ordered event, buffered so that events are +emitted in the same order as the tests are defined, +and one or more corresponding execution ordered events, emitted immediately as +the tests execute. | Declaration ordered (buffered) | Execution ordered (immediate) | | ------------------------------ | ----------------------------------------------------- | @@ -3497,19 +3501,33 @@ ordered events, emitted immediately as the tests execute. | [`'test:plan'`][] | | | [`'test:diagnostic'`][] | | | | [`'test:log'`][] | +| | [`'test:stderr'`][] (attributed console output) | +| | [`'test:stdout'`][] (attributed console output) | [`'test:log'`][] is deliberately execution ordered only: it is the live counterpart of [`'test:diagnostic'`][]'s buffered reporting. -File scoped and global events are always emitted immediately, in execution -order. +With process isolation, output written through the built-in global console +during an active test or suite is associated with that test or suite and +emitted in execution order. Other output, such as direct writes to the process +streams, remains file scoped. -File scoped events are emitted once per test file: +Output from a hook is associated with the test or suite that owns the hook. +For example, output from a suite's `beforeEach()` hook is associated with the +suite rather than with the child test that triggered it. Output from +root-level hooks remains file scoped. + +Global events are always emitted immediately, in execution order. + +File scoped events carry information about a test file rather than an +individual test. [`'test:stdout'`][] and [`'test:stderr'`][] may be emitted +any number of times for a file, while the remaining events are emitted once +per test file: | Event | Notes | | -------------------- | ---------------------------------------------- | -| [`'test:stderr'`][] | Only emitted if the `--test` flag is passed. | -| [`'test:stdout'`][] | Only emitted if the `--test` flag is passed. | +| [`'test:stderr'`][] | Only emitted when process isolation is used. | +| [`'test:stdout'`][] | Only emitted when process isolation is used. | | [`'test:summary'`][] | Per file, only when process isolation is used. | Global events are emitted once per test run: @@ -3895,30 +3913,78 @@ The corresponding execution ordered event is `'test:dequeue'`. ### Event: `'test:stderr'` * `data` {Object} + * `column` {number|undefined} The column number where the test or suite is + defined. Only present when the output is associated with a test or suite + and that test or suite has a known source location. * `entryFile` {string|undefined} The path of the test file that was executed as the entry point of the child process that emitted this event. Only present when tests run with process isolation. - * `file` {string} The path of the test file. + * `file` {string} The path of the test file. When the output is associated + with a test or suite, this is the file where that test or suite is defined, + or the entry point of the child process if its source location is not + known. + * `line` {number|undefined} The line number where the test or suite is + defined. Only present when the output is associated with a test or suite + and that test or suite has a known source location. * `message` {string} The message written to `stderr`. - -Emitted when a running test writes to `stderr`. -This event is only emitted if `--test` flag is passed. -This event is not guaranteed to be emitted in the same order as the tests are -defined. + * `name` {string|undefined} The test or suite name. Only present when the + output is associated with a test or suite. + * `nesting` {number|undefined} The nesting level of the test or suite. Only + present when the output is associated with a test or suite. + * `parentId` {number|undefined} The `testId` of the enclosing test or suite. + Only present when the output is associated with a test or suite. + * `testId` {number|undefined} A numeric identifier for the test or suite. + Only present when the output is associated with a test or suite. + +Emitted when the test runner observes output written to `stderr`. This event is +only emitted when tests run with process isolation. Output written through the +built-in global console during an active test or suite is associated with that +test or suite and is emitted after its [`'test:dequeue'`][] event. Direct +writes to `process.stderr`, native output, output written through separately +constructed `Console` instances, top-level output outside an active test or +suite, root-level hook output, output produced while suites are being defined, +and asynchronous output after a test finishes are not associated with a test. +Their ordering relative to test events is not guaranteed. Because associated and +unassociated output reach the reporter through different paths, their relative +order is not guaranteed either, even within a single test. ### Event: `'test:stdout'` * `data` {Object} + * `column` {number|undefined} The column number where the test or suite is + defined. Only present when the output is associated with a test or suite + and that test or suite has a known source location. * `entryFile` {string|undefined} The path of the test file that was executed as the entry point of the child process that emitted this event. Only present when tests run with process isolation. - * `file` {string} The path of the test file. + * `file` {string} The path of the test file. When the output is associated + with a test or suite, this is the file where that test or suite is defined, + or the entry point of the child process if its source location is not + known. + * `line` {number|undefined} The line number where the test or suite is + defined. Only present when the output is associated with a test or suite + and that test or suite has a known source location. * `message` {string} The message written to `stdout`. - -Emitted when a running test writes to `stdout`. -This event is only emitted if `--test` flag is passed. -This event is not guaranteed to be emitted in the same order as the tests are -defined. + * `name` {string|undefined} The test or suite name. Only present when the + output is associated with a test or suite. + * `nesting` {number|undefined} The nesting level of the test or suite. Only + present when the output is associated with a test or suite. + * `parentId` {number|undefined} The `testId` of the enclosing test or suite. + Only present when the output is associated with a test or suite. + * `testId` {number|undefined} A numeric identifier for the test or suite. + Only present when the output is associated with a test or suite. + +Emitted when the test runner observes output written to `stdout`. This event is +only emitted when tests run with process isolation. Output written through the +built-in global console during an active test or suite is associated with that +test or suite and is emitted after its [`'test:dequeue'`][] event. Direct +writes to `process.stdout`, native output, output written through separately +constructed `Console` instances, top-level output outside an active test or +suite, root-level hook output, output produced while suites are being defined, +and asynchronous output after a test finishes are not associated with a test. +Their ordering relative to test events is not guaranteed. Because associated and +unassociated output reach the reporter through different paths, their relative +order is not guaranteed either, even within a single test. ### Event: `'test:summary'` diff --git a/lib/internal/console/constructor.js b/lib/internal/console/constructor.js index 9d653793f133..b22a911eff0f 100644 --- a/lib/internal/console/constructor.js +++ b/lib/internal/console/constructor.js @@ -12,6 +12,7 @@ const { Boolean, ErrorCaptureStackTrace, FunctionPrototypeBind, + FunctionPrototypeCall, MapPrototypeGet, MapPrototypeValues, ObjectDefineProperties, @@ -97,6 +98,17 @@ const kUseStdout = Symbol('kUseStdout'); const kUseStderr = Symbol('kUseStderr'); const optionsMap = new SafeWeakMap(); +// Store hooks separately so installing one does not mutate the Console +// instance, including when it has been frozen. +const writeToConsoleHooks = new SafeWeakMap(); + +// Registers a hook that runs just before a formatted string is written to the +// underlying stream. Returning true means the hook handled the output and the +// stream write is skipped. The hook must not throw. +function setWriteToConsoleHook(console, hook) { + writeToConsoleHooks.set(console, hook); +} + function Console(options /* or: stdout, stderr, ignoreErrors = true */) { // We have to test new.target here to see if this function is called // with new, because we need to define a custom instanceof to accommodate @@ -297,12 +309,26 @@ ObjectDefineProperties(Console.prototype, { } string += '\n'; - if (ignoreErrors === false) return stream.write(string); + const hook = writeToConsoleHooks.get(this); + + if (ignoreErrors === false) { + if (hook !== undefined && + FunctionPrototypeCall(hook, this, useStdout, string)) { + return; + } + return stream.write(string); + } // There may be an error occurring synchronously (e.g. for files or TTYs // on POSIX systems) or asynchronously (e.g. pipes on POSIX systems), so - // handle both situations. + // handle both situations. Run the hook inside the same error boundary so + // it cannot make a console write throw while errors are being ignored. try { + if (hook !== undefined && + FunctionPrototypeCall(hook, this, useStdout, string)) { + return; + } + // Add and later remove a noop error handler to catch synchronous // errors. if (stream.listenerCount('error') === 0) @@ -713,5 +739,6 @@ module.exports = { Console, kBindStreamsLazy, kBindProperties, + setWriteToConsoleHook, initializeGlobalConsole, }; diff --git a/lib/internal/test_runner/harness.js b/lib/internal/test_runner/harness.js index 16f9392bf776..f5fba36345c0 100644 --- a/lib/internal/test_runner/harness.js +++ b/lib/internal/test_runner/harness.js @@ -14,6 +14,10 @@ const { createHook, executionAsyncId, } = require('async_hooks'); +const globalConsole = require('internal/console/global'); +const { + setWriteToConsoleHook, +} = require('internal/console/constructor'); const { relative } = require('path'); const { codes: { @@ -230,6 +234,48 @@ function setupFailureStateFile(rootTest, globalOptions) { } function setupProcessState(root, globalOptions) { + // Attribution only works when the child reports through the V8 serializer, + // which preserves the structured event. The 'child' value selects the TAP + // reporter, which cannot carry testId/parentId/line/column, so intercepting + // there would drop the write without emitting an equivalent event. + // + // The environment is inspected once, while the runner-provided value is + // still intact, and the hook is only installed for that case. Ordinary + // processes, runs without process isolation, and TAP children leave the + // global console untouched, and assigning to process.env.NODE_TEST_CONTEXT + // later cannot turn attribution on. + if (process.env.NODE_TEST_CONTEXT === 'child-v8') { + setWriteToConsoleHook(globalConsole, (useStdout, message) => { + let test = testResources.get(executionAsyncId()); + if (test?.hookType !== undefined) { + test = test.parentTest; + } + if (test === undefined || + test === reporterScope || + test.parent === null || + test.startTime === null || + test.endTime !== null) { + return false; + } + + const report = useStdout ? + test.reporter.stdout : test.reporter.stderr; + reporterScope.runInAsyncScope( + report, + test.reporter, + test.nesting, + // Tests defined without a known source location still report the file + // they were executed from, so that 'file' stays a string. + test.loc ?? { __proto__: null, file: test.entryFile }, + message, + test.name, + test.testId, + test.parent.testId, + ); + return true; + }); + } + const hook = createHook({ __proto__: null, init(asyncId, type, triggerAsyncId, resource) { diff --git a/lib/internal/test_runner/runner.js b/lib/internal/test_runner/runner.js index a5a53e44d29a..43d8e84e561e 100644 --- a/lib/internal/test_runner/runner.js +++ b/lib/internal/test_runner/runner.js @@ -343,7 +343,10 @@ class FileTest extends Test { } } addToReport(item) { - if (kExecutionOrderedEvents.has(item.type)) { + const isAttributedOutput = + (item.type === 'test:stdout' || item.type === 'test:stderr') && + item.data.testId !== undefined; + if (kExecutionOrderedEvents.has(item.type) || isAttributedOutput) { this.#handleReportItem(item); return; } diff --git a/lib/internal/test_runner/tests_stream.js b/lib/internal/test_runner/tests_stream.js index 7fb514fb99e2..36d684fcfaeb 100644 --- a/lib/internal/test_runner/tests_stream.js +++ b/lib/internal/test_runner/tests_stream.js @@ -152,6 +152,30 @@ class TestsStream extends Readable { }); } + stdout(nesting, loc, message, name, testId, parentId) { + this[kEmitMessage]('test:stdout', { + __proto__: null, + name, + nesting, + testId, + parentId, + message, + ...loc, + }); + } + + stderr(nesting, loc, message, name, testId, parentId) { + this[kEmitMessage]('test:stderr', { + __proto__: null, + name, + nesting, + testId, + parentId, + message, + ...loc, + }); + } + diagnostic(nesting, loc, message, level = 'info') { this[kEmitMessage]('test:diagnostic', { __proto__: null, diff --git a/test/fixtures/test-runner/console-output-attribution-env-mutation.mjs b/test/fixtures/test-runner/console-output-attribution-env-mutation.mjs new file mode 100644 index 000000000000..a994e13d33bd --- /dev/null +++ b/test/fixtures/test-runner/console-output-attribution-env-mutation.mjs @@ -0,0 +1,17 @@ +import { run } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const fixture = fileURLToPath( + new URL('./console-output-attribution-mutates-env.mjs', import.meta.url), +); +const stream = run({ files: [fixture], isolation: 'none' }); +let attributed = 0; + +for await (const event of stream) { + if ((event.type === 'test:stdout' || event.type === 'test:stderr') && + 'testId' in event.data) { + attributed++; + } +} + +process.stdout.write(`__attributed_count__:${attributed}\n`); diff --git a/test/fixtures/test-runner/console-output-attribution-frozen-console.mjs b/test/fixtures/test-runner/console-output-attribution-frozen-console.mjs new file mode 100644 index 000000000000..900f3bcf4872 --- /dev/null +++ b/test/fixtures/test-runner/console-output-attribution-frozen-console.mjs @@ -0,0 +1,5 @@ +import { test } from 'node:test'; + +test('runs with a frozen console', () => { + console.log('frozen-console-out-8a4f'); +}); diff --git a/test/fixtures/test-runner/console-output-attribution-mutates-env.mjs b/test/fixtures/test-runner/console-output-attribution-mutates-env.mjs new file mode 100644 index 000000000000..4b71cf9dcbc0 --- /dev/null +++ b/test/fixtures/test-runner/console-output-attribution-mutates-env.mjs @@ -0,0 +1,8 @@ +import { test } from 'node:test'; + +test('mutates NODE_TEST_CONTEXT before writing', () => { + // Attribution is decided once, while the runner-provided environment is still + // intact. Assigning here must not turn it on for the write that follows. + process.env.NODE_TEST_CONTEXT = 'child-v8'; + console.log('env-mutated-out-2d9c'); +}); diff --git a/test/fixtures/test-runner/console-output-attribution-no-isolation.mjs b/test/fixtures/test-runner/console-output-attribution-no-isolation.mjs new file mode 100644 index 000000000000..7811d391f631 --- /dev/null +++ b/test/fixtures/test-runner/console-output-attribution-no-isolation.mjs @@ -0,0 +1,17 @@ +import { run } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const fixture = fileURLToPath( + new URL('./console-output-attribution.mjs', import.meta.url), +); +const stream = run({ files: [fixture], isolation: 'none' }); +let attributed = 0; + +for await (const event of stream) { + if ((event.type === 'test:stdout' || event.type === 'test:stderr') && + 'testId' in event.data) { + attributed++; + } +} + +process.stdout.write(`__attributed_count__:${attributed}\n`); diff --git a/test/fixtures/test-runner/console-output-attribution.mjs b/test/fixtures/test-runner/console-output-attribution.mjs new file mode 100644 index 000000000000..61c5fb410554 --- /dev/null +++ b/test/fixtures/test-runner/console-output-attribution.mjs @@ -0,0 +1,35 @@ +import { Console } from 'node:console'; +import { before, beforeEach, describe, test } from 'node:test'; + +console.log('toplevel-console-7b31'); +before(() => console.log('globalhook-console-52ac')); + +describe('concurrent tests', { concurrency: true }, () => { + console.log('suitedef-console-91de'); + before(() => console.log('suitehook-console-4f0a')); + beforeEach((t) => console.log(`beforeeach-console-${t.name}-6c2b`)); + + test('first', async () => { + console.log('attributed-out-first-a17e'); + const customConsole = new Console({ + stdout: process.stdout, + stderr: process.stderr, + }); + customConsole.log('customconsole-out-3e75'); + process.stdout.write('directstream-out-first-d40c\n'); + await new Promise((resolve) => setImmediate(resolve)); + console.error('attributed-err-first-b85f'); + process.stderr.write('directstream-err-first-e93a\n'); + }); + + test('second', () => { + console.log('attributed-out-second-c62d'); + setImmediate(() => console.log('late-console-f08b')); + }); +}); + +// A test declared through eval() has no source location, so its events carry +// no line or column. Its output still has to report a file. +eval(`test('no source location', () => { + console.log('attributed-out-noloc-5ad9'); +});`); diff --git a/test/fixtures/test-runner/freeze-console.js b/test/fixtures/test-runner/freeze-console.js new file mode 100644 index 000000000000..ba87d1648d35 --- /dev/null +++ b/test/fixtures/test-runner/freeze-console.js @@ -0,0 +1,2 @@ +'use strict'; +Object.freeze(console); diff --git a/test/parallel/test-runner-console-output-attribution.mjs b/test/parallel/test-runner-console-output-attribution.mjs new file mode 100644 index 000000000000..2bb3c560fa08 --- /dev/null +++ b/test/parallel/test-runner-console-output-attribution.mjs @@ -0,0 +1,246 @@ +// Flags: --no-warnings + +import * as common from '../common/index.mjs'; +import * as fixtures from '../common/fixtures.mjs'; +import assert from 'node:assert'; +import { test, run } from 'node:test'; + +const fixture = fixtures.path( + 'test-runner', + 'console-output-attribution.mjs', +); + +function findOutput(events, type, message) { + return events.findIndex((event) => + event.type === type && event.data.message === message); +} + +function assertAttributedOutput(events, type, message, name) { + const dequeueIndex = events.findIndex((event) => + event.type === 'test:dequeue' && event.data.name === name); + const outputIndex = findOutput(events, type, message); + + assert.notStrictEqual(dequeueIndex, -1); + assert.notStrictEqual(outputIndex, -1); + assert.ok(dequeueIndex < outputIndex); + + // Attributed output replaces the raw write, so it must be reported exactly + // once through the attributed path and must not reach the file scoped one. + // Raw events can be merged by the pipe, so the raw side is checked as a + // single joined string rather than per event. + const attributed = events.filter((event) => + event.type === type && 'testId' in event.data && + event.data.message === message); + assert.strictEqual(attributed.length, 1); + + const rawOutput = events + .filter((event) => event.type === type && !('testId' in event.data)) + .map((event) => event.data.message) + .join(''); + assert.ok(!rawOutput.includes(message)); + + const dequeue = events[dequeueIndex].data; + const output = events[outputIndex].data; + + // 'file' is documented as a string for these events. Attributed output must + // not weaken that, even for a test with no known source location. + assert.strictEqual(typeof output.file, 'string'); + + for (const key of [ + 'name', + 'nesting', + 'testId', + 'parentId', + 'file', + 'line', + 'column', + 'entryFile', + ]) { + assert.strictEqual(output[key], dequeue[key]); + } +} + +function assertUnattributedOutput(events, type, message) { + const output = events + .filter((event) => + event.type === type && !('testId' in event.data)) + .map((event) => event.data.message) + .join(''); + + assert.ok(output.includes(message)); +} + +test('global console output is attributed to the current test', async () => { + const stream = run({ + files: [fixture], + isolation: 'process', + }); + const events = []; + + for await (const event of stream) { + if ( + event.type === 'test:dequeue' || + event.type === 'test:stdout' || + event.type === 'test:stderr' + ) { + events.push(event); + } + } + + assertAttributedOutput( + events, + 'test:stdout', + 'suitehook-console-4f0a\n', + 'concurrent tests', + ); + assertAttributedOutput( + events, + 'test:stdout', + 'beforeeach-console-first-6c2b\n', + 'concurrent tests', + ); + assertAttributedOutput( + events, + 'test:stdout', + 'beforeeach-console-second-6c2b\n', + 'concurrent tests', + ); + assertAttributedOutput( + events, + 'test:stdout', + 'attributed-out-first-a17e\n', + 'first', + ); + assertAttributedOutput( + events, + 'test:stderr', + 'attributed-err-first-b85f\n', + 'first', + ); + assertAttributedOutput( + events, + 'test:stdout', + 'attributed-out-second-c62d\n', + 'second', + ); + + // A test with no known source location still reports a file, so that the + // documented type of 'file' holds for every attributed event. The event has + // to be the attributed one: the raw path also reports the file it came from, + // so checking the file alone would pass even if attribution never happened. + const noLocIndex = findOutput( + events, + 'test:stdout', + 'attributed-out-noloc-5ad9\n', + ); + const noLocDequeueIndex = events.findIndex((event) => + event.type === 'test:dequeue' && + event.data.name === 'no source location'); + + assert.notStrictEqual(noLocIndex, -1); + assert.notStrictEqual(noLocDequeueIndex, -1); + assert.ok(noLocDequeueIndex < noLocIndex); + + const noLoc = events[noLocIndex].data; + const noLocDequeue = events[noLocDequeueIndex].data; + + assert.ok('testId' in noLoc); + assert.strictEqual(noLoc.name, 'no source location'); + assert.strictEqual(noLoc.testId, noLocDequeue.testId); + assert.strictEqual(noLoc.parentId, noLocDequeue.parentId); + assert.strictEqual(noLoc.nesting, noLocDequeue.nesting); + assert.strictEqual(noLoc.entryFile, noLocDequeue.entryFile); + assert.strictEqual(typeof noLoc.file, 'string'); + assert.strictEqual(noLoc.file, noLoc.entryFile); + assert.strictEqual(noLoc.line, undefined); + assert.strictEqual(noLoc.column, undefined); + + const noLocRaw = events + .filter((event) => + event.type === 'test:stdout' && !('testId' in event.data)) + .map((event) => event.data.message) + .join(''); + + assert.ok(!noLocRaw.includes('attributed-out-noloc-5ad9\n')); + + assertUnattributedOutput( + events, + 'test:stdout', + 'toplevel-console-7b31\n', + ); + assertUnattributedOutput( + events, + 'test:stdout', + 'suitedef-console-91de\n', + ); + assertUnattributedOutput( + events, + 'test:stdout', + 'globalhook-console-52ac\n', + ); + assertUnattributedOutput( + events, + 'test:stdout', + 'late-console-f08b\n', + ); + assertUnattributedOutput( + events, + 'test:stdout', + 'directstream-out-first-d40c\n', + ); + assertUnattributedOutput( + events, + 'test:stderr', + 'directstream-err-first-e93a\n', + ); + assertUnattributedOutput( + events, + 'test:stdout', + 'customconsole-out-3e75\n', + ); +}); + +test('console output is not attributed without process isolation', async () => { + // A stray NODE_TEST_CONTEXT value must not turn on attribution, otherwise the + // documented "process isolation only" behavior would not hold. + const { stdout } = await common.spawnPromisified(process.execPath, [ + '--no-warnings', + fixtures.path('test-runner', 'console-output-attribution-no-isolation.mjs'), + ], { env: { ...process.env, NODE_TEST_CONTEXT: 'not-a-child' } }); + + assert.match(stdout, /__attributed_count__:0\n/); + // The writes still have to reach stdout. Suppressing them would trade a + // wrong attribution for lost output. + assert.match(stdout, /attributed-out-first-a17e/); +}); + +test('assigning NODE_TEST_CONTEXT mid-run does not enable attribution', async () => { + // Attribution is decided once during setup. If it were re-read per write, + // test code could switch it on in a process that has no reporter able to + // carry the attributed event, and the write would be dropped. + const { stdout } = await common.spawnPromisified(process.execPath, [ + '--no-warnings', + fixtures.path('test-runner', 'console-output-attribution-env-mutation.mjs'), + ]); + + assert.match(stdout, /__attributed_count__:0\n/); + assert.match(stdout, /env-mutated-out-2d9c/); +}); + +test('the hook does not mutate the global console', async () => { + // The hook is kept in module private storage, so the console object must not + // gain a new symbol and freezing it must not break the test runner. + assert.ok(!Object.getOwnPropertySymbols(console).some((symbol) => + String(symbol).includes('WriteToConsoleHook'))); + + const { code, stderr } = await common.spawnPromisified(process.execPath, [ + '--no-warnings', + '--require', + fixtures.path('test-runner', 'freeze-console.js'), + '--test', + fixtures.path('test-runner', 'console-output-attribution-frozen-console.mjs'), + ]); + + assert.strictEqual(code, 0); + assert.doesNotMatch(stderr, /not extensible/); +}); From d5a88d01275336f64c36fa0c99f83643726c076e Mon Sep 17 00:00:00 2001 From: hanityx Date: Sun, 9 Aug 2026 06:30:23 +0900 Subject: [PATCH 2/2] doc: add TestsStream history entry for console attribution Signed-off-by: hanityx --- doc/api/test.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/doc/api/test.md b/doc/api/test.md index ffe70f936982..e1fd102b276a 100644 --- a/doc/api/test.md +++ b/doc/api/test.md @@ -3456,6 +3456,10 @@ added: - v18.9.0 - v16.19.0 changes: + - version: REPLACEME + pr-url: https://github.com/nodejs/node/pull/65147 + description: Added test and suite attribution to `test:stdout` and + `test:stderr` events for built-in global `console` output. - version: v26.6.0 pr-url: https://github.com/nodejs/node/pull/64309 description: Added `entryFile` to events forwarded from child processes