current commit
261006.7 · watch: strip underscore aliases of watch flags
import * as common from '../common/index.mjs';
import tmpdir from '../common/tmpdir.js';
import assert from 'node:assert';
import os from 'node:os';
import path from 'node:path';
import { execPath } from 'node:process';
import { describe, it } from 'node:test';
import { spawn } from 'node:child_process';
import { writeFileSync, readFileSync, mkdirSync } from 'node:fs';
import { inspect } from 'node:util';
import { pathToFileURL } from 'node:url';
import { once } from 'node:events';
import { createInterface } from 'node:readline';

if (common.isIBMi)
  common.skip('IBMi does not support `fs.watch()`');

const supportsRecursive = common.isMacOS || common.isWindows;

function restart(file, content = readFileSync(file)) {
  // To avoid flakiness, we save the file repeatedly until test is done
  writeFileSync(file, content);
  const timer = setInterval(() => writeFileSync(file, content), common.platformTimeout(2500));
  return () => clearInterval(timer);
}

function changeDetected(file) {
  return `Change detected in ${inspect(file)}`;
}

let tmpFiles = 0;
function createTmpFile(content = 'console.log("running");', ext = '.js', basename = tmpdir.path) {
  const file = path.join(basename, `${tmpFiles++}${ext}`);
  writeFileSync(file, content);
  return file;
}

function runInBackground({ args = [], options = {}, completed = 'Completed running', shouldFail = false }) {
  let future = Promise.withResolvers();
  let child;
  let stderr = '';
  let stdout = [];

  const run = () => {
    args.unshift('--no-warnings');
    child = spawn(execPath, args, { encoding: 'utf8', stdio: 'pipe', ...options });

    child.stderr.on('data', (data) => {
      stderr += data;
    });

    const rl = createInterface({ input: child.stdout });
    rl.on('line', (data) => {
      if (!data.startsWith('Waiting for graceful termination') && !data.startsWith('Gracefully restarted')) {
        stdout.push(data);
        if (data.startsWith(completed)) {
          future.resolve({ stderr, stdout });
          future = Promise.withResolvers();
          stdout = [];
          stderr = '';
        } else if (data.startsWith('Failed running')) {
          const settle = () => {
            if (shouldFail) {
              future.resolve({ stderr, stdout });
            } else {
              future.reject({ stderr, stdout });
            }
            future = Promise.withResolvers();
            stdout = [];
            stderr = '';
          };
          // If stderr is empty, wait for it to receive data before settling.
          // This handles the race condition where stdout arrives before stderr.
          if (stderr === '') {
            child.stderr.once('data', settle);
          } else {
            settle();
          }
        }
      }
    });
  };

  return {
    async done() {
      child?.kill();
      future.resolve();
      return { stdout, stderr };
    },
    restart(timeout = common.platformTimeout(10_000)) {
      if (!child) {
        run();
      }
      const timer = setTimeout(() => {
        if (!future.resolved) {
          child.kill();
          future.reject(new Error('Timed out waiting for restart'));
        }
      }, timeout);
      return future.promise.finally(() => {
        clearTimeout(timer);
      });
    },
  };
}

async function runWriteSucceed({
  file,
  watchedFile,
  watchFlag = '--watch',
  args = [file],
  completed = 'Completed running',
  restarts = 2,
  options = {},
  shouldFail = false,
}) {
  args.unshift('--no-warnings');
  if (watchFlag !== null) args.unshift(watchFlag);
  const child = spawn(execPath, args, { encoding: 'utf8', stdio: 'pipe', ...options });
  let completes = 0;
  let cancelRestarts = () => {};
  let stderr = '';
  const stdout = [];

  child.stderr.on('data', (data) => {
    stderr += data;
  });

  try {
    // Break the chunks into lines
    for await (const data of createInterface({ input: child.stdout })) {
      if (!data.startsWith('Waiting for graceful termination') && !data.startsWith('Gracefully restarted')) {
        stdout.push(data);
      }
      if (data.startsWith(completed)) {
        completes++;
        if (completes === restarts) {
          break;
        }
        if (completes === 1) {
          cancelRestarts = restart(watchedFile);
        }
      }

      if (!shouldFail && data.startsWith('Failed running')) {
        break;
      }
    }
  } finally {
    child.kill();
    cancelRestarts();
  }
  return { stdout, stderr, pid: child.pid };
}

async function failWriteSucceed({ file, watchedFile }) {
  const child = spawn(execPath, ['--watch', '--no-warnings', file], { encoding: 'utf8', stdio: 'pipe' });
  let cancelRestarts = () => {};

  try {
    // Break the chunks into lines
    for await (const data of createInterface({ input: child.stdout })) {
      if (data.startsWith('Completed running')) {
        break;
      }
      if (data.startsWith('Failed running')) {
        cancelRestarts = restart(watchedFile, 'console.log("test has ran");');
      }
    }
  } finally {
    child.kill();
    cancelRestarts();
  }
}

tmpdir.refresh();

describe('watch mode', { concurrency: !process.env.TEST_PARALLEL, timeout: 60_000 }, () => {
  it('should exit when terminated after the watched process has completed', async () => {
    const file = createTmpFile();
    const child = spawn(execPath, ['--watch', '--no-warnings', file], {
      encoding: 'utf8',
      stdio: 'pipe',
    });

    for await (const line of createInterface({ input: child.stdout })) {
      if (line.includes('Completed running')) {
        break;
      }
    }

    child.kill();
    await once(child, 'exit');
  });

  it('should watch changes to a file', async () => {
    const file = createTmpFile();
    const { stderr, stdout } = await runWriteSucceed({ file, watchedFile: file, watchFlag: '--watch=true', options: {
      timeout: 10000,
    } });

    assert.strictEqual(stderr, '');
    assert.deepStrictEqual(stdout, [
      'running',
      `Completed running ${inspect(file)}. Waiting for file changes before restarting...`,
      changeDetected(file),
      `Restarting ${inspect(file)}`,
      'running',
      `Completed running ${inspect(file)}. Waiting for file changes before restarting...`,
    ]);
  });

  it('should watch changes to a file - event loop ended', async () => {
    const file = createTmpFile();
    const { stderr, stdout } = await runWriteSucceed({ file, watchedFile: file });

    assert.strictEqual(stderr, '');
    assert.deepStrictEqual(stdout, [
      'running',
      `Completed running ${inspect(file)}. Waiting for file changes before restarting...`,
      changeDetected(file),
      `Restarting ${inspect(file)}`,
      'running',
      `Completed running ${inspect(file)}. Waiting for file changes before restarting...`,
    ]);
  });

  it('should reload env variables when --env-file changes', async () => {
    const envKey = `TEST_ENV_${Date.now()}`;
    const jsFile = createTmpFile(`console.log('ENV: ' + process.env.${envKey});`);
    const envFile = createTmpFile(`${envKey}=value1`, '.env');
    const { done, restart } = runInBackground({ args: ['--watch', `--env-file=${envFile}`, jsFile] });

    try {
      await restart();
      writeFileSync(envFile, `${envKey}=value2`);

      // Second restart, after env change
      const { stdout, stderr } = await restart();

      assert.strictEqual(stderr, '');
      assert.deepStrictEqual(stdout, [
        changeDetected(envFile),
        `Restarting ${inspect(jsFile)}`,
        'ENV: value2',
        `Completed running ${inspect(jsFile)}. Waiting for file changes before restarting...`,
      ]);
    } finally {
      await done();
    }
  });

  it('should load new env variables when --env-file changes', async () => {
    const envKey = `TEST_ENV_${Date.now()}`;
    const envKey2 = `TEST_ENV_2_${Date.now()}`;
    const jsFile = createTmpFile(`console.log('ENV: ' + process.env.${envKey} + '\\n' + 'ENV2: ' + process.env.${envKey2});`);
    const envFile = createTmpFile(`${envKey}=value1`, '.env');
    const { done, restart } = runInBackground({ args: ['--watch', `--env-file=${envFile}`, jsFile] });

    try {
      await restart();
      writeFileSync(envFile, `${envKey}=value1\n${envKey2}=newValue`);

      // Second restart, after env change
      const { stderr, stdout } = await restart();

      assert.strictEqual(stderr, '');
      assert.deepStrictEqual(stdout, [
        changeDetected(envFile),
        `Restarting ${inspect(jsFile)}`,
        'ENV: value1',
        'ENV2: newValue',
        `Completed running ${inspect(jsFile)}. Waiting for file changes before restarting...`,
      ]);
    } finally {
      await done();
    }
  });

  it('should load new env variables when --env-file-if-exists changes', async () => {
    const envKey = `TEST_ENV_${Date.now()}`;
    const envKey2 = `TEST_ENV_2_${Date.now()}`;
    const jsFile = createTmpFile(`console.log('ENV: ' + process.env.${envKey} + '\\n' + 'ENV2: ' + process.env.${envKey2});`);
    const envFile = createTmpFile(`${envKey}=value1`, '.env');
    const { done, restart } = runInBackground({ args: ['--watch', `--env-file-if-exists=${envFile}`, jsFile] });

    try {
      await restart();
      writeFileSync(envFile, `${envKey}=value1\n${envKey2}=newValue`);

      // Second restart, after env change
      const { stderr, stdout } = await restart();

      assert.strictEqual(stderr, '');
      assert.deepStrictEqual(stdout, [
        changeDetected(envFile),
        `Restarting ${inspect(jsFile)}`,
        'ENV: value1',
        'ENV2: newValue',
        `Completed running ${inspect(jsFile)}. Waiting for file changes before restarting...`,
      ]);
    } finally {
      await done();
    }
  });

  it('should not crash when --env-file-if-exists points to a missing file', async () => {
    const envKey = `TEST_ENV_${Date.now()}`;
    const jsFile = createTmpFile(`console.log('ENV: ' + process.env.${envKey});`);
    const missingEnvFile = path.join(tmpdir.path, `missing-${Date.now()}.env`);
    const { done, restart } = runInBackground({
      args: ['--watch-path', tmpdir.path, `--env-file-if-exists=${missingEnvFile}`, jsFile],
    });

    try {
      const { stderr, stdout } = await restart();

      assert.doesNotMatch(stderr, /ENOENT: no such file or directory, watch/);
      assert.deepStrictEqual(stdout, [
        'ENV: undefined',
        `Completed running ${inspect(jsFile)}. Waiting for file changes before restarting...`,
      ]);
    } finally {
      await done();
    }
  });

  it('should watch changes to a failing file', async () => {
    const file = createTmpFile('throw new Error("fails");');
    const { stderr, stdout } = await runWriteSucceed({
      file,
      watchedFile: file,
      completed: 'Failed running',
      shouldFail: true,
    });

    assert.match(stderr, /Error: fails\r?\n/);
    assert.deepStrictEqual(stdout, [
      `Failed running ${inspect(file)}. Waiting for file changes before restarting...`,
      changeDetected(file),
      `Restarting ${inspect(file)}`,
      `Failed running ${inspect(file)}. Waiting for file changes before restarting...`,
    ]);
  });

  it('should watch changes to a file with watch-path', {
    skip: !supportsRecursive,
  }, async () => {
    const dir = tmpdir.resolve('subdir1');
    mkdirSync(dir);
    const file = createTmpFile();
    const watchedFile = createTmpFile('', '.js', dir);
    const args = ['--watch-path', dir, file];
    const { stderr, stdout } = await runWriteSucceed({ file, watchedFile, args });

    assert.strictEqual(stderr, '');
    assert.deepStrictEqual(stdout, [
      'running',
      `Completed running ${inspect(file)}. Waiting for file changes before restarting...`,
      changeDetected(watchedFile),
      `Restarting ${inspect(file)}`,
      'running',
      `Completed running ${inspect(file)}. Waiting for file changes before restarting...`,
    ]);
    assert.strictEqual(stderr, '');
  });

  it('should watch when running an non-existing file - when specified under --watch-path', {
    skip: !supportsRecursive,
  }, async () => {
    const dir = tmpdir.resolve('subdir2');
    mkdirSync(dir);
    const file = path.join(dir, 'non-existing.js');
    const watchedFile = createTmpFile('', '.js', dir);
    const args = ['--watch-path', dir, file];
    const { stderr, stdout } = await runWriteSucceed({
      file,
      watchedFile,
      args,
      completed: 'Failed running',
      shouldFail: true,
    });

    assert.match(stderr, /Error: Cannot find module/g);
    assert.deepStrictEqual(stdout, [
      `Failed running ${inspect(file)}. Waiting for file changes before restarting...`,
      changeDetected(watchedFile),
      `Restarting ${inspect(file)}`,
      `Failed running ${inspect(file)}. Waiting for file changes before restarting...`,
    ]);
  });

  it('should watch when running an non-existing file - when specified under --watch-path with equals', {
    skip: !supportsRecursive,
  }, async () => {
    const dir = tmpdir.resolve('subdir3');
    mkdirSync(dir);
    const file = path.join(dir, 'non-existing.js');
    const watchedFile = createTmpFile('', '.js', dir);
    const args = [`--watch-path=${dir}`, file];
    const { stderr, stdout } = await runWriteSucceed({
      file,
      watchedFile,
      args,
      completed: 'Failed running',
      shouldFail: true,
    });

    assert.match(stderr, /Error: Cannot find module/g);
    assert.deepStrictEqual(stdout, [
      `Failed running ${inspect(file)}. Waiting for file changes before restarting...`,
      changeDetected(watchedFile),
      `Restarting ${inspect(file)}`,
      `Failed running ${inspect(file)}. Waiting for file changes before restarting...`,
    ]);
  });

  it('should watch changes to a file - event loop blocked', { timeout: 10_000 }, async () => {
    const file = createTmpFile(`
console.log("running");
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0);
console.log("don't show me");`);
    const { stderr, stdout } = await runWriteSucceed({ file, watchedFile: file, completed: 'running' });

    assert.strictEqual(stderr, '');
    assert.deepStrictEqual(stdout, [
      'running',
      changeDetected(file),
      `Restarting ${inspect(file)}`,
      'running',
    ]);
  });

  it('should watch changes to dependencies - cjs', async () => {
    const dependency = createTmpFile('module.exports = {};');
    const file = createTmpFile(`
const dependency = require(${JSON.stringify(dependency)});
console.log(dependency);
`);
    const { stderr, stdout } = await runWriteSucceed({ file, watchedFile: dependency });

    assert.strictEqual(stderr, '');
    assert.deepStrictEqual(stdout, [
      '{}',
      `Completed running ${inspect(file)}. Waiting for file changes before restarting...`,
      changeDetected(dependency),
      `Restarting ${inspect(file)}`,
      '{}',
      `Completed running ${inspect(file)}. Waiting for file changes before restarting...`,
    ]);
  });

  it('should watch changes to dependencies - esm', async () => {
    const dependency = createTmpFile('module.exports = {};');
    const file = createTmpFile(`
import dependency from ${JSON.stringify(pathToFileURL(dependency))};
console.log(dependency);
`, '.mjs');
    const { stderr, stdout } = await runWriteSucceed({ file, watchedFile: dependency });

    assert.strictEqual(stderr, '');
    assert.deepStrictEqual(stdout, [
      '{}',
      `Completed running ${inspect(file)}. Waiting for file changes before restarting...`,
      changeDetected(dependency),
      `Restarting ${inspect(file)}`,
      '{}',
      `Completed running ${inspect(file)}. Waiting for file changes before restarting...`,
    ]);
  });

  it('should restart multiple times', async () => {
    const file = createTmpFile();
    const { stderr, stdout } = await runWriteSucceed({ file, watchedFile: file, restarts: 3 });

    assert.strictEqual(stderr, '');
    assert.deepStrictEqual(stdout, [
      'running',
      `Completed running ${inspect(file)}. Waiting for file changes before restarting...`,
      changeDetected(file),
      `Restarting ${inspect(file)}`,
      'running',
      `Completed running ${inspect(file)}. Waiting for file changes before restarting...`,
      changeDetected(file),
      `Restarting ${inspect(file)}`,
      'running',
      `Completed running ${inspect(file)}. Waiting for file changes before restarting...`,
    ]);
  });

  it('should pass arguments to file', async () => {
    const file = createTmpFile(`
const { parseArgs } = require('node:util');
const { values } = parseArgs({ options: { random: { type: 'string' } } });
console.log(values.random);
    `);
    const random = Date.now().toString();
    const args = [file, '--random', random];
    const { stderr, stdout } = await runWriteSucceed({ file, watchedFile: file, args });

    assert.strictEqual(stderr, '');
    assert.deepStrictEqual(stdout, [
      random,
      `Completed running ${inspect(`${file} --random ${random}`)}. Waiting for file changes before restarting...`,
      changeDetected(file),
      `Restarting ${inspect(`${file} --random ${random}`)}`,
      random,
      `Completed running ${inspect(`${file} --random ${random}`)}. Waiting for file changes before restarting...`,
    ]);
  });

  it('should load --require modules in the watched process, and not in the orchestrator process', async () => {
    const file = createTmpFile();
    const required = createTmpFile('process._rawDebug(\'pid\', process.pid);');
    const args = ['--require', required, file];
    const { stdout, pid, stderr } = await runWriteSucceed({ file, watchedFile: file, args });

    const importPid = parseInt(stderr[0].split(' ')[1], 10);
    assert.notStrictEqual(pid, importPid);
    assert.deepStrictEqual(stdout, [
      'running',
      `Completed running ${inspect(file)}. Waiting for file changes before restarting...`,
      changeDetected(file),
      `Restarting ${inspect(file)}`,
      'running',
      `Completed running ${inspect(file)}. Waiting for file changes before restarting...`,
    ]);
  });

  it('should load --import modules in the watched process, and not in the orchestrator process', async () => {
    const file = createTmpFile();
    const imported = "data:text/javascript,process._rawDebug('pid', process.pid);";
    const args = ['--import', imported, file];
    const { stdout, pid, stderr } = await runWriteSucceed({ file, watchedFile: file, args });

    const importPid = parseInt(stderr.split('\n', 1)[0].split(' ', 2)[1], 10);

    assert.notStrictEqual(importPid, NaN);
    assert.notStrictEqual(pid, importPid);

    assert.deepStrictEqual(stdout, [
      'running',
      `Completed running ${inspect(file)}. Waiting for file changes before restarting...`,
      changeDetected(file),
      `Restarting ${inspect(file)}`,
      'running',
      `Completed running ${inspect(file)}. Waiting for file changes before restarting...`,
    ]);
  });

  // TODO: Remove skip after https://github.com/nodejs/node/pull/45271 lands
  it('should not watch when running an missing file', {
    skip: !supportsRecursive,
  }, async () => {
    const nonExistingfile = tmpdir.resolve(`${tmpFiles++}.js`);
    await failWriteSucceed({ file: nonExistingfile, watchedFile: nonExistingfile });
  });

  it('should not watch when running an missing mjs file', {
    skip: !supportsRecursive,
  }, async () => {
    const nonExistingfile = tmpdir.resolve(`${tmpFiles++}.mjs`);
    await failWriteSucceed({ file: nonExistingfile, watchedFile: nonExistingfile });
  });

  it('should watch changes to previously missing dependency', {
    skip: !supportsRecursive,
  }, async () => {
    const dependency = tmpdir.resolve(`${tmpFiles++}.js`);
    const relativeDependencyPath = `./${path.basename(dependency)}`;
    const dependant = createTmpFile(`console.log(require('${relativeDependencyPath}'))`);

    await failWriteSucceed({ file: dependant, watchedFile: dependency });
  });

  it('should watch changes to previously missing ESM dependency', {
    skip: !supportsRecursive,
  }, async () => {
    const relativeDependencyPath = `./${tmpFiles++}.mjs`;
    const dependency = tmpdir.resolve(relativeDependencyPath);
    const dependant = createTmpFile(`import ${JSON.stringify(relativeDependencyPath)}`, '.mjs');

    await failWriteSucceed({ file: dependant, watchedFile: dependency });
  });

  it('should clear output between runs', async () => {
    const file = createTmpFile();
    const { stderr, stdout } = await runWriteSucceed({ file, watchedFile: file });

    assert.strictEqual(stderr, '');
    assert.deepStrictEqual(stdout, [
      'running',
      `Completed running ${inspect(file)}. Waiting for file changes before restarting...`,
      changeDetected(file),
      `Restarting ${inspect(file)}`,
      'running',
      `Completed running ${inspect(file)}. Waiting for file changes before restarting...`,
    ]);
  });

  it('should preserve output when --watch-preserve-output flag is passed', async () => {
    const file = createTmpFile();
    const args = ['--watch-preserve-output', file];
    const { stderr, stdout } = await runWriteSucceed({ file, watchedFile: file, args });

    assert.strictEqual(stderr, '');
    assert.deepStrictEqual(stdout, [
      'running',
      `Completed running ${inspect(file)}. Waiting for file changes before restarting...`,
      changeDetected(file),
      `Restarting ${inspect(file)}`,
      'running',
      `Completed running ${inspect(file)}. Waiting for file changes before restarting...`,
    ]);
  });

  it('should run when `--watch-path=./foo --require ./bar.js`', {
    skip: !supportsRecursive,
  }, async () => {
    const projectDir = tmpdir.resolve('project2');
    mkdirSync(projectDir);

    const dir = path.join(projectDir, 'watched-dir');
    mkdirSync(dir);

    writeFileSync(path.join(projectDir, 'some.js'), 'console.log(\'hello\')');

    const file = createTmpFile('console.log(\'running\');', '.js', projectDir);
    const watchedFile = createTmpFile('', '.js', dir);
    const args = [`--watch-path=${dir}`, '--require', './some.js', file];
    const { stdout, stderr } = await runWriteSucceed({
      file, watchedFile, args, options: {
        cwd: projectDir,
      },
    });

    assert.strictEqual(stderr, '');
    assert.deepStrictEqual(stdout, [
      'hello',
      'running',
      `Completed running ${inspect(file)}. Waiting for file changes before restarting...`,
      changeDetected(watchedFile),
      `Restarting ${inspect(file)}`,
      'hello',
      'running',
      `Completed running ${inspect(file)}. Waiting for file changes before restarting...`,
    ]);
  });

  it('should run when `--watch-path=./foo --require=./bar.js`', {
    skip: !supportsRecursive,
  }, async () => {
    const projectDir = tmpdir.resolve('project3');
    mkdirSync(projectDir);

    const dir = path.join(projectDir, 'watched-dir');
    mkdirSync(dir);

    writeFileSync(path.join(projectDir, 'some.js'), "console.log('hello')");

    const file = createTmpFile("console.log('running');", '.js', projectDir);
    const watchedFile = createTmpFile('', '.js', dir);
    const args = [`--watch-path=${dir}`, '--require=./some.js', file];
    const { stdout, stderr } = await runWriteSucceed({
      file, watchedFile, args, options: {
        cwd: projectDir,
      },
    });

    assert.strictEqual(stderr, '');
    assert.deepStrictEqual(stdout, [
      'hello',
      'running',
      `Completed running ${inspect(file)}. Waiting for file changes before restarting...`,
      changeDetected(watchedFile),
      `Restarting ${inspect(file)}`,
      'hello',
      'running',
      `Completed running ${inspect(file)}. Waiting for file changes before restarting...`,
    ]);
  });

  it('should run when `--watch-path ./foo --require ./bar.js`', {
    skip: !supportsRecursive,
  }, async () => {
    const projectDir = tmpdir.resolve('project5');
    mkdirSync(projectDir);

    const dir = path.join(projectDir, 'watched-dir');
    mkdirSync(dir);

    writeFileSync(path.join(projectDir, 'some.js'), 'console.log(\'hello\')');

    const file = createTmpFile('console.log(\'running\');', '.js', projectDir);
    const watchedFile = createTmpFile('', '.js', dir);
    const args = ['--watch-path', `${dir}`, '--require', './some.js', file];
    const { stdout, stderr } = await runWriteSucceed({
      file, watchedFile, args, options: {
        cwd: projectDir,
      },
    });

    assert.strictEqual(stderr, '');
    assert.deepStrictEqual(stdout, [
      'hello',
      'running',
      `Completed running ${inspect(file)}. Waiting for file changes before restarting...`,
      changeDetected(watchedFile),
      `Restarting ${inspect(file)}`,
      'hello',
      'running',
      `Completed running ${inspect(file)}. Waiting for file changes before restarting...`,
    ]);
  });

  it('should run when `--watch-path=./foo --require=./bar.js`', {
    skip: !supportsRecursive,
  }, async () => {
    const projectDir = tmpdir.resolve('project6');
    mkdirSync(projectDir);

    const dir = path.join(projectDir, 'watched-dir');
    mkdirSync(dir);

    writeFileSync(path.join(projectDir, 'some.js'), "console.log('hello')");

    const file = createTmpFile("console.log('running');", '.js', projectDir);
    const watchedFile = createTmpFile('', '.js', dir);
    const args = ['--watch-path', `${dir}`, '--require=./some.js', file];
    const { stdout, stderr } = await runWriteSucceed({
      file, watchedFile, args, options: {
        cwd: projectDir,
      },
    });

    assert.strictEqual(stderr, '');
    assert.deepStrictEqual(stdout, [
      'hello',
      'running',
      `Completed running ${inspect(file)}. Waiting for file changes before restarting...`,
      changeDetected(watchedFile),
      `Restarting ${inspect(file)}`,
      'hello',
      'running',
      `Completed running ${inspect(file)}. Waiting for file changes before restarting...`,
    ]);
  });

  it('should run when `--watch --inspect`', { skip: !process.features.inspector }, async () => {
    const file = createTmpFile();
    const args = ['--watch', '--inspect', file];
    const { stdout, stderr } = await runWriteSucceed({ file, watchedFile: file, watchFlag: null, args });

    assert.match(stderr, /listening on ws:\/\//);
    assert.deepStrictEqual(stdout, [
      'running',
      `Completed running ${inspect(file)}. Waiting for file changes before restarting...`,
      changeDetected(file),
      `Restarting ${inspect(file)}`,
      'running',
      `Completed running ${inspect(file)}. Waiting for file changes before restarting...`,
    ]);
  });

  it('should run when `--watch -r ./foo.js`', async () => {
    const projectDir = tmpdir.resolve('project7');
    mkdirSync(projectDir);

    const dir = path.join(projectDir, 'watched-dir');
    mkdirSync(dir);
    writeFileSync(path.join(projectDir, 'some.js'), "console.log('hello')");

    const file = createTmpFile("console.log('running');", '.js', projectDir);
    const args = ['--watch', '-r', './some.js', file];
    const { stdout, stderr } = await runWriteSucceed({
      file, watchedFile: file, watchFlag: null, args, options: { cwd: projectDir },
    });

    assert.strictEqual(stderr, '');
    assert.deepStrictEqual(stdout, [
      'hello',
      'running',
      `Completed running ${inspect(file)}. Waiting for file changes before restarting...`,
      changeDetected(file),
      `Restarting ${inspect(file)}`,
      'hello',
      'running',
      `Completed running ${inspect(file)}. Waiting for file changes before restarting...`,
    ]);
  });

  it('should pass IPC messages from a spawning parent to the child and back', async () => {
    const file = createTmpFile(`console.log('running');
process.on('message', (message) => {
  if (message === 'exit') {
    process.exit(0);
  } else {
    console.log('Received:', message);
    process.send(message);
  }
})`);

    const child = spawn(
      execPath,
      [
        '--watch',
        '--no-warnings',
        file,
      ],
      {
        encoding: 'utf8',
        stdio: ['pipe', 'pipe', 'pipe', 'ipc'],
      },
    );

    let stderr = '';
    let stdout = '';

    child.stdout.on('data', (data) => stdout += data);
    child.stderr.on('data', (data) => stderr += data);
    async function waitForEcho(msg) {
      const receivedPromise = new Promise((resolve) => {
        const fn = (message) => {
          if (message === msg) {
            child.off('message', fn);
            resolve();
          }
        };
        child.on('message', fn);
      });
      child.send(msg);
      await receivedPromise;
    }

    async function waitForText(text) {
      const seenPromise = new Promise((resolve) => {
        const fn = (data) => {
          if (data.toString().includes(text)) {
            resolve();
            child.stdout.off('data', fn);
          }
        };
        child.stdout.on('data', fn);
      });
      await seenPromise;
    }

    await waitForText('running');
    await waitForEcho('first message');
    const stopRestarts = restart(file);
    await waitForText('running');
    stopRestarts();
    await waitForEcho('second message');
    const exitedPromise = once(child, 'exit');
    child.send('exit');
    await waitForText('Completed');
    child.disconnect();
    child.kill();
    await exitedPromise;
    assert.strictEqual(stderr, '');
    const lines = stdout.split(/\r?\n/).filter(Boolean);
    assert.deepStrictEqual(lines, [
      'running',
      'Received: first message',
      changeDetected(file),
      `Restarting ${inspect(file)}`,
      'running',
      'Received: second message',
      `Completed running ${inspect(file)}. Waiting for file changes before restarting...`,
    ]);
  });

  it('should watch changes to a file from config file', async () => {
    const file = createTmpFile();
    const configFile = createTmpFile(JSON.stringify({ watch: { 'watch': true } }), '.json');
    const { stderr, stdout } = await runWriteSucceed({
      file, watchedFile: file, args: [`--config-file=${configFile}`, file], options: {
        timeout: 10000,
      },
    });

    assert.strictEqual(stderr, '');
    assert.deepStrictEqual(stdout, [
      'running',
      `Completed running ${inspect(file)}. Waiting for file changes before restarting...`,
      changeDetected(file),
      `Restarting ${inspect(file)}`,
      'running',
      `Completed running ${inspect(file)}. Waiting for file changes before restarting...`,
    ]);
  });

  it('should watch changes to a file with watch-path from config file', {
    skip: !supportsRecursive,
  }, async () => {
    const dir = tmpdir.resolve('subdir4');
    mkdirSync(dir);
    const file = createTmpFile();
    const watchedFile = createTmpFile('', '.js', dir);
    const configFile = createTmpFile(JSON.stringify({ watch: { 'watch-path': [dir] } }), '.json', dir);

    const args = [`--config-file=${configFile}`, file];
    const { stderr, stdout } = await runWriteSucceed({ file, watchedFile, args });

    assert.strictEqual(stderr, '');
    assert.deepStrictEqual(stdout, [
      'running',
      `Completed running ${inspect(file)}. Waiting for file changes before restarting...`,
      changeDetected(watchedFile),
      `Restarting ${inspect(file)}`,
      'running',
      `Completed running ${inspect(file)}. Waiting for file changes before restarting...`,
    ]);
    assert.strictEqual(stderr, '');
  });

  it('should watch changes to a file from default config file', async () => {
    const dir = tmpdir.resolve('subdir5');
    mkdirSync(dir);

    const file = createTmpFile('console.log("running");', '.js', dir);
    writeFileSync(path.join(dir, 'node.config.json'), JSON.stringify({ watch: { 'watch': true } }));

    const { stderr, stdout } = await runWriteSucceed({
      file,
      watchedFile: file,
      args: ['--config-file', file],
      options: {
        timeout: 10000,
        cwd: dir,
      },
    });

    assert.strictEqual(stderr, '');
    assert.deepStrictEqual(stdout, [
      'running',
      `Completed running ${inspect(file)}. Waiting for file changes before restarting...`,
      changeDetected(file),
      `Restarting ${inspect(file)}`,
      'running',
      `Completed running ${inspect(file)}. Waiting for file changes before restarting...`,
    ]);
  });

  it('should support multiple --env-file flags', async () => {
    const envKey = `TEST_ENV_A_${Date.now()}`;
    const envKey2 = `TEST_ENV_B_${Date.now()}`;
    const jsFile = createTmpFile(`console.log('ENV_A: ' + process.env.${envKey} + '\\n' + 'ENV_B: ' + process.env.${envKey2});`);
    const envFileA = createTmpFile(`${envKey}=123`, '.env');
    const envFileB = createTmpFile(`${envKey2}=456`, '.env');
    const { done, restart } = runInBackground({
      args: ['--watch', `--env-file=${envFileA}`, `--env-file=${envFileB}`, jsFile],
    });

    try {
      const { stderr, stdout } = await restart();

      assert.strictEqual(stderr, '');
      assert.deepStrictEqual(stdout, [
        'ENV_A: 123',
        'ENV_B: 456',
        `Completed running ${inspect(jsFile)}. Waiting for file changes before restarting...`,
      ]);
    } finally {
      await done();
    }
  });

  it('should respect the order for --env-file and --env-file-if-exists', async () => {
    const envKey = `TEST_ENV_${Date.now()}`;
    const jsFile = createTmpFile(`console.log('ENV: ' + process.env.${envKey});`);

    const envFile = createTmpFile(`${envKey}=base`, '.env');
    const envFileIfExists = createTmpFile(`${envKey}=override`, '.env');

    const { done, restart } = runInBackground({
      args: [
        '--watch',
        `--env-file=${envFile}`,
        `--env-file-if-exists=${envFileIfExists}`,
        jsFile,
      ],
    });

    try {
      const { stdout, stderr } = await restart();

      assert.strictEqual(stderr, '');
      assert.deepStrictEqual(stdout, [
        'ENV: override',
        `Completed running ${inspect(jsFile)}. Waiting for file changes before restarting...`,
      ]);
    } finally {
      await done();
    }
  });

  it('should strip all watch flags from NODE_OPTIONS in child process', async () => {
    // Avoid recursively watching the repository's source and test trees.
    const cwd = tmpdir.resolve('node-options');
    mkdirSync(path.join(cwd, 'src'), { recursive: true });
    mkdirSync(path.join(cwd, 'test'));
    const file = createTmpFile('console.log(process.env.NODE_OPTIONS);');
    const nodeOptions = [
      '--watch',
      '--watch=true',
      '--watch-path=./src',
      '--watch-path', './test',
      '--watch-preserve-output',
      '--watch-preserve-output=true',
      '--watch-kill-signal=SIGKILL',
      '--watch-kill-signal', 'SIGINT',
      '--max-old-space-size=4096',
      '--no-warnings',
    ].join(' ');
    const { done, restart } = runInBackground({
      args: ['--watch', file],
      options: {
        cwd,
        env: { ...process.env, NODE_OPTIONS: nodeOptions },
      },
    });

    try {
      const { stdout, stderr } = await restart();

      assert.strictEqual(stderr, '');
      const nodeOptionsLine = stdout.find((line) => line.includes('--max-old-space-size'));
      assert.ok(nodeOptionsLine);
      assert.strictEqual(nodeOptionsLine, '--max-old-space-size=4096 --no-warnings');
    } finally {
      await done();
    }
  });

  it('should strip underscore aliases of watch flags from NODE_OPTIONS', async () => {
    // Avoid recursively watching the repository's source and test trees.
    const cwd = tmpdir.resolve('node-options-underscore');
    mkdirSync(path.join(cwd, 'src'), { recursive: true });
    mkdirSync(path.join(cwd, 'test'));
    const file = createTmpFile('console.log(process.env.NODE_OPTIONS);');
    const nodeOptions = [
      '--watch_path=./src',
      '--watch_path', './test',
      '--watch_preserve_output',
      '--watch_preserve_output=true',
      '--watch_kill_signal=SIGKILL',
      '--watch_kill_signal', 'SIGINT',
      '--max_old_space_size=4096',
      '--no-warnings',
    ].join(' ');
    const { done, restart } = runInBackground({
      args: ['--watch', file],
      options: {
        cwd,
        env: { ...process.env, NODE_OPTIONS: nodeOptions },
      },
    });

    try {
      const { stdout, stderr } = await restart();

      assert.strictEqual(stderr, '');
      const nodeOptionsLine = stdout.find((line) => line.includes('--max_old_space_size'));
      assert.ok(nodeOptionsLine);
      assert.strictEqual(nodeOptionsLine, '--max_old_space_size=4096 --no-warnings');
    } finally {
      await done();
    }
  });

  it('should strip --watch_path from execArgv in child process', async () => {
    const dir = tmpdir.resolve('exec-argv-underscore');
    mkdirSync(dir, { recursive: true });
    const file = createTmpFile('console.log(JSON.stringify(process.execArgv));');
    const { done, restart } = runInBackground({
      args: [`--watch_path=${dir}`, file],
    });

    try {
      const { stdout, stderr } = await restart();

      assert.strictEqual(stderr, '');
      assert.ok(stdout.includes('["--no-warnings"]'));
    } finally {
      await done();
    }
  });

  it('should not strip --watch when it appears inside a quoted NODE_OPTIONS value', {
    // Honoring --require from NODE_OPTIONS is required for this test.
    skip: !!process.config.variables.node_without_node_options,
  }, async () => {
    // Use /tmp to avoid CI directories with special characters (e.g. ")
    // that would break NODE_OPTIONS parsing.
    const watchDir = path.join(os.tmpdir(), 'test for --watch parsing');
    mkdirSync(watchDir, { recursive: true });
    const reqFile = path.join(watchDir, 'req.cjs');
    writeFileSync(reqFile, 'globalThis.requiredOk = true;');

    const file = createTmpFile('console.log("required:" + !!globalThis.requiredOk);');
    // Backslashes inside quoted NODE_OPTIONS values are escape characters.
    const nodeOptions = `--watch --require "${reqFile.replaceAll(path.sep, '/')}"`;
    const { done, restart } = runInBackground({
      args: ['--watch', file],
      options: {
        env: { ...process.env, NODE_OPTIONS: nodeOptions },
      },
    });

    try {
      const { stdout, stderr } = await restart();

      assert.strictEqual(stderr, '');
      assert.ok(stdout.some((line) => line.includes('required:true')));
    } finally {
      await done();
    }
  });

  for (const { name, nodeOptions, expected } of [
    { name: 'a double quote', nodeOptions: '--title="a\\"b"', expected: 'a"b' },
    { name: 'a backslash', nodeOptions: '--title="a \\\\b"', expected: 'a \\b' },
  ]) {
    it(`should preserve NODE_OPTIONS values containing ${name} in child process`, {
      // Honoring --title from NODE_OPTIONS is required for this test.
      // process.title is always an empty string on SunOS, so --title
      // cannot be observed there.
      skip: !!process.config.variables.node_without_node_options || common.isSunOS,
    }, async () => {
      const file = createTmpFile('console.log(JSON.stringify(process.title));');
      const { done, restart } = runInBackground({
        args: ['--watch', file],
        options: {
          env: { ...process.env, NODE_OPTIONS: `--watch ${nodeOptions}` },
        },
      });

      try {
        const { stdout, stderr } = await restart();

        assert.strictEqual(stderr, '');
        assert.ok(stdout.includes(JSON.stringify(expected)), stdout.join('\n'));
      } finally {
        await done();
      }
    });
  }

  it('should handle NODE_OPTIONS containing only watch flags', async () => {
    const file = createTmpFile('console.log(JSON.stringify(process.env.NODE_OPTIONS));');
    const { done, restart } = runInBackground({
      args: ['--watch', file],
      options: {
        env: { ...process.env, NODE_OPTIONS: '--watch' },
      },
    });

    try {
      const { stdout, stderr } = await restart();

      assert.strictEqual(stderr, '');
      assert.ok(stdout.some((line) => line.includes('""')));
    } finally {
      await done();
    }
  });

  it('should strip multiple --watch-path entries from NODE_OPTIONS', async () => {
    const file = createTmpFile('console.log(process.env.NODE_OPTIONS);');
    // Use /tmp to avoid CI directories with special characters (e.g. ")
    // that would break NODE_OPTIONS parsing.
    const dirA = path.join(os.tmpdir(), 'node-watch-path-a');
    const dirB = path.join(os.tmpdir(), 'node-watch-path-b');
    mkdirSync(dirA, { recursive: true });
    mkdirSync(dirB, { recursive: true });
    const nodeOptions = `--watch --watch-path=${dirA} --watch-path ${dirB} --no-warnings`;
    const { done, restart } = runInBackground({
      args: ['--watch', file],
      options: {
        env: { ...process.env, NODE_OPTIONS: nodeOptions },
      },
    });

    try {
      const { stdout, stderr } = await restart();

      assert.strictEqual(stderr, '');
      assert.ok(stdout.some((line) => line === '--no-warnings'));
    } finally {
      await done();
    }
  });
});