Repository: nodejs/node
Pull request: test_runner: print formatted errors on summary

Following this discussion: https://github.com/nodejs/node/pull/56662#discussion_r1939602370

This PR should address #56902

<!--
Before submitting a pull request, please read:

- the CONTRIBUTING guide at https://github.com/nodejs/node/blob/HEAD/CONTRIBUTING.md
- the commit message formatting guidelines at
  https://github.com/nodejs/node/blob/HEAD/doc/contributing/pull-requests.md#commit-message-guidelines

For code changes:
1. Include tests for any bug fixes or new features.
2. Update documentation if relevant.
3. Ensure that `make -j4 test` (UNIX), or `vcbuild test` (Windows) passes.

If you believe this PR should be highlighted in the Node.js CHANGELOG
please add the `notable-change` label.

Developer's Certificate of Origin 1.1

By making a contribution to this project, I certify that:

(a) The contribution was created in whole or in part by me and I
    have the right to submit it under the open source license
    indicated in the file; or

(b) The contribution is based upon previous work that, to the best
    of my knowledge, is covered under an appropriate open source
    license and I have the right under that license to submit that
    work with modifications, whether created in whole or in part
    by me, under the same open source license (unless I am
    permitted to submit under a different license), as indicated
    in the file; or

(c) The contribution was provided directly to me by some other
    person who certified (a), (b) or (c) and I have not modified
    it.

(d) I understand and agree that this project and the contribution
    are public and that a record of the contribution (including all
    personal information I submit with it, including my sign-off) is
    maintained indefinitely and may be redistributed consistent with
    this project or the open source license(s) involved.
-->


Changed files:
filename: lib/internal/test_runner/reporter/spec.js
patch: @@ -30,6 +30,31 @@ class SpecReporter extends Transform {
     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) {
@@ -74,31 +99,18 @@ class SpecReporter extends Transform {
       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();
+        }
     }
   }
   _transform({ type, data }, encoding, callback) {
     callback(null, this.#handleEvent({ __proto__: null, type, data }));
   }
   _flush(callback) {
-    if (this.#failedTests.length === 0) {
-      callback(null, '');
-      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);
-    }
-    callback(null, ArrayPrototypeJoin(results, '\n'));
+    callback(null, this.#formatFailedTestResults());
   }
 }
 

filename: test/fixtures/test-runner/output/test-runner-watch-spec.mjs
patch: @@ -0,0 +1,46 @@
+import { run } from 'node:test';
+import { spec } from 'node:test/reporters';
+import tmpdir from '../../../common/tmpdir.js';
+import { writeFileSync } from 'node:fs';
+
+
+const fixtureContent = {
+  'dependency.js': 'module.exports = {};',
+  'dependency.mjs': 'export const a = 1;',
+  'test.js': `
+  const test = require('node:test');
+  require('./dependency.js');
+  import('./dependency.mjs');
+  import('data:text/javascript,');
+  test('test has ran');`,
+  'failing-test.js': `
+  const test = require('node:test');
+  test('failing test', () => {
+    throw new Error('failed');
+  });`,
+};
+
+tmpdir.refresh();
+
+const fixturePaths = Object.keys(fixtureContent)
+  .reduce((acc, file) => ({ ...acc, [file]: tmpdir.resolve(file) }), {});
+Object.entries(fixtureContent)
+  .forEach(([file, content]) => writeFileSync(fixturePaths[file], content));
+
+const controller = new AbortController();
+const { signal } = controller;
+
+const stream = run({
+  watch: true,
+  cwd: tmpdir.path,
+  signal,
+});
+
+
+stream.compose(spec).pipe(process.stdout);
+
+for await (const event of stream) {
+  if (event.type === 'test:watch:drained') {
+    controller.abort();
+  }
+}

filename: test/fixtures/test-runner/output/test-runner-watch-spec.snapshot
patch: @@ -0,0 +1,30 @@
+✖ failing test (*ms)
+✔ test has ran (*ms)
+ℹ tests 2
+ℹ suites 0
+ℹ pass 1
+ℹ fail 1
+ℹ cancelled 0
+ℹ skipped 0
+ℹ todo 0
+ℹ duration_ms *
+
+✖ failing tests:
+
+*
+✖ failing test (*ms)
+  Error: failed
+      *
+      *
+      *
+      *
+      *
+      *
+ℹ tests 0
+ℹ suites 0
+ℹ pass 0
+ℹ fail 0
+ℹ cancelled 0
+ℹ skipped 0
+ℹ todo 0
+ℹ duration_ms *

filename: test/parallel/test-runner-output.mjs
patch: @@ -235,6 +235,10 @@ const tests = [
     name: 'test-runner/output/test-runner-plan.js',
     flags: ['--test-reporter=tap'],
   },
+  {
+    name: 'test-runner/output/test-runner-watch-spec.mjs',
+    transform: specTransform,
+  },
   process.features.inspector ? {
     name: 'test-runner/output/coverage_failure.js',
     flags: ['--test-reporter=tap', '--test-coverage-exclude=!test/**'],