1import * as common from '../common/index.mjs';
2import tmpdir from '../common/tmpdir.js';
3import assert from 'node:assert';
4import path from 'node:path';
5import { execPath } from 'node:process';
6import { describe, it } from 'node:test';
7import { spawn } from 'node:child_process';
8import { writeFileSync, readFileSync, mkdirSync } from 'node:fs';
9import { inspect } from 'node:util';
10import { pathToFileURL } from 'node:url';
11import { createInterface } from 'node:readline';
12
13if (common.isIBMi)
14  common.skip('IBMi does not support `fs.watch()`');
15
16const supportsRecursive = common.isOSX || common.isWindows;
17
18function restart(file, content = readFileSync(file)) {
19  // To avoid flakiness, we save the file repeatedly until test is done
20  writeFileSync(file, content);
21  const timer = setInterval(() => writeFileSync(file, content), common.platformTimeout(2500));
22  return () => clearInterval(timer);
23}
24
25let tmpFiles = 0;
26function createTmpFile(content = 'console.log("running");', ext = '.js', basename = tmpdir.path) {
27  const file = path.join(basename, `${tmpFiles++}${ext}`);
28  writeFileSync(file, content);
29  return file;
30}
31
32async function runWriteSucceed({
33  file, watchedFile, args = [file], completed = 'Completed running', restarts = 2
34}) {
35  const child = spawn(execPath, ['--watch', '--no-warnings', ...args], { encoding: 'utf8', stdio: 'pipe' });
36  let completes = 0;
37  let cancelRestarts = () => {};
38  let stderr = '';
39  const stdout = [];
40
41  child.stderr.on('data', (data) => {
42    stderr += data;
43  });
44
45  try {
46    // Break the chunks into lines
47    for await (const data of createInterface({ input: child.stdout })) {
48      if (!data.startsWith('Waiting for graceful termination') && !data.startsWith('Gracefully restarted')) {
49        stdout.push(data);
50      }
51      if (data.startsWith(completed)) {
52        completes++;
53        if (completes === restarts) {
54          break;
55        }
56        if (completes === 1) {
57          cancelRestarts = restart(watchedFile);
58        }
59      }
60    }
61  } finally {
62    child.kill();
63    cancelRestarts();
64  }
65  return { stdout, stderr };
66}
67
68async function failWriteSucceed({ file, watchedFile }) {
69  const child = spawn(execPath, ['--watch', '--no-warnings', file], { encoding: 'utf8', stdio: 'pipe' });
70  let cancelRestarts = () => {};
71
72  try {
73    // Break the chunks into lines
74    for await (const data of createInterface({ input: child.stdout })) {
75      if (data.startsWith('Completed running')) {
76        break;
77      }
78      if (data.startsWith('Failed running')) {
79        cancelRestarts = restart(watchedFile, 'console.log("test has ran");');
80      }
81    }
82  } finally {
83    child.kill();
84    cancelRestarts();
85  }
86}
87
88tmpdir.refresh();
89
90describe('watch mode', { concurrency: true, timeout: 60_000 }, () => {
91  it('should watch changes to a file - event loop ended', async () => {
92    const file = createTmpFile();
93    const { stderr, stdout } = await runWriteSucceed({ file, watchedFile: file });
94
95    assert.strictEqual(stderr, '');
96    assert.deepStrictEqual(stdout, [
97      'running',
98      `Completed running ${inspect(file)}`,
99      `Restarting ${inspect(file)}`,
100      'running',
101      `Completed running ${inspect(file)}`,
102    ]);
103  });
104
105  it('should watch changes to a failing file', async () => {
106    const file = createTmpFile('throw new Error("fails");');
107    const { stderr, stdout } = await runWriteSucceed({ file, watchedFile: file, completed: 'Failed running' });
108
109    assert.match(stderr, /Error: fails\r?\n/);
110    assert.deepStrictEqual(stdout, [
111      `Failed running ${inspect(file)}`,
112      `Restarting ${inspect(file)}`,
113      `Failed running ${inspect(file)}`,
114    ]);
115  });
116
117  it('should watch changes to a file with watch-path', {
118    skip: !supportsRecursive,
119  }, async () => {
120    const dir = path.join(tmpdir.path, 'subdir1');
121    mkdirSync(dir);
122    const file = createTmpFile();
123    const watchedFile = createTmpFile('', '.js', dir);
124    const args = ['--watch-path', dir, file];
125    const { stderr, stdout } = await runWriteSucceed({ file, watchedFile, args });
126
127    assert.strictEqual(stderr, '');
128    assert.deepStrictEqual(stdout, [
129      'running',
130      `Completed running ${inspect(file)}`,
131      `Restarting ${inspect(file)}`,
132      'running',
133      `Completed running ${inspect(file)}`,
134    ]);
135    assert.strictEqual(stderr, '');
136  });
137
138  it('should watch when running an non-existing file - when specified under --watch-path', {
139    skip: !supportsRecursive
140  }, async () => {
141    const dir = path.join(tmpdir.path, 'subdir2');
142    mkdirSync(dir);
143    const file = path.join(dir, 'non-existing.js');
144    const watchedFile = createTmpFile('', '.js', dir);
145    const args = ['--watch-path', dir, file];
146    const { stderr, stdout } = await runWriteSucceed({ file, watchedFile, args, completed: 'Failed running' });
147
148    assert.match(stderr, /Error: Cannot find module/g);
149    assert.deepStrictEqual(stdout, [
150      `Failed running ${inspect(file)}`,
151      `Restarting ${inspect(file)}`,
152      `Failed running ${inspect(file)}`,
153    ]);
154  });
155
156  it('should watch when running an non-existing file - when specified under --watch-path with equals', {
157    skip: !supportsRecursive
158  }, async () => {
159    const dir = path.join(tmpdir.path, 'subdir3');
160    mkdirSync(dir);
161    const file = path.join(dir, 'non-existing.js');
162    const watchedFile = createTmpFile('', '.js', dir);
163    const args = [`--watch-path=${dir}`, file];
164    const { stderr, stdout } = await runWriteSucceed({ file, watchedFile, args, completed: 'Failed running' });
165
166    assert.match(stderr, /Error: Cannot find module/g);
167    assert.deepStrictEqual(stdout, [
168      `Failed running ${inspect(file)}`,
169      `Restarting ${inspect(file)}`,
170      `Failed running ${inspect(file)}`,
171    ]);
172  });
173
174  it('should watch changes to a file - event loop blocked', { timeout: 10_000 }, async () => {
175    const file = createTmpFile(`
176console.log("running");
177Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0);
178console.log("don't show me");`);
179    const { stderr, stdout } = await runWriteSucceed({ file, watchedFile: file, completed: 'running' });
180
181    assert.strictEqual(stderr, '');
182    assert.deepStrictEqual(stdout, [
183      'running',
184      `Restarting ${inspect(file)}`,
185      'running',
186    ]);
187  });
188
189  it('should watch changes to dependencies - cjs', async () => {
190    const dependency = createTmpFile('module.exports = {};');
191    const file = createTmpFile(`
192const dependency = require(${JSON.stringify(dependency)});
193console.log(dependency);
194`);
195    const { stderr, stdout } = await runWriteSucceed({ file, watchedFile: dependency });
196
197    assert.strictEqual(stderr, '');
198    assert.deepStrictEqual(stdout, [
199      '{}',
200      `Completed running ${inspect(file)}`,
201      `Restarting ${inspect(file)}`,
202      '{}',
203      `Completed running ${inspect(file)}`,
204    ]);
205  });
206
207  it('should watch changes to dependencies - esm', async () => {
208    const dependency = createTmpFile('module.exports = {};');
209    const file = createTmpFile(`
210import dependency from ${JSON.stringify(pathToFileURL(dependency))};
211console.log(dependency);
212`, '.mjs');
213    const { stderr, stdout } = await runWriteSucceed({ file, watchedFile: dependency });
214
215    assert.strictEqual(stderr, '');
216    assert.deepStrictEqual(stdout, [
217      '{}',
218      `Completed running ${inspect(file)}`,
219      `Restarting ${inspect(file)}`,
220      '{}',
221      `Completed running ${inspect(file)}`,
222    ]);
223  });
224
225  it('should restart multiple times', async () => {
226    const file = createTmpFile();
227    const { stderr, stdout } = await runWriteSucceed({ file, watchedFile: file, restarts: 3 });
228
229    assert.strictEqual(stderr, '');
230    assert.deepStrictEqual(stdout, [
231      'running',
232      `Completed running ${inspect(file)}`,
233      `Restarting ${inspect(file)}`,
234      'running',
235      `Completed running ${inspect(file)}`,
236      `Restarting ${inspect(file)}`,
237      'running',
238      `Completed running ${inspect(file)}`,
239    ]);
240  });
241
242  it('should pass arguments to file', async () => {
243    const file = createTmpFile(`
244const { parseArgs } = require('node:util');
245const { values } = parseArgs({ options: { random: { type: 'string' } } });
246console.log(values.random);
247    `);
248    const random = Date.now().toString();
249    const args = [file, '--random', random];
250    const { stderr, stdout } = await runWriteSucceed({ file, watchedFile: file, args });
251
252    assert.strictEqual(stderr, '');
253    assert.deepStrictEqual(stdout, [
254      random,
255      `Completed running ${inspect(`${file} --random ${random}`)}`,
256      `Restarting ${inspect(`${file} --random ${random}`)}`,
257      random,
258      `Completed running ${inspect(`${file} --random ${random}`)}`,
259    ]);
260  });
261
262  it('should not load --require modules in main process', async () => {
263    const file = createTmpFile();
264    const required = createTmpFile('setImmediate(() => process.exit(0));');
265    const args = ['--require', required, file];
266    const { stderr, stdout } = await runWriteSucceed({ file, watchedFile: file, args });
267
268    assert.strictEqual(stderr, '');
269    assert.deepStrictEqual(stdout, [
270      'running',
271      `Completed running ${inspect(file)}`,
272      `Restarting ${inspect(file)}`,
273      'running',
274      `Completed running ${inspect(file)}`,
275    ]);
276  });
277
278  it('should not load --import modules in main process', {
279    skip: 'enable once --import is backported',
280  }, async () => {
281    const file = createTmpFile();
282    const imported = pathToFileURL(createTmpFile('setImmediate(() => process.exit(0));'));
283    const args = ['--import', imported, file];
284    const { stderr, stdout } = await runWriteSucceed({ file, watchedFile: file, args });
285
286    assert.strictEqual(stderr, '');
287    assert.deepStrictEqual(stdout, [
288      'running',
289      `Completed running ${inspect(file)}`,
290      `Restarting ${inspect(file)}`,
291      'running',
292      `Completed running ${inspect(file)}`,
293    ]);
294  });
295
296  // TODO: Remove skip after https://github.com/nodejs/node/pull/45271 lands
297  it('should not watch when running an missing file', {
298    skip: !supportsRecursive
299  }, async () => {
300    const nonExistingfile = path.join(tmpdir.path, `${tmpFiles++}.js`);
301    await failWriteSucceed({ file: nonExistingfile, watchedFile: nonExistingfile });
302  });
303
304  it('should not watch when running an missing mjs file', {
305    skip: !supportsRecursive
306  }, async () => {
307    const nonExistingfile = path.join(tmpdir.path, `${tmpFiles++}.mjs`);
308    await failWriteSucceed({ file: nonExistingfile, watchedFile: nonExistingfile });
309  });
310
311  it('should watch changes to previously missing dependency', {
312    skip: !supportsRecursive
313  }, async () => {
314    const dependency = path.join(tmpdir.path, `${tmpFiles++}.js`);
315    const relativeDependencyPath = `./${path.basename(dependency)}`;
316    const dependant = createTmpFile(`console.log(require('${relativeDependencyPath}'))`);
317
318    await failWriteSucceed({ file: dependant, watchedFile: dependency });
319  });
320
321  it('should watch changes to previously missing ESM dependency', {
322    skip: !supportsRecursive
323  }, async () => {
324    const relativeDependencyPath = `./${tmpFiles++}.mjs`;
325    const dependency = path.join(tmpdir.path, relativeDependencyPath);
326    const dependant = createTmpFile(`import ${JSON.stringify(relativeDependencyPath)}`, '.mjs');
327
328    await failWriteSucceed({ file: dependant, watchedFile: dependency });
329  });
330
331  it('should clear output between runs', async () => {
332    const file = createTmpFile();
333    const { stderr, stdout } = await runWriteSucceed({ file, watchedFile: file });
334
335    assert.strictEqual(stderr, '');
336    assert.deepStrictEqual(stdout, [
337      'running',
338      `Completed running ${inspect(file)}`,
339      `Restarting ${inspect(file)}`,
340      'running',
341      `Completed running ${inspect(file)}`,
342    ]);
343  });
344
345  it('should preserve output when --watch-preserve-output flag is passed', async () => {
346    const file = createTmpFile();
347    const args = ['--watch-preserve-output', file];
348    const { stderr, stdout } = await runWriteSucceed({ file, watchedFile: file, args });
349
350    assert.strictEqual(stderr, '');
351    assert.deepStrictEqual(stdout, [
352      'running',
353      `Completed running ${inspect(file)}`,
354      `Restarting ${inspect(file)}`,
355      'running',
356      `Completed running ${inspect(file)}`,
357    ]);
358  });
359});
360