Skip to content

Commit 26fb2c3

Browse files
committed
test_runner: support watch mode
PR-URL: nodejs#45214 Reviewed-By: Benjamin Gruenbaum <[email protected]> Reviewed-By: Colin Ihrig <[email protected]> Reviewed-By: Antoine du Hamel <[email protected]>
1 parent 176d431 commit 26fb2c3

File tree

10 files changed

+182
-21
lines changed

10 files changed

+182
-21
lines changed

doc/api/cli.md

+16-5
Original file line numberDiff line numberDiff line change
@@ -1086,12 +1086,19 @@ The value given must be a power of two.
10861086
### `--test`
10871087

10881088
<!-- YAML
1089-
added: v16.17.0
1089+
added:
1090+
- v18.1.0
1091+
- v16.17.0
1092+
changes:
1093+
- version: REPLACEME
1094+
pr-url: https://github.com/nodejs/node/pull/45214
1095+
description: Test runner now supports running in watch mode.
10901096
-->
10911097

10921098
Starts the Node.js command line test runner. This flag cannot be combined with
1093-
`--check`, `--eval`, `--interactive`, or the inspector. See the documentation
1094-
on [running tests from the command line][] for more details.
1099+
`--watch-path`, `--check`, `--eval`, `--interactive`, or the inspector.
1100+
See the documentation on [running tests from the command line][]
1101+
for more details.
10951102

10961103
### `--test-name-pattern`
10971104

@@ -1440,7 +1447,11 @@ will be chosen.
14401447
### `--watch`
14411448

14421449
<!-- YAML
1443-
added: REPLACEME
1450+
added: v18.11.0
1451+
changes:
1452+
- version: REPLACEME
1453+
pr-url: https://github.com/nodejs/node/pull/45214
1454+
description: Test runner now supports running in watch mode.
14441455
-->
14451456

14461457
> Stability: 1 - Experimental
@@ -1474,7 +1485,7 @@ This will turn off watching of required or imported modules, even when used in
14741485
combination with `--watch`.
14751486

14761487
This flag cannot be combined with
1477-
`--check`, `--eval`, `--interactive`, or the REPL.
1488+
`--check`, `--eval`, `--interactive`, `--test`, or the REPL.
14781489

14791490
```console
14801491
$ node --watch-path=./src --watch-path=./tests index.js

doc/api/test.md

+19
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,25 @@ test('a test that creates asynchronous activity', (t) => {
291291
});
292292
```
293293

294+
## Watch mode
295+
296+
<!-- YAML
297+
added: REPLACEME
298+
-->
299+
300+
> Stability: 1 - Experimental
301+
302+
The Node.js test runner supports running in watch mode by passing the `--watch` flag:
303+
304+
```bash
305+
node --test --watch
306+
```
307+
308+
In watch mode, the test runner will watch for changes to test files and
309+
their dependencies. When a change is detected, the test runner will
310+
rerun the tests affected by the change.
311+
The test runner will continue to run until the process is terminated.
312+
294313
## Running tests from the command line
295314

296315
The Node.js test runner can be invoked from the command line by passing the

lib/internal/main/test_runner.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
const {
33
prepareMainThreadExecution,
44
} = require('internal/bootstrap/pre_execution');
5+
const { getOptionValue } = require('internal/options');
56
const { isUsingInspector } = require('internal/util/inspector');
67
const { run } = require('internal/test_runner/runner');
78

@@ -18,7 +19,7 @@ if (isUsingInspector()) {
1819
inspectPort = process.debugPort;
1920
}
2021

21-
const tapStream = run({ concurrency, inspectPort });
22+
const tapStream = run({ concurrency, inspectPort, watch: getOptionValue('--watch') });
2223
tapStream.pipe(process.stdout);
2324
tapStream.once('test:fail', () => {
2425
process.exitCode = 1;

lib/internal/test_runner/runner.js

+62-7
Original file line numberDiff line numberDiff line change
@@ -10,20 +10,23 @@ const {
1010
ObjectAssign,
1111
PromisePrototypeThen,
1212
SafePromiseAll,
13+
SafePromiseAllSettled,
14+
SafeMap,
1315
SafeSet,
1416
} = primordials;
1517

1618
const { spawn } = require('child_process');
1719
const { readdirSync, statSync } = require('fs');
1820
// TODO(aduh95): switch to internal/readline/interface when backporting to Node.js 16.x is no longer a concern.
1921
const { createInterface } = require('readline');
22+
const { FilesWatcher } = require('internal/watch_mode/files_watcher');
2023
const console = require('internal/console/global');
2124
const {
2225
codes: {
2326
ERR_TEST_FAILURE,
2427
},
2528
} = require('internal/errors');
26-
const { validateArray } = require('internal/validators');
29+
const { validateArray, validateBoolean } = require('internal/validators');
2730
const { getInspectPort, isUsingInspector, isInspectorMessage } = require('internal/util/inspector');
2831
const { kEmptyObject } = require('internal/util');
2932
const { createTestTree } = require('internal/test_runner/harness');
@@ -34,8 +37,11 @@ const {
3437
} = require('internal/test_runner/utils');
3538
const { basename, join, resolve } = require('path');
3639
const { once } = require('events');
40+
const {
41+
triggerUncaughtException,
42+
} = internalBinding('errors');
3743

38-
const kFilterArgs = ['--test'];
44+
const kFilterArgs = ['--test', '--watch'];
3945

4046
// TODO(cjihrig): Replace this with recursive readdir once it lands.
4147
function processPath(path, testFiles, options) {
@@ -112,17 +118,28 @@ function getRunArgs({ path, inspectPort }) {
112118
return argv;
113119
}
114120

121+
const runningProcesses = new SafeMap();
122+
const runningSubtests = new SafeMap();
115123

116-
function runTestFile(path, root, inspectPort) {
124+
function runTestFile(path, root, inspectPort, filesWatcher) {
117125
const subtest = root.createSubtest(Test, path, async (t) => {
118126
const args = getRunArgs({ path, inspectPort });
127+
const stdio = ['pipe', 'pipe', 'pipe'];
128+
const env = { ...process.env };
129+
if (filesWatcher) {
130+
stdio.push('ipc');
131+
env.WATCH_REPORT_DEPENDENCIES = '1';
132+
}
119133

120-
const child = spawn(process.execPath, args, { signal: t.signal, encoding: 'utf8' });
134+
const child = spawn(process.execPath, args, { signal: t.signal, encoding: 'utf8', env, stdio });
135+
runningProcesses.set(path, child);
121136
// TODO(cjihrig): Implement a TAP parser to read the child's stdout
122137
// instead of just displaying it all if the child fails.
123138
let err;
124139
let stderr = '';
125140

141+
filesWatcher?.watchChildProcessModules(child, path);
142+
126143
child.on('error', (error) => {
127144
err = error;
128145
});
@@ -145,6 +162,8 @@ function runTestFile(path, root, inspectPort) {
145162
child.stdout.toArray({ signal: t.signal }),
146163
]);
147164

165+
runningProcesses.delete(path);
166+
runningSubtests.delete(path);
148167
if (code !== 0 || signal !== null) {
149168
if (!err) {
150169
err = ObjectAssign(new ERR_TEST_FAILURE('test failed', kSubtestsFailed), {
@@ -165,21 +184,57 @@ function runTestFile(path, root, inspectPort) {
165184
return subtest.start();
166185
}
167186

187+
function watchFiles(testFiles, root, inspectPort) {
188+
const filesWatcher = new FilesWatcher({ throttle: 500, mode: 'filter' });
189+
filesWatcher.on('changed', ({ owners }) => {
190+
filesWatcher.unfilterFilesOwnedBy(owners);
191+
PromisePrototypeThen(SafePromiseAll(testFiles, async (file) => {
192+
if (!owners.has(file)) {
193+
return;
194+
}
195+
const runningProcess = runningProcesses.get(file);
196+
if (runningProcess) {
197+
runningProcess.kill();
198+
await once(runningProcess, 'exit');
199+
}
200+
await runningSubtests.get(file);
201+
runningSubtests.set(file, runTestFile(file, root, inspectPort, filesWatcher));
202+
}, undefined, (error) => {
203+
triggerUncaughtException(error, true /* fromPromise */);
204+
}));
205+
});
206+
return filesWatcher;
207+
}
208+
168209
function run(options) {
169210
if (options === null || typeof options !== 'object') {
170211
options = kEmptyObject;
171212
}
172-
const { concurrency, timeout, signal, files, inspectPort } = options;
213+
const { concurrency, timeout, signal, files, inspectPort, watch } = options;
173214

174215
if (files != null) {
175216
validateArray(files, 'options.files');
176217
}
218+
if (watch != null) {
219+
validateBoolean(watch, 'options.watch');
220+
}
177221

178222
const root = createTestTree({ concurrency, timeout, signal });
179223
const testFiles = files ?? createTestFileList();
180224

181-
PromisePrototypeThen(SafePromiseAll(testFiles, (path) => runTestFile(path, root, inspectPort)),
182-
() => root.postRun());
225+
let postRun = () => root.postRun();
226+
let filesWatcher;
227+
if (watch) {
228+
filesWatcher = watchFiles(testFiles, root, inspectPort);
229+
postRun = undefined;
230+
}
231+
232+
PromisePrototypeThen(SafePromiseAllSettled(testFiles, (path) => {
233+
const subtest = runTestFile(path, root, inspectPort, filesWatcher);
234+
runningSubtests.set(path, subtest);
235+
return subtest;
236+
}), postRun);
237+
183238

184239
return root.reporter;
185240
}

lib/internal/watch_mode/files_watcher.js

+29-5
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ class FilesWatcher extends EventEmitter {
2626
#watchers = new SafeMap();
2727
#filteredFiles = new SafeSet();
2828
#throttling = new SafeSet();
29+
#depencencyOwners = new SafeMap();
30+
#ownerDependencies = new SafeMap();
2931
#throttle;
3032
#mode;
3133

@@ -74,7 +76,8 @@ class FilesWatcher extends EventEmitter {
7476
return;
7577
}
7678
this.#throttling.add(trigger);
77-
this.emit('changed');
79+
const owners = this.#depencencyOwners.get(trigger);
80+
this.emit('changed', { owners });
7881
setTimeout(() => this.#throttling.delete(trigger), this.#throttle).unref();
7982
}
8083

@@ -95,7 +98,7 @@ class FilesWatcher extends EventEmitter {
9598
}
9699
}
97100

98-
filterFile(file) {
101+
filterFile(file, owner) {
99102
if (!file) return;
100103
if (supportsRecursiveWatching) {
101104
this.watchPath(dirname(file));
@@ -105,31 +108,52 @@ class FilesWatcher extends EventEmitter {
105108
this.watchPath(file, false);
106109
}
107110
this.#filteredFiles.add(file);
111+
if (owner) {
112+
const owners = this.#depencencyOwners.get(file) ?? new SafeSet();
113+
const dependencies = this.#ownerDependencies.get(file) ?? new SafeSet();
114+
owners.add(owner);
115+
dependencies.add(file);
116+
this.#depencencyOwners.set(file, owners);
117+
this.#ownerDependencies.set(owner, dependencies);
118+
}
108119
}
109-
watchChildProcessModules(child) {
120+
watchChildProcessModules(child, key = null) {
110121
if (this.#mode !== 'filter') {
111122
return;
112123
}
113124
child.on('message', (message) => {
114125
try {
115126
if (ArrayIsArray(message['watch:require'])) {
116-
ArrayPrototypeForEach(message['watch:require'], (file) => this.filterFile(file));
127+
ArrayPrototypeForEach(message['watch:require'], (file) => this.filterFile(file, key));
117128
}
118129
if (ArrayIsArray(message['watch:import'])) {
119-
ArrayPrototypeForEach(message['watch:import'], (file) => this.filterFile(fileURLToPath(file)));
130+
ArrayPrototypeForEach(message['watch:import'], (file) => this.filterFile(fileURLToPath(file), key));
120131
}
121132
} catch {
122133
// Failed watching file. ignore
123134
}
124135
});
125136
}
137+
unfilterFilesOwnedBy(owners) {
138+
owners.forEach((owner) => {
139+
this.#ownerDependencies.get(owner)?.forEach((dependency) => {
140+
this.#filteredFiles.delete(dependency);
141+
this.#depencencyOwners.delete(dependency);
142+
});
143+
this.#filteredFiles.delete(owner);
144+
this.#depencencyOwners.delete(owner);
145+
this.#ownerDependencies.delete(owner);
146+
});
147+
}
126148
clearFileFilters() {
127149
this.#filteredFiles.clear();
128150
}
129151
clear() {
130152
this.#watchers.forEach(this.#unwatch);
131153
this.#watchers.clear();
132154
this.#filteredFiles.clear();
155+
this.#depencencyOwners.clear();
156+
this.#ownerDependencies.clear();
133157
}
134158
}
135159

src/node_options.cc

+3-3
Original file line numberDiff line numberDiff line change
@@ -156,9 +156,9 @@ void EnvironmentOptions::CheckOptions(std::vector<std::string>* errors) {
156156
errors->push_back("either --test or --interactive can be used, not both");
157157
}
158158

159-
if (watch_mode) {
160-
// TODO(MoLow): Support (incremental?) watch mode within test runner
161-
errors->push_back("either --test or --watch can be used, not both");
159+
if (watch_mode_paths.size() > 0) {
160+
errors->push_back(
161+
"--watch-path cannot be used in combination with --test");
162162
}
163163

164164
#ifndef ALLOW_ATTACHING_DEBUGGER_IN_TEST_RUNNER
+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
module.exports = {};
+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const a = 1;
+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
require('./dependency.js');
2+
import('./dependency.mjs');
3+
import('data:text/javascript,');
+46
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
// Flags: --expose-internals
2+
import '../common/index.mjs';
3+
import { describe, it } from 'node:test';
4+
import { spawn } from 'node:child_process';
5+
import { writeFileSync, readFileSync } from 'node:fs';
6+
import util from 'internal/util';
7+
import * as fixtures from '../common/fixtures.mjs';
8+
9+
async function testWatch({ files, fileToUpdate }) {
10+
const ran1 = util.createDeferredPromise();
11+
const ran2 = util.createDeferredPromise();
12+
const child = spawn(process.execPath, ['--watch', '--test', '--no-warnings', ...files], { encoding: 'utf8' });
13+
let stdout = '';
14+
child.stdout.on('data', (data) => {
15+
stdout += data.toString();
16+
if (/ok 2/.test(stdout)) ran1.resolve();
17+
if (/ok 3/.test(stdout)) ran2.resolve();
18+
});
19+
20+
await ran1.promise;
21+
writeFileSync(fileToUpdate, readFileSync(fileToUpdate, 'utf8'));
22+
await ran2.promise;
23+
child.kill();
24+
}
25+
26+
describe('test runner watch mode', () => {
27+
it('should run tests repeatedly', async () => {
28+
const file1 = fixtures.path('test-runner/index.test.js');
29+
const file2 = fixtures.path('test-runner/subdir/subdir_test.js');
30+
await testWatch({ files: [file1, file2], fileToUpdate: file2 });
31+
});
32+
33+
it('should run tests with dependency repeatedly', async () => {
34+
const file1 = fixtures.path('test-runner/index.test.js');
35+
const dependent = fixtures.path('test-runner/dependent.js');
36+
const dependency = fixtures.path('test-runner/dependency.js');
37+
await testWatch({ files: [file1, dependent], fileToUpdate: dependency });
38+
});
39+
40+
it('should run tests with ESM dependency', async () => {
41+
const file1 = fixtures.path('test-runner/index.test.js');
42+
const dependent = fixtures.path('test-runner/dependent.js');
43+
const dependency = fixtures.path('test-runner/dependency.mjs');
44+
await testWatch({ files: [file1, dependent], fileToUpdate: dependency });
45+
});
46+
});

0 commit comments

Comments
 (0)