Skip to content

Commit 55b64e0

Browse files
MoLowdanielleadams
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 404172b commit 55b64e0

File tree

10 files changed

+179
-19
lines changed

10 files changed

+179
-19
lines changed

doc/api/cli.md

+12-3
Original file line numberDiff line numberDiff line change
@@ -1198,11 +1198,16 @@ status code 1.
11981198

11991199
<!-- YAML
12001200
added: v18.1.0
1201+
changes:
1202+
- version: REPLACEME
1203+
pr-url: https://github.com/nodejs/node/pull/45214
1204+
description: Test runner now supports running in watch mode.
12011205
-->
12021206

12031207
Starts the Node.js command line test runner. This flag cannot be combined with
1204-
`--check`, `--eval`, `--interactive`, or the inspector. See the documentation
1205-
on [running tests from the command line][] for more details.
1208+
`--watch-path`, `--check`, `--eval`, `--interactive`, or the inspector.
1209+
See the documentation on [running tests from the command line][]
1210+
for more details.
12061211

12071212
### `--test-name-pattern`
12081213

@@ -1560,6 +1565,10 @@ will be chosen.
15601565

15611566
<!-- YAML
15621567
added: v18.11.0
1568+
changes:
1569+
- version: REPLACEME
1570+
pr-url: https://github.com/nodejs/node/pull/45214
1571+
description: Test runner now supports running in watch mode.
15631572
-->
15641573

15651574
> Stability: 1 - Experimental
@@ -1593,7 +1602,7 @@ This will turn off watching of required or imported modules, even when used in
15931602
combination with `--watch`.
15941603

15951604
This flag cannot be combined with
1596-
`--check`, `--eval`, `--interactive`, or the REPL.
1605+
`--check`, `--eval`, `--interactive`, `--test`, or the REPL.
15971606

15981607
```console
15991608
$ 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

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

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

lib/internal/test_runner/runner.js

+63-7
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,8 +38,11 @@ const {
3438
} = require('internal/test_runner/utils');
3539
const { basename, join, resolve } = require('path');
3640
const { once } = require('events');
41+
const {
42+
triggerUncaughtException,
43+
} = internalBinding('errors');
3744

38-
const kFilterArgs = ['--test'];
45+
const kFilterArgs = ['--test', '--watch'];
3946

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

122+
const runningProcesses = new SafeMap();
123+
const runningSubtests = new SafeMap();
115124

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

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

142+
filesWatcher?.watchChildProcessModules(child, path);
143+
126144
child.on('error', (error) => {
127145
err = error;
128146
});
@@ -145,6 +163,8 @@ function runTestFile(path, root, inspectPort) {
145163
child.stdout.toArray({ signal: t.signal }),
146164
]);
147165

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

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

174216
if (files != null) {
175217
validateArray(files, 'options.files');
176218
}
219+
if (watch != null) {
220+
validateBoolean(watch, 'options.watch');
221+
}
177222

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

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

184240
return root.reporter;
185241
}

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

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

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