Skip to content

Commit e9760b4

Browse files
MoLowruyadorno
authored andcommitted
test_runner: support watch mode
PR-URL: #45214 Reviewed-By: Benjamin Gruenbaum <benjamingr@gmail.com> Reviewed-By: Colin Ihrig <cjihrig@gmail.com> Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com>
1 parent d13ea68 commit e9760b4

File tree

10 files changed

+180
-20
lines changed

10 files changed

+180
-20
lines changed

doc/api/cli.md

+12-3
Original file line numberDiff line numberDiff line change
@@ -1208,11 +1208,16 @@ status code 1.
12081208
added:
12091209
- v18.1.0
12101210
- v16.17.0
1211+
changes:
1212+
- version: REPLACEME
1213+
pr-url: https://github.com/nodejs/node/pull/45214
1214+
description: Test runner now supports running in watch mode.
12111215
-->
12121216

12131217
Starts the Node.js command line test runner. This flag cannot be combined with
1214-
`--check`, `--eval`, `--interactive`, or the inspector. See the documentation
1215-
on [running tests from the command line][] for more details.
1218+
`--watch-path`, `--check`, `--eval`, `--interactive`, or the inspector.
1219+
See the documentation on [running tests from the command line][]
1220+
for more details.
12161221

12171222
### `--test-name-pattern`
12181223

@@ -1574,6 +1579,10 @@ will be chosen.
15741579

15751580
<!-- YAML
15761581
added: v18.11.0
1582+
changes:
1583+
- version: REPLACEME
1584+
pr-url: https://github.com/nodejs/node/pull/45214
1585+
description: Test runner now supports running in watch mode.
15771586
-->
15781587

15791588
> Stability: 1 - Experimental
@@ -1607,7 +1616,7 @@ This will turn off watching of required or imported modules, even when used in
16071616
combination with `--watch`.
16081617

16091618
This flag cannot be combined with
1610-
`--check`, `--eval`, `--interactive`, or the REPL.
1619+
`--check`, `--eval`, `--interactive`, `--test`, or the REPL.
16111620

16121621
```console
16131622
$ 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
@@ -3,6 +3,7 @@ const {
33
prepareMainThreadExecution,
44
markBootstrapComplete
55
} = require('internal/process/pre_execution');
6+
const { getOptionValue } = require('internal/options');
67
const { isUsingInspector } = require('internal/util/inspector');
78
const { run } = require('internal/test_runner/runner');
89
const { exitCodes: { kGenericUserError } } = internalBinding('errors');
@@ -20,7 +21,7 @@ if (isUsingInspector()) {
2021
inspectPort = process.debugPort;
2122
}
2223

23-
const tapStream = run({ concurrency, inspectPort });
24+
const tapStream = run({ concurrency, inspectPort, watch: getOptionValue('--watch') });
2425
tapStream.pipe(process.stdout);
2526
tapStream.once('test:fail', () => {
2627
process.exitCode = kGenericUserError;

lib/internal/test_runner/runner.js

+64-8
Original file line numberDiff line numberDiff line change
@@ -10,20 +10,24 @@ const {
1010
ObjectAssign,
1111
PromisePrototypeThen,
1212
SafePromiseAll,
13+
SafePromiseAllReturnVoid,
14+
SafePromiseAllSettledReturnVoid,
15+
SafeMap,
1316
SafeSet,
1417
} = primordials;
1518

1619
const { spawn } = require('child_process');
1720
const { readdirSync, statSync } = require('fs');
1821
// TODO(aduh95): switch to internal/readline/interface when backporting to Node.js 16.x is no longer a concern.
1922
const { createInterface } = require('readline');
23+
const { FilesWatcher } = require('internal/watch_mode/files_watcher');
2024
const console = require('internal/console/global');
2125
const {
2226
codes: {
2327
ERR_TEST_FAILURE,
2428
},
2529
} = require('internal/errors');
26-
const { validateArray } = require('internal/validators');
30+
const { validateArray, validateBoolean } = require('internal/validators');
2731
const { getInspectPort, isUsingInspector, isInspectorMessage } = require('internal/util/inspector');
2832
const { kEmptyObject } = require('internal/util');
2933
const { createTestTree } = require('internal/test_runner/harness');
@@ -34,9 +38,12 @@ const {
3438
} = require('internal/test_runner/utils');
3539
const { basename, join, resolve } = require('path');
3640
const { once } = require('events');
37-
const { exitCodes: { kGenericUserError } } = internalBinding('errors');
41+
const {
42+
triggerUncaughtException,
43+
exitCodes: { kGenericUserError },
44+
} = internalBinding('errors');
3845

39-
const kFilterArgs = ['--test'];
46+
const kFilterArgs = ['--test', '--watch'];
4047

4148
// TODO(cjihrig): Replace this with recursive readdir once it lands.
4249
function processPath(path, testFiles, options) {
@@ -113,17 +120,28 @@ function getRunArgs({ path, inspectPort }) {
113120
return argv;
114121
}
115122

123+
const runningProcesses = new SafeMap();
124+
const runningSubtests = new SafeMap();
116125

117-
function runTestFile(path, root, inspectPort) {
126+
function runTestFile(path, root, inspectPort, filesWatcher) {
118127
const subtest = root.createSubtest(Test, path, async (t) => {
119128
const args = getRunArgs({ path, inspectPort });
129+
const stdio = ['pipe', 'pipe', 'pipe'];
130+
const env = { ...process.env };
131+
if (filesWatcher) {
132+
stdio.push('ipc');
133+
env.WATCH_REPORT_DEPENDENCIES = '1';
134+
}
120135

121-
const child = spawn(process.execPath, args, { signal: t.signal, encoding: 'utf8' });
136+
const child = spawn(process.execPath, args, { signal: t.signal, encoding: 'utf8', env, stdio });
137+
runningProcesses.set(path, child);
122138
// TODO(cjihrig): Implement a TAP parser to read the child's stdout
123139
// instead of just displaying it all if the child fails.
124140
let err;
125141
let stderr = '';
126142

143+
filesWatcher?.watchChildProcessModules(child, path);
144+
127145
child.on('error', (error) => {
128146
err = error;
129147
});
@@ -146,6 +164,8 @@ function runTestFile(path, root, inspectPort) {
146164
child.stdout.toArray({ signal: t.signal }),
147165
]);
148166

167+
runningProcesses.delete(path);
168+
runningSubtests.delete(path);
149169
if (code !== 0 || signal !== null) {
150170
if (!err) {
151171
err = ObjectAssign(new ERR_TEST_FAILURE('test failed', kSubtestsFailed), {
@@ -166,21 +186,57 @@ function runTestFile(path, root, inspectPort) {
166186
return subtest.start();
167187
}
168188

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

175217
if (files != null) {
176218
validateArray(files, 'options.files');
177219
}
220+
if (watch != null) {
221+
validateBoolean(watch, 'options.watch');
222+
}
178223

179224
const root = createTestTree({ concurrency, timeout, signal });
180225
const testFiles = files ?? createTestFileList();
181226

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

185241
return root.reporter;
186242
}

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
@@ -152,9 +152,9 @@ void EnvironmentOptions::CheckOptions(std::vector<std::string>* errors,
152152
errors->push_back("either --test or --interactive can be used, not both");
153153
}
154154

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

160160
#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)