Repository: nodejs/node
Pull request: test_runner: emit test:watch:restarted event on watched file changes

The goal of this PR is to add a `test:watch:restarted` event to signal to the reporter that some tests have restarted while in watch mode.

This will enable a trivial fix for [nodejs/node#57206](https://github.com/nodejs/node/issues/57206), which I plan to address later.

Changed files:
filename: doc/api/test.md
patch: @@ -1121,6 +1121,9 @@ const customReporter = new Transform({
       case 'test:watch:drained':
         callback(null, 'test watch queue drained');
         break;
+      case 'test:watch:restarted':
+        callback(null, 'test watch restarted due to file change');
+        break;
       case 'test:start':
         callback(null, `test ${event.data.name} started`);
         break;
@@ -1166,6 +1169,9 @@ const customReporter = new Transform({
       case 'test:watch:drained':
         callback(null, 'test watch queue drained');
         break;
+      case 'test:watch:restarted':
+        callback(null, 'test watch restarted due to file change');
+        break;
       case 'test:start':
         callback(null, `test ${event.data.name} started`);
         break;
@@ -1210,6 +1216,9 @@ export default async function * customReporter(source) {
       case 'test:watch:drained':
         yield 'test watch queue drained\n';
         break;
+      case 'test:watch:restarted':
+        yield 'test watch restarted due to file change\n';
+        break;
       case 'test:start':
         yield `test ${event.data.name} started\n`;
         break;
@@ -1250,6 +1259,9 @@ module.exports = async function * customReporter(source) {
       case 'test:watch:drained':
         yield 'test watch queue drained\n';
         break;
+      case 'test:watch:restarted':
+        yield 'test watch restarted due to file change\n';
+        break;
       case 'test:start':
         yield `test ${event.data.name} started\n`;
         break;
@@ -3175,6 +3187,10 @@ generated for each test file in addition to a final cumulative summary.
 
 Emitted when no more tests are queued for execution in watch mode.
 
+### Event: `'test:watch:restarted'`
+
+Emitted when one or more tests are restarted due to a file change in watch mode.
+
 ## Class: `TestContext`
 
 <!-- YAML

filename: lib/internal/test_runner/runner.js
patch: @@ -481,6 +481,7 @@ function watchFiles(testFiles, opts) {
       // Reset the topLevel counter
       opts.root.harness.counters.topLevel = 0;
     }
+
     await runningSubtests.get(file);
     runningSubtests.set(file, runTestFile(file, filesWatcher, opts));
   }
@@ -508,6 +509,8 @@ function watchFiles(testFiles, opts) {
     // Reset the root start time to recalculate the duration
     // of the run
     opts.root.clearExecutionTime();
+    opts.root.reporter[kEmitMessage]('test:watch:restarted');
+
     // Restart test files
     if (opts.isolation === 'none') {
       PromisePrototypeThen(restartTestFile(kIsolatedProcessName), undefined, (error) => {

filename: test/parallel/test-runner-run-watch.mjs
patch: @@ -257,6 +257,56 @@ describe('test runner watch mode', () => {
     assert.notDeepStrictEqual(durations[0][1], durations[1][1]);
   });
 
+  it('should emit test:watch:restarted when file is updated', async () => {
+    let alreadyDrained = false;
+    const events = [];
+    const testWatchRestarted = common.mustCall(1);
+
+    const controller = new AbortController();
+    const stream = run({
+      cwd: tmpdir.path,
+      watch: true,
+      signal: controller.signal,
+    }).on('data', function({ type }) {
+      events.push(type);
+      if (type === 'test:watch:restarted') {
+        testWatchRestarted();
+      }
+      if (type === 'test:watch:drained') {
+        if (alreadyDrained) {
+          controller.abort();
+        }
+        alreadyDrained = true;
+      }
+    });
+
+    await once(stream, 'test:watch:drained');
+
+    writeFileSync(join(tmpdir.path, 'test.js'), fixtureContent['test.js']);
+
+    // eslint-disable-next-line no-unused-vars
+    for await (const _ of stream);
+
+    assert.partialDeepStrictEqual(events, [
+      'test:watch:drained',
+      'test:watch:restarted',
+      'test:watch:drained',
+    ]);
+  });
+
+  it('should not emit test:watch:restarted since watch mode is disabled', async () => {
+    const stream = run({
+      cwd: tmpdir.path,
+      watch: false,
+    });
+
+    stream.on('test:watch:restarted', common.mustNotCall());
+    writeFileSync(join(tmpdir.path, 'test.js'), fixtureContent['test.js']);
+
+    // eslint-disable-next-line no-unused-vars
+    for await (const _ of stream);
+  });
+
   describe('test runner watch mode with different cwd', () => {
     it(
       'should execute run using a different cwd for the runner than the process cwd',