node/test/sequential/test-watch-mode.mjs
Zack 70995bde61
watch: enable passthrough ipc in watch mode
PR-URL: https://github.com/nodejs/node/pull/50890
Reviewed-By: Moshe Atlow <moshe@atlow.co.il>
Reviewed-By: Benjamin Gruenbaum <benjamingr@gmail.com>
2024-05-09 18:56:30 +00:00

658 lines
20 KiB
JavaScript

import * as common from '../common/index.mjs';
import tmpdir from '../common/tmpdir.js';
import assert from 'node:assert';
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.isOSX || 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);
}
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;
}
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 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)}`,
`Restarting ${inspect(file)}`,
'running',
`Completed running ${inspect(file)}`,
]);
});
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)}`,
`Restarting ${inspect(file)}`,
'running',
`Completed running ${inspect(file)}`,
]);
});
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)}`,
`Restarting ${inspect(file)}`,
`Failed running ${inspect(file)}`,
]);
});
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)}`,
`Restarting ${inspect(file)}`,
'running',
`Completed running ${inspect(file)}`,
]);
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)}`,
`Restarting ${inspect(file)}`,
`Failed running ${inspect(file)}`,
]);
});
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)}`,
`Restarting ${inspect(file)}`,
`Failed running ${inspect(file)}`,
]);
});
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',
`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)}`,
`Restarting ${inspect(file)}`,
'{}',
`Completed running ${inspect(file)}`,
]);
});
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)}`,
`Restarting ${inspect(file)}`,
'{}',
`Completed running ${inspect(file)}`,
]);
});
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)}`,
`Restarting ${inspect(file)}`,
'running',
`Completed running ${inspect(file)}`,
`Restarting ${inspect(file)}`,
'running',
`Completed running ${inspect(file)}`,
]);
});
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}`)}`,
`Restarting ${inspect(`${file} --random ${random}`)}`,
random,
`Completed running ${inspect(`${file} --random ${random}`)}`,
]);
});
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)}`,
`Restarting ${inspect(file)}`,
'running',
`Completed running ${inspect(file)}`,
]);
});
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)}`,
`Restarting ${inspect(file)}`,
'running',
`Completed running ${inspect(file)}`,
]);
});
// 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)}`,
`Restarting ${inspect(file)}`,
'running',
`Completed running ${inspect(file)}`,
]);
});
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)}`,
`Restarting ${inspect(file)}`,
'running',
`Completed running ${inspect(file)}`,
]);
});
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)}`,
`Restarting ${inspect(file)}`,
'hello',
'running',
`Completed running ${inspect(file)}`,
]);
});
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)}`,
`Restarting ${inspect(file)}`,
'hello',
'running',
`Completed running ${inspect(file)}`,
]);
});
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)}`,
`Restarting ${inspect(file)}`,
'hello',
'running',
`Completed running ${inspect(file)}`,
]);
});
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)}`,
`Restarting ${inspect(file)}`,
'hello',
'running',
`Completed running ${inspect(file)}`,
]);
});
it('should run when `--watch --inspect`', 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)}`,
`Restarting ${inspect(file)}`,
'running',
`Completed running ${inspect(file)}`,
]);
});
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)}`,
`Restarting ${inspect(file)}`,
'hello',
'running',
`Completed running ${inspect(file)}`,
]);
});
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',
`Restarting ${inspect(file)}`,
'running',
'Received: second message',
`Completed running ${inspect(file)}`,
]);
});
});