From a64b5c88d689bacf161a3de5fd0214c63b924eac Mon Sep 17 00:00:00 2001 From: Moshe Atlow Date: Tue, 23 Aug 2022 20:50:21 +0300 Subject: [PATCH] cli: add `--watch` --- doc/api/cli.md | 28 ++++ lib/internal/assert/assertion_error.js | 49 +++---- lib/internal/main/watch_mode.js | 125 +++++++++++++++++ lib/internal/modules/cjs/loader.js | 10 ++ lib/internal/modules/esm/loader.js | 4 + lib/internal/util/colors.js | 20 +++ lib/internal/watch_mode/files_watcher.js | 124 +++++++++++++++++ src/env-inl.h | 3 +- src/inspector_agent.cc | 4 + src/node.cc | 4 + src/node_options.cc | 34 ++++- src/node_options.h | 5 + test/fixtures/watch-mode/failing.js | 1 + test/fixtures/watch-mode/file.mjs | 3 + test/fixtures/watch-mode/gracefulExit.js | 12 ++ test/fixtures/watch-mode/ipc.js | 7 + test/fixtures/watch-mode/logs.mjs | 1 + test/fixtures/watch-mode/server.js | 11 ++ test/parallel/test-watch-mode-cli.mjs | 111 +++++++++++++++ test/parallel/test-watch-mode-colors.mjs | 1 + .../test-watch-mode-files_watcher.mjs | 131 ++++++++++++++++++ 21 files changed, 654 insertions(+), 34 deletions(-) create mode 100644 lib/internal/main/watch_mode.js create mode 100644 lib/internal/util/colors.js create mode 100644 lib/internal/watch_mode/files_watcher.js create mode 100644 test/fixtures/watch-mode/failing.js create mode 100644 test/fixtures/watch-mode/file.mjs create mode 100644 test/fixtures/watch-mode/gracefulExit.js create mode 100644 test/fixtures/watch-mode/ipc.js create mode 100644 test/fixtures/watch-mode/logs.mjs create mode 100644 test/fixtures/watch-mode/server.js create mode 100644 test/parallel/test-watch-mode-cli.mjs create mode 100644 test/parallel/test-watch-mode-colors.mjs create mode 100644 test/parallel/test-watch-mode-files_watcher.mjs diff --git a/doc/api/cli.md b/doc/api/cli.md index 798400ade85f1d..19f947948e7677 100644 --- a/doc/api/cli.md +++ b/doc/api/cli.md @@ -1577,6 +1577,32 @@ on the number of online processors. If the value provided is larger than V8's maximum, then the largest value will be chosen. +### `--watch` + + + +> Stability: 1 - Experimental + +Starts the Node.js in watch mode. +When in watch mode, changes in the watched files cause node to restart. +By default, watch mode will watch the entry point +and any required module. use `--watch-path` to specify what paths to watch + +This flag cannot be combined with +`--check`, `--eval`, `--interactive`, or the REPL. + +### `--watch-path` + + + +> Stability: 1 - Experimental + +Specify what paths to watch in watch mode. + ### `--zero-fill-buffers` diff --git a/lib/internal/assert/assertion_error.js b/lib/internal/assert/assertion_error.js index 3deb8185229d7f..17e261bd4cb1ef 100644 --- a/lib/internal/assert/assertion_error.js +++ b/lib/internal/assert/assertion_error.js @@ -21,15 +21,12 @@ const { inspect } = require('internal/util/inspect'); const { removeColors, } = require('internal/util'); +const colors = require('internal/util/colors'); const { validateObject, } = require('internal/validators'); const { isErrorStackTraceLimitWritable } = require('internal/errors'); -let blue = ''; -let green = ''; -let red = ''; -let white = ''; const kReadableOperator = { deepStrictEqual: 'Expected values to be strictly deep-equal:', @@ -169,7 +166,7 @@ function createErrDiff(actual, expected, operator) { // Only remove lines in case it makes sense to collapse those. // TODO: Accept env to always show the full error. if (actualLines.length > 50) { - actualLines[46] = `${blue}...${white}`; + actualLines[46] = `${colors.blue}...${colors.white}`; while (actualLines.length > 47) { ArrayPrototypePop(actualLines); } @@ -182,7 +179,7 @@ function createErrDiff(actual, expected, operator) { // There were at least five identical lines at the end. Mark a couple of // skipped. if (i >= 5) { - end = `\n${blue}...${white}${end}`; + end = `\n${colors.blue}...${colors.white}${end}`; skipped = true; } if (other !== '') { @@ -193,15 +190,15 @@ function createErrDiff(actual, expected, operator) { let printedLines = 0; let identical = 0; const msg = kReadableOperator[operator] + - `\n${green}+ actual${white} ${red}- expected${white}`; - const skippedMsg = ` ${blue}...${white} Lines skipped`; + `\n${colors.green}+ actual${colors.white} ${colors.red}- expected${colors.white}`; + const skippedMsg = ` ${colors.blue}...${colors.white} Lines skipped`; let lines = actualLines; - let plusMinus = `${green}+${white}`; + let plusMinus = `${colors.green}+${colors.white}`; let maxLength = expectedLines.length; if (actualLines.length < maxLines) { lines = expectedLines; - plusMinus = `${red}-${white}`; + plusMinus = `${colors.red}-${colors.white}`; maxLength = actualLines.length; } @@ -216,7 +213,7 @@ function createErrDiff(actual, expected, operator) { res += `\n ${lines[i - 3]}`; printedLines++; } else { - res += `\n${blue}...${white}`; + res += `\n${colors.blue}...${colors.white}`; skipped = true; } } @@ -272,7 +269,7 @@ function createErrDiff(actual, expected, operator) { res += `\n ${actualLines[i - 3]}`; printedLines++; } else { - res += `\n${blue}...${white}`; + res += `\n${colors.blue}...${colors.white}`; skipped = true; } } @@ -286,8 +283,8 @@ function createErrDiff(actual, expected, operator) { identical = 0; // Add the actual line to the result and cache the expected diverging // line so consecutive diverging lines show up as +++--- and not +-+-+-. - res += `\n${green}+${white} ${actualLine}`; - other += `\n${red}-${white} ${expectedLine}`; + res += `\n${colors.green}+${colors.white} ${actualLine}`; + other += `\n${colors.red}-${colors.white} ${expectedLine}`; printedLines += 2; // Lines are identical } else { @@ -306,8 +303,8 @@ function createErrDiff(actual, expected, operator) { } // Inspected object to big (Show ~50 rows max) if (printedLines > 50 && i < maxLines - 2) { - return `${msg}${skippedMsg}\n${res}\n${blue}...${white}${other}\n` + - `${blue}...${white}`; + return `${msg}${skippedMsg}\n${res}\n${colors.blue}...${colors.white}${other}\n` + + `${colors.blue}...${colors.white}`; } } @@ -347,21 +344,9 @@ class AssertionError extends Error { if (message != null) { super(String(message)); } else { - if (process.stderr.isTTY) { - // Reset on each call to make sure we handle dynamically set environment - // variables correct. - if (process.stderr.hasColors()) { - blue = '\u001b[34m'; - green = '\u001b[32m'; - white = '\u001b[39m'; - red = '\u001b[31m'; - } else { - blue = ''; - green = ''; - white = ''; - red = ''; - } - } + // Reset colors on each call to make sure we handle dynamically set environment + // variables correct. + colors.refresh(); // Prevent the error stack from being visible by duplicating the error // in a very close way to the original in case both sides are actually // instances of Error. @@ -393,7 +378,7 @@ class AssertionError extends Error { // Only remove lines in case it makes sense to collapse those. // TODO: Accept env to always show the full error. if (res.length > 50) { - res[46] = `${blue}...${white}`; + res[46] = `${colors.blue}...${colors.white}`; while (res.length > 47) { ArrayPrototypePop(res); } diff --git a/lib/internal/main/watch_mode.js b/lib/internal/main/watch_mode.js new file mode 100644 index 00000000000000..afca579e6acdc7 --- /dev/null +++ b/lib/internal/main/watch_mode.js @@ -0,0 +1,125 @@ +'use strict'; +const { + ArrayPrototypeFilter, + ArrayPrototypeJoin, + ArrayPrototypeMap, + ArrayPrototypePush, + ArrayPrototypePushApply, + ArrayPrototypeSlice, +} = primordials; + +const { + prepareMainThreadExecution, + markBootstrapComplete +} = require('internal/process/pre_execution'); +const { getOptionValue } = require('internal/options'); +const { emitExperimentalWarning } = require('internal/util'); +const { FilesWatcher } = require('internal/watch_mode/files_watcher'); +const { green, blue, red, white, clear } = require('internal/util/colors'); + +const { spawn } = require('child_process'); +const { inspect } = require('util'); +const { setTimeout, clearTimeout } = require('timers'); +const { resolve } = require('path'); +const { once, on } = require('events'); + + +prepareMainThreadExecution(false, false); +markBootstrapComplete(); + +// TODO(MoLow): Make kill signal configurable +const kKillSignal = 'SIGTERM'; +const kShouldFilterModules = getOptionValue('--watch-path').length === 0; +const kWatchedPaths = getOptionValue('--watch-path').length ? + ArrayPrototypeMap(getOptionValue('--watch-path'), (path) => resolve(path)) : + []; +const kCommand = ArrayPrototypeSlice(process.argv, 1); +const kCommandStr = inspect(ArrayPrototypeJoin(kCommand, ' ')); +const args = ArrayPrototypeFilter(process.execArgv, (x, i, arr) => + x !== '--watch-path' && arr[i - 1] !== '--watch-path' && x !== '--watch'); +ArrayPrototypePush(args, '--watch-report-ipc'); +ArrayPrototypePushApply(args, kCommand); + +const watcher = new FilesWatcher({ throttle: 500, mode: kShouldFilterModules ? 'filter' : 'all' }); +kWatchedPaths.forEach((p) => watcher.watchPath(p)); +let graceTimer; +let child; +let exited; + +function start() { + // Spawning in detached mode so node can control when signals are forwarded + exited = false; + const stdio = kShouldFilterModules ? ['inherit', 'inherit', 'inherit', 'ipc'] : undefined; + child = spawn(process.execPath, args, { stdio, detached: true }); + watcher.watchChildProcessModules(child); + child.once('exit', (code) => { + exited = true; + if (code === 0) { + process.stdout.write(`${blue}Completed running ${kCommandStr}${white}\n`); + } else { + process.stdout.write(`${red}Failed running ${kCommandStr}${white}\n`); + } + }); +} + +async function killAndWait(signal = kKillSignal) { + child?.removeAllListeners(); + if (!child || child.killed || exited) { + return; + } + const onExit = once(child, 'exit'); + child.kill(signal); + const { 0: exitCode } = await onExit; + return exitCode; +} + +function reportGracefulTermination() { + // Log if process takes more than 500ms to stop + let reported = false; + clearTimeout(graceTimer); + graceTimer = setTimeout(() => { + reported = true; + process.stdout.write(`${blue}Waiting for graceful termination...${white}\n`); + }, 500).unref(); + return () => { + clearTimeout(graceTimer); + if (reported) { + process.stdout.write(`${clear}${green}Gracefully restarted ${kCommandStr}${white}\n`); + } + }; +} + +async function stop() { + watcher.clearFileFilters(); + const clearGraceReport = reportGracefulTermination(); + await killAndWait(); + clearGraceReport(); +} + +async function restart() { + process.stdout.write(`${clear}${green}Restarting ${kCommandStr}${white}\n`); + await stop(); + start(); +} + +(async () => { + emitExperimentalWarning('Watch mode'); + start(); + + // eslint-disable-next-line no-unused-vars + for await (const _ of on(watcher, 'changed')) { + await restart(); + } +})(); + +// Exiting gracefully to avoid stdout/stderr getting written after +// parent process is killed. +// this is fairly safe since user code cannot run in this process +function signalHandler(signal) { + return async () => { + watcher.clear(); + process.exit(await killAndWait(signal)); + }; +} +process.on('SIGTERM', signalHandler('SIGTERM')); +process.on('SIGINT', signalHandler('SIGINT')); diff --git a/lib/internal/modules/cjs/loader.js b/lib/internal/modules/cjs/loader.js index 796c527b011139..bc2fa4eb427d73 100644 --- a/lib/internal/modules/cjs/loader.js +++ b/lib/internal/modules/cjs/loader.js @@ -100,6 +100,7 @@ const { const { getOptionValue } = require('internal/options'); const preserveSymlinks = getOptionValue('--preserve-symlinks'); const preserveSymlinksMain = getOptionValue('--preserve-symlinks-main'); +const shouldReportRequiredModules = getOptionValue('--watch-report-ipc'); // Do not eagerly grab .manifest, it may be in TDZ const policy = getOptionValue('--experimental-policy') ? require('internal/process/policy') : @@ -168,6 +169,12 @@ function updateChildren(parent, child, scan) { ArrayPrototypePush(children, child); } +function reportModuleToWatchMode(filename) { + if (shouldReportRequiredModules && process.send) { + process.send({ 'watch:require': filename }); + } +} + const moduleParentCache = new SafeWeakMap(); function Module(id = '', parent) { this.id = id; @@ -776,6 +783,7 @@ Module._load = function(request, parent, isMain) { // cache key names. relResolveCacheIdentifier = `${parent.path}\x00${request}`; const filename = relativeResolveCache[relResolveCacheIdentifier]; + reportModuleToWatchMode(filename); if (filename !== undefined) { const cachedModule = Module._cache[filename]; if (cachedModule !== undefined) { @@ -828,6 +836,8 @@ Module._load = function(request, parent, isMain) { module.id = '.'; } + reportModuleToWatchMode(filename); + Module._cache[filename] = module; if (parent !== undefined) { relativeResolveCache[relResolveCacheIdentifier] = filename; diff --git a/lib/internal/modules/esm/loader.js b/lib/internal/modules/esm/loader.js index 79e9b2446c6bbf..ca2abd320ab3e9 100644 --- a/lib/internal/modules/esm/loader.js +++ b/lib/internal/modules/esm/loader.js @@ -475,6 +475,10 @@ class ESMLoader { getOptionValue('--inspect-brk') ); + if (getOptionValue('--watch-report-ipc') && process.send) { + process.send({ 'watch:import': url }); + } + const job = new ModuleJob( this, url, diff --git a/lib/internal/util/colors.js b/lib/internal/util/colors.js new file mode 100644 index 00000000000000..cb60b9ceb8770b --- /dev/null +++ b/lib/internal/util/colors.js @@ -0,0 +1,20 @@ +'use strict'; + +module.exports = { + blue: '', + green: '', + white: '', + red: '', + clear: '', + refresh() { + if (process.stderr.isTTY && process.stderr.hasColors()) { + module.exports.blue = '\u001b[34m'; + module.exports.green = '\u001b[32m'; + module.exports.white = '\u001b[39m'; + module.exports.red = '\u001b[31m'; + module.exports.clear = '\u001bc'; + } + } +}; + +module.exports.refresh(); diff --git a/lib/internal/watch_mode/files_watcher.js b/lib/internal/watch_mode/files_watcher.js new file mode 100644 index 00000000000000..1b9211c94123a3 --- /dev/null +++ b/lib/internal/watch_mode/files_watcher.js @@ -0,0 +1,124 @@ +'use strict'; + +const { + SafeMap, + SafeSet, +} = primordials; + +const { validateNumber, validateOneOf } = require('internal/validators'); +const { kEmptyObject } = require('internal/util'); +const { TIMEOUT_MAX } = require('internal/timers'); + +const EventEmitter = require('events'); +const { watch } = require('fs'); +const { fileURLToPath } = require('url'); +const { resolve, dirname } = require('path'); +const { setTimeout } = require('timers'); + +// Having multiple FSWatcher's seems to be slower +// than a single recursive FSWatcher, and ignoring irrelevant changes +class FilesWatcher extends EventEmitter { + #watchers = new SafeMap(); + #filteredFiles = new SafeSet(); + #throttling = new SafeSet(); + #throttle; + #mode; + + constructor(options = kEmptyObject) { + super(); + let { throttle, mode } = options; + throttle ??= 500; + mode ??= 'filter'; + + validateNumber(throttle, 'options.throttle', 0, TIMEOUT_MAX); + validateOneOf(mode, 'options.mode', ['filter', 'all']); + this.#throttle = throttle; + this.#mode = mode; + } + + #isPathWatched(path) { + if (this.#watchers.has(path)) { + return true; + } + + for (const watchedPath of this.#watchers.keys()) { + if (path.startsWith(watchedPath)) { + return true; + } + } + + return false; + } + + #removeWatchedChildren(path) { + for (const { 0: watchedPath, 1: watcher } of this.#watchers.entries()) { + if (path !== watchedPath && watchedPath.startsWith(path)) { + this.#unwatch(watcher); + this.#watchers.delete(watchedPath); + } + } + } + + #unwatch(watcher) { + watcher.removeAllListeners(); + watcher.close(); + } + + #onChange(trigger) { + if (this.#throttling.has(trigger)) { + return; + } + if (this.#mode === 'filter' && !this.#filteredFiles.has(trigger)) { + return; + } + this.#throttling.add(trigger); + this.emit('changed'); + setTimeout(() => this.#throttling.delete(trigger), this.#throttle).unref(); + } + + get watchedPaths() { + return [...this.#watchers.keys()]; + } + + watchPath(path) { + if (this.#isPathWatched(path)) { + return; + } + const watcher = watch(path, { recursive: true }); + watcher.on('change', (eventType, fileName) => this.#onChange(resolve(path, fileName))); + this.#watchers.set(path, watcher); + this.#removeWatchedChildren(path); + } + + filterFile(file) { + this.watchPath(dirname(file)); + this.#filteredFiles.add(file); + } + watchChildProcessModules(child) { + if (this.#mode !== 'filter') { + return; + } + child.on('message', (message) => { + if (message['watch:require']) { + this.filterFile(message['watch:require']); + } + if (message['watch:import']) { + try { + this.filterFile(fileURLToPath(message['watch:import'])); + } catch { + // Failed watching file. ignore + } + } + }); + } + clearFileFilters() { + this.#filteredFiles.clear(); + } + clear() { + this.#watchers.forEach(this.#unwatch); + this.#watchers.clear(); + this.#filteredFiles.clear(); + } +} + +module.exports = { FilesWatcher }; diff --git a/src/env-inl.h b/src/env-inl.h index 428e517aedb1e8..e30d5d7f3290ae 100644 --- a/src/env-inl.h +++ b/src/env-inl.h @@ -655,7 +655,8 @@ inline bool Environment::owns_inspector() const { } inline bool Environment::should_create_inspector() const { - return (flags_ & EnvironmentFlags::kNoCreateInspector) == 0; + return (flags_ & EnvironmentFlags::kNoCreateInspector) == 0 && + !options_->test_runner && !options_->watch_mode; } inline bool Environment::tracks_unmanaged_fds() const { diff --git a/src/inspector_agent.cc b/src/inspector_agent.cc index 34bb11e7d7122c..0179c03aad1046 100644 --- a/src/inspector_agent.cc +++ b/src/inspector_agent.cc @@ -676,6 +676,10 @@ bool Agent::Start(const std::string& path, const DebugOptions& options, std::shared_ptr> host_port, bool is_main) { + + if (!options.allow_attaching_debugger) { + return false; + } path_ = path; debug_options_ = options; CHECK_NOT_NULL(host_port); diff --git a/src/node.cc b/src/node.cc index 2891c18bb9aa9a..cb6969d763cd91 100644 --- a/src/node.cc +++ b/src/node.cc @@ -490,6 +490,10 @@ MaybeLocal StartExecution(Environment* env, StartExecutionCallback cb) { return StartExecution(env, "internal/main/test_runner"); } + if (env->options()->watch_mode && !first_argv.empty()) { + return StartExecution(env, "internal/main/watch_mode"); + } + if (!first_argv.empty() && first_argv != "-") { return StartExecution(env, "internal/main/run_main_module"); } diff --git a/src/node_options.cc b/src/node_options.cc index 0869cbb974be86..21f6fe62f6e9c3 100644 --- a/src/node_options.cc +++ b/src/node_options.cc @@ -156,9 +156,30 @@ void EnvironmentOptions::CheckOptions(std::vector* errors) { errors->push_back("either --test or --interactive can be used, not both"); } + if (watch_mode) { + // TODO(MoLow): Support (incremental?) watch mode within test runner + errors->push_back("either --test or --watch can be used, not both"); + } + if (debug_options_.inspector_enabled) { errors->push_back("the inspector cannot be used with --test"); } + debug_options_.allow_attaching_debugger = false; + } + + if (watch_mode) { + if (syntax_check_only) { + errors->push_back("either --watch or --check can be used, not both"); + } + + if (has_eval_string) { + errors->push_back("either --watch or --eval can be used, not both"); + } + + if (force_repl) { + errors->push_back("either --watch or --interactive can be used, not both"); + } + debug_options_.allow_attaching_debugger = false; } #if HAVE_INSPECTOR @@ -586,7 +607,18 @@ EnvironmentOptionsParser::EnvironmentOptionsParser() { "", /* undocumented, only for debugging */ &EnvironmentOptions::verify_base_objects, kAllowedInEnvironment); - + AddOption("--watch", + "run in watch mode", + &EnvironmentOptions::watch_mode, + kAllowedInEnvironment); + AddOption("--watch-path", + "path to watch", + &EnvironmentOptions::watch_mode_paths, + kAllowedInEnvironment); + Implies("--watch-path", "--watch"); + AddOption("--watch-report-ipc", + "", /* undocumented, used internally */ + &EnvironmentOptions::watch_mode_report_to_parent); AddOption("--check", "syntax check script without executing", &EnvironmentOptions::syntax_check_only); diff --git a/src/node_options.h b/src/node_options.h index ca43192d85a4b4..004bcae837c406 100644 --- a/src/node_options.h +++ b/src/node_options.h @@ -71,6 +71,7 @@ class DebugOptions : public Options { DebugOptions(DebugOptions&&) = default; DebugOptions& operator=(DebugOptions&&) = default; + bool allow_attaching_debugger = true; // --inspect bool inspector_enabled = false; // --debug @@ -172,6 +173,10 @@ class EnvironmentOptions : public Options { false; #endif // DEBUG + bool watch_mode = false; + bool watch_mode_report_to_parent = false; + std::vector watch_mode_paths; + bool syntax_check_only = false; bool has_eval_string = false; bool experimental_wasi = false; diff --git a/test/fixtures/watch-mode/failing.js b/test/fixtures/watch-mode/failing.js new file mode 100644 index 00000000000000..d1e87944d9f33c --- /dev/null +++ b/test/fixtures/watch-mode/failing.js @@ -0,0 +1 @@ +throw new Error('fails'); diff --git a/test/fixtures/watch-mode/file.mjs b/test/fixtures/watch-mode/file.mjs new file mode 100644 index 00000000000000..488335b204c07e --- /dev/null +++ b/test/fixtures/watch-mode/file.mjs @@ -0,0 +1,3 @@ +import { log } from './logs.mjs'; + +log(123); diff --git a/test/fixtures/watch-mode/gracefulExit.js b/test/fixtures/watch-mode/gracefulExit.js new file mode 100644 index 00000000000000..cb1247f1fb531e --- /dev/null +++ b/test/fixtures/watch-mode/gracefulExit.js @@ -0,0 +1,12 @@ + +setInterval(() => {}, 1000); + +process.on('SIGINT', () => { + setTimeout(() => process.exit(0), 1500); +}); +process.on('SIGTERM', () => { + setTimeout(() => process.exit(0), 1500); +}); +process.on('exit', () => { + console.log('exit'); +}); diff --git a/test/fixtures/watch-mode/ipc.js b/test/fixtures/watch-mode/ipc.js new file mode 100644 index 00000000000000..f9e8a6e1c28afa --- /dev/null +++ b/test/fixtures/watch-mode/ipc.js @@ -0,0 +1,7 @@ +const path = require('node:path'); +const url = require('node:url'); + +process.send({ 'watch:require': path.resolve(__filename) }); +process.send({ 'watch:import': url.pathToFileURL(path.resolve(__filename)).toString() }); +process.send({ 'watch:import': new URL('file:///tmp/path').toString() }); +process.send({ 'watch:import': new URL('http://invalid.com').toString() }); diff --git a/test/fixtures/watch-mode/logs.mjs b/test/fixtures/watch-mode/logs.mjs new file mode 100644 index 00000000000000..bb6a029def26ad --- /dev/null +++ b/test/fixtures/watch-mode/logs.mjs @@ -0,0 +1 @@ +export const log = (...args) => console.log('CUSTOM LOG', ...args); diff --git a/test/fixtures/watch-mode/server.js b/test/fixtures/watch-mode/server.js new file mode 100644 index 00000000000000..fb861108924d32 --- /dev/null +++ b/test/fixtures/watch-mode/server.js @@ -0,0 +1,11 @@ +require('./gracefulExit'); +const http = require('http'); + +const startTime = new Date(); + +const server = http.createServer((req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }) + .end(JSON.stringify({ ok: true, uptime: new Date() - startTime, startTime })); +}); + +server.listen(8080, (err) => console.log({ err, startTime }, 'listening on port 8080')); diff --git a/test/parallel/test-watch-mode-cli.mjs b/test/parallel/test-watch-mode-cli.mjs new file mode 100644 index 00000000000000..035fa47b8c4d89 --- /dev/null +++ b/test/parallel/test-watch-mode-cli.mjs @@ -0,0 +1,111 @@ +import '../common/index.mjs'; +import fixtures from '../common/fixtures.js'; +import assert from 'node:assert'; +import { execPath } from 'node:process'; +import { describe, it } from 'node:test'; +import { spawn } from 'node:child_process'; +import { utimesSync } from 'node:fs'; +import { inspect } from 'node:util'; +import { once } from 'node:events'; +import { setTimeout } from 'node:timers/promises'; + +async function spawnWithRestarts({ + args, + file, + restarts, + startedPredicate, + restartMethod, +}) { + args ??= [file]; + startedPredicate ??= (data) => Boolean(data.match(new RegExp(`(Failed|Completed) running ${inspect(args.join(' '))}`, 'g'))?.length); + restartMethod ??= () => utimesSync(file, new Date(), new Date()); + + let stderr = ''; + let stdout = ''; + let restartCount = 0; + let completedStart = false; + + const child = spawn(execPath, ['--watch', '--no-warnings', ...args], { encoding: 'utf8' }); + child.stderr.on('data', (data) => stderr += data); + child.stdout.on('data', async (data) => { + stdout += data; + const restartMessages = stdout.match(new RegExp(`Restarting ${inspect(args.join(' '))}`, 'g'))?.length ?? 0; + completedStart = completedStart || startedPredicate(data.toString()); + if (restartMessages >= restarts && completedStart) { + child.kill(); + return; + } + if (restartCount <= restartMessages && completedStart) { + restartCount++; + completedStart = false; + await setTimeout(1000, { ref: false }); // Prevent throttling + restartMethod(); + } + }); + + const [code, signal] = await once(child, 'exit'); + return { code, signal, stderr, stdout }; +} + +describe('watch mode', () => { + it('should watch changes to a file - event loop ended', async () => { + const file = fixtures.path('watch-mode/file.mjs'); + const { code, signal, stderr, stdout } = await spawnWithRestarts({ file, restarts: 1 }); + + assert.strictEqual(stderr, ''); + assert.strictEqual(stdout, [ + 'CUSTOM LOG 123', `Completed running '${file}'`, `Restarting '${file}'`, + 'CUSTOM LOG 123', `Completed running '${file}'`, '', + ].join('\n')); + assert.strictEqual(code, 0); + assert.strictEqual(signal, null); + }); + + it('should watch changes to a failing file', async () => { + const file = fixtures.path('watch-mode/failing.js'); + const { code, signal, stderr, stdout } = await spawnWithRestarts({ file, restarts: 1 }); + + assert.strictEqual(stderr.match(/Error: fails\n/g).length, 2); + assert.strictEqual(stdout, [`Failed running '${file}'`, `Restarting '${file}'`, + `Failed running '${file}'`, ''].join('\n')); + assert.strictEqual(code, 0); + assert.strictEqual(signal, null); + }); + + it('should not watch when running an non-existing file', async () => { + const file = fixtures.path('watch-mode/non-existing.js'); + const { code, signal, stderr, stdout } = await spawnWithRestarts({ file, restarts: 2, restartMethod: () => {} }); + + assert.match(stderr, /code: 'MODULE_NOT_FOUND'/); + assert.strictEqual(stdout, [`Failed running '${file}'`, ''].join('\n')); + assert.strictEqual(code, 0); + assert.strictEqual(signal, null); + }); + + + it('should watch when running an non-existing file - when specified under --watch-path'); + + it('should watch changes to a file - event loop blocked'); + + it('should watch changes to dependencies - cjs'); + + it('should watch changes to dependencies - esm'); + + it('should restart multiple times', async () => { + const file = fixtures.path('watch-mode/file.mjs'); + const { code, signal, stderr, stdout } = await spawnWithRestarts({ file, restarts: 3 }); + + assert.strictEqual(stderr, ''); + assert.strictEqual(stdout.match(new RegExp(`Restarting '${file}'`, 'g')).length, 3); + assert.strictEqual(code, 0); + assert.strictEqual(signal, null); + }); + + it('should gracefully wait when restarting'); + + it('should pass arguments to file'); + + it('should not load --require modules in main process'); + + it('should not load --import modules in main process'); +}); diff --git a/test/parallel/test-watch-mode-colors.mjs b/test/parallel/test-watch-mode-colors.mjs new file mode 100644 index 00000000000000..72c95f0a0a8549 --- /dev/null +++ b/test/parallel/test-watch-mode-colors.mjs @@ -0,0 +1 @@ +import '../common/index.mjs'; diff --git a/test/parallel/test-watch-mode-files_watcher.mjs b/test/parallel/test-watch-mode-files_watcher.mjs new file mode 100644 index 00000000000000..d90fd3d23dc92a --- /dev/null +++ b/test/parallel/test-watch-mode-files_watcher.mjs @@ -0,0 +1,131 @@ +// Flags: --expose-internals +import { mustNotCall } from '../common/index.mjs'; +import fixtures from '../common/fixtures.js'; +import tmpdir from '../common/tmpdir.js'; +import path from 'node:path'; +import assert from 'node:assert'; +import process from 'node:process'; +import { describe, it, beforeEach, afterEach } from 'node:test'; +import { writeFileSync, mkdirSync } from 'node:fs'; +import { setTimeout } from 'node:timers/promises'; +import { once } from 'node:events'; +import { spawn } from 'node:child_process'; +import watcher from 'internal/watch_mode/files_watcher'; + +const { FilesWatcher } = watcher; +tmpdir.refresh(); + +describe('watch mode file watcher', () => { + let watcher; + let changesCount; + + beforeEach(() => { + changesCount = 0; + watcher = new FilesWatcher({ throttle: 100 }); + watcher.on('changed', () => changesCount++); + }); + + afterEach(() => watcher.clear()); + + it('should watch changed files', async () => { + const file = path.join(tmpdir.path, 'file1'); + watcher.filterFile(file); + const changed = once(watcher, 'changed'); + writeFileSync(file, 'changed'); + await changed; + assert.strictEqual(changesCount, 1); + }); + + it('should throttle changes', async () => { + const file = path.join(tmpdir.path, 'file2'); + watcher.filterFile(file); + + writeFileSync(file, '1'); + writeFileSync(file, '2'); + writeFileSync(file, '3'); + writeFileSync(file, '4'); + await setTimeout(200); + writeFileSync(file, '5'); + const changed = once(watcher, 'changed'); + writeFileSync(file, 'after'); + await changed; + assert.strictEqual(changesCount, 2); + }); + + it('should ignore files in watched directory if they are not filtered', async () => { + watcher.on('changed', mustNotCall()); + watcher.watchPath(tmpdir.path); + writeFileSync(path.join(tmpdir.path, 'file3'), '1'); + // wait for this long to make sure changes are not triggered + await setTimeout(500); + }) + + it('should allow clearing filters', async () => { + const file = path.join(tmpdir.path, 'file4'); + watcher.filterFile(file); + writeFileSync(file, '1'); + + await setTimeout(200); // avoid throttling + watcher.clearFileFilters(); + writeFileSync(file, '2'); + // wait for this long to make sure changes are triggered only once + await setTimeout(500); + assert.strictEqual(changesCount, 1); + }); + + it('should watch all files in watched path when in "all" mode', async () => { + watcher = new FilesWatcher({ throttle: 100, mode: 'all' }); + watcher.on('changed', () => changesCount++); + + const file = path.join(tmpdir.path, 'file5'); + watcher.watchPath(tmpdir.path); + + const changed = once(watcher, 'changed'); + writeFileSync(file, 'changed'); + await changed; + assert.strictEqual(changesCount, 1); + }); + + it('should ruse existing watcher if it exists', () => { + assert.deepStrictEqual(watcher.watchedPaths, []); + watcher.watchPath(tmpdir.path); + assert.deepStrictEqual(watcher.watchedPaths, [tmpdir.path]); + watcher.watchPath(tmpdir.path); + assert.deepStrictEqual(watcher.watchedPaths, [tmpdir.path]); + }); + + it('should ruse existing watcher of a parent directory', () => { + assert.deepStrictEqual(watcher.watchedPaths, []); + watcher.watchPath(tmpdir.path); + assert.deepStrictEqual(watcher.watchedPaths, [tmpdir.path]); + watcher.watchPath(path.join(tmpdir.path, 'subdirectory')); + assert.deepStrictEqual(watcher.watchedPaths, [tmpdir.path]); + }); + + it('should remove existing watcher if adding a parent directory watcher', () => { + assert.deepStrictEqual(watcher.watchedPaths, []); + const subdirectory = path.join(tmpdir.path, 'subdirectory'); + mkdirSync(subdirectory); + watcher.watchPath(subdirectory); + assert.deepStrictEqual(watcher.watchedPaths, [subdirectory]); + watcher.watchPath(tmpdir.path); + assert.deepStrictEqual(watcher.watchedPaths, [tmpdir.path]); + }); + + it('should clear all watchers when calling clear', () => { + assert.deepStrictEqual(watcher.watchedPaths, []); + watcher.watchPath(tmpdir.path); + assert.deepStrictEqual(watcher.watchedPaths, [tmpdir.path]); + watcher.clear(); + assert.deepStrictEqual(watcher.watchedPaths, []); + }); + + it('should watch files from subprocess IPC events', async () => { + const child = spawn(process.execPath, [fixtures.path('watch-mode/ipc.js')], { stdio: ['pipe', 'pipe', 'pipe', 'ipc'] }); + watcher.watchChildProcessModules(child); + await once(child, 'exit'); + assert.deepStrictEqual(watcher.watchedPaths, [fixtures.path('watch-mode'), '/tmp']); + }); + + +});