GeezerVison is showing github.com — test_runner: support watch mode · nodejs/node@e9760b4
File treeExpand file treeCollapse file tree Open diff view settings Expand file treeCollapse file tree Open diff view settings Collapse file Copy file name to clipboardExpand all lines: doc/api/cli.md +12-3Lines changed: 12 additions & 3 deletions - Display the source diff
- Display the rich diff
| Original file line number | Diff line number | Diff line change |
|---|
@@ -1208,11 +1208,16 @@ status code 1. | 1208 | 1208 | added: | 1209 | 1209 | - v18.1.0 | 1210 | 1210 | - 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. | 1211 | 1215 | --> | 1212 | 1216 |
| 1213 | 1217 | 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. | 1216 | 1221 |
| 1217 | 1222 | ### `--test-name-pattern` | 1218 | 1223 |
| @@ -1574,6 +1579,10 @@ will be chosen. | 1574 | 1579 |
| 1575 | 1580 | <!-- YAML | 1576 | 1581 | 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. | 1577 | 1586 | --> | 1578 | 1587 |
| 1579 | 1588 | > Stability: 1 - Experimental | @@ -1607,7 +1616,7 @@ This will turn off watching of required or imported modules, even when used in | 1607 | 1616 | combination with `--watch`. | 1608 | 1617 |
| 1609 | 1618 | This flag cannot be combined with | 1610 | | -`--check`, `--eval`, `--interactive`, or the REPL. | | 1619 | +`--check`, `--eval`, `--interactive`, `--test`, or the REPL. | 1611 | 1620 |
| 1612 | 1621 | ```console | 1613 | 1622 | $ node --watch-path=./src --watch-path=./tests index.js | |
Collapse file Copy file name to clipboardExpand all lines: doc/api/test.md +19Lines changed: 19 additions & 0 deletions - Display the source diff
- Display the rich diff
| Original file line number | Diff line number | Diff line change |
|---|
@@ -291,6 +291,25 @@ test('a test that creates asynchronous activity', (t) => { | 291 | 291 | }); | 292 | 292 | ``` | 293 | 293 |
| | 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 | + | 294 | 313 | ## Running tests from the command line | 295 | 314 |
| 296 | 315 | The Node.js test runner can be invoked from the command line by passing the | |
Collapse file Copy file name to clipboardExpand all lines: lib/internal/main/test_runner.js +2-1Lines changed: 2 additions & 1 deletion | Original file line number | Diff line number | Diff line change |
|---|
| 3 | 3 | prepareMainThreadExecution, | 4 | 4 | markBootstrapComplete | 5 | 5 | } = require('internal/process/pre_execution'); | | 6 | +const { getOptionValue } = require('internal/options'); | 6 | 7 | const { isUsingInspector } = require('internal/util/inspector'); | 7 | 8 | const { run } = require('internal/test_runner/runner'); | 8 | 9 | const { exitCodes: { kGenericUserError } } = internalBinding('errors'); | @@ -20,7 +21,7 @@ if (isUsingInspector()) { | 20 | 21 | inspectPort = process.debugPort; | 21 | 22 | } | 22 | 23 |
| 23 | | -const tapStream = run({ concurrency, inspectPort }); | | 24 | +const tapStream = run({ concurrency, inspectPort, watch: getOptionValue('--watch') }); | 24 | 25 | tapStream.pipe(process.stdout); | 25 | 26 | tapStream.once('test:fail', () => { | 26 | 27 | process.exitCode = kGenericUserError; | |
Collapse file Copy file name to clipboardExpand all lines: lib/internal/test_runner/runner.js +64-8Lines changed: 64 additions & 8 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -10,20 +10,24 @@ const { | 10 | 10 | ObjectAssign, | 11 | 11 | PromisePrototypeThen, | 12 | 12 | SafePromiseAll, | | 13 | + SafePromiseAllReturnVoid, | | 14 | + SafePromiseAllSettledReturnVoid, | | 15 | + SafeMap, | 13 | 16 | SafeSet, | 14 | 17 | } = primordials; | 15 | 18 |
| 16 | 19 | const { spawn } = require('child_process'); | 17 | 20 | const { readdirSync, statSync } = require('fs'); | 18 | 21 | // TODO(aduh95): switch to internal/readline/interface when backporting to Node.js 16.x is no longer a concern. | 19 | 22 | const { createInterface } = require('readline'); | | 23 | +const { FilesWatcher } = require('internal/watch_mode/files_watcher'); | 20 | 24 | const console = require('internal/console/global'); | 21 | 25 | const { | 22 | 26 | codes: { | 23 | 27 | ERR_TEST_FAILURE, | 24 | 28 | }, | 25 | 29 | } = require('internal/errors'); | 26 | | -const { validateArray } = require('internal/validators'); | | 30 | +const { validateArray, validateBoolean } = require('internal/validators'); | 27 | 31 | const { getInspectPort, isUsingInspector, isInspectorMessage } = require('internal/util/inspector'); | 28 | 32 | const { kEmptyObject } = require('internal/util'); | 29 | 33 | const { createTestTree } = require('internal/test_runner/harness'); | @@ -34,9 +38,12 @@ const { | 34 | 38 | } = require('internal/test_runner/utils'); | 35 | 39 | const { basename, join, resolve } = require('path'); | 36 | 40 | const { once } = require('events'); | 37 | | -const { exitCodes: { kGenericUserError } } = internalBinding('errors'); | | 41 | +const { | | 42 | + triggerUncaughtException, | | 43 | + exitCodes: { kGenericUserError }, | | 44 | +} = internalBinding('errors'); | 38 | 45 |
| 39 | | -const kFilterArgs = ['--test']; | | 46 | +const kFilterArgs = ['--test', '--watch']; | 40 | 47 |
| 41 | 48 | // TODO(cjihrig): Replace this with recursive readdir once it lands. | 42 | 49 | function processPath(path, testFiles, options) { | @@ -113,17 +120,28 @@ function getRunArgs({ path, inspectPort }) { | 113 | 120 | return argv; | 114 | 121 | } | 115 | 122 |
| | 123 | +const runningProcesses = new SafeMap(); | | 124 | +const runningSubtests = new SafeMap(); | 116 | 125 |
| 117 | | -function runTestFile(path, root, inspectPort) { | | 126 | +function runTestFile(path, root, inspectPort, filesWatcher) { | 118 | 127 | const subtest = root.createSubtest(Test, path, async (t) => { | 119 | 128 | 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 | + } | 120 | 135 |
| 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); | 122 | 138 | // TODO(cjihrig): Implement a TAP parser to read the child's stdout | 123 | 139 | // instead of just displaying it all if the child fails. | 124 | 140 | let err; | 125 | 141 | let stderr = ''; | 126 | 142 |
| | 143 | + filesWatcher?.watchChildProcessModules(child, path); | | 144 | + | 127 | 145 | child.on('error', (error) => { | 128 | 146 | err = error; | 129 | 147 | }); | @@ -146,6 +164,8 @@ function runTestFile(path, root, inspectPort) { | 146 | 164 | child.stdout.toArray({ signal: t.signal }), | 147 | 165 | ]); | 148 | 166 |
| | 167 | + runningProcesses.delete(path); | | 168 | + runningSubtests.delete(path); | 149 | 169 | if (code !== 0 || signal !== null) { | 150 | 170 | if (!err) { | 151 | 171 | err = ObjectAssign(new ERR_TEST_FAILURE('test failed', kSubtestsFailed), { | @@ -166,21 +186,57 @@ function runTestFile(path, root, inspectPort) { | 166 | 186 | return subtest.start(); | 167 | 187 | } | 168 | 188 |
| | 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 | + | 169 | 211 | function run(options) { | 170 | 212 | if (options === null || typeof options !== 'object') { | 171 | 213 | options = kEmptyObject; | 172 | 214 | } | 173 | | - const { concurrency, timeout, signal, files, inspectPort } = options; | | 215 | + const { concurrency, timeout, signal, files, inspectPort, watch } = options; | 174 | 216 |
| 175 | 217 | if (files != null) { | 176 | 218 | validateArray(files, 'options.files'); | 177 | 219 | } | | 220 | + if (watch != null) { | | 221 | + validateBoolean(watch, 'options.watch'); | | 222 | + } | 178 | 223 |
| 179 | 224 | const root = createTestTree({ concurrency, timeout, signal }); | 180 | 225 | const testFiles = files ?? createTestFileList(); | 181 | 226 |
| 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 | + | 184 | 240 |
| 185 | 241 | return root.reporter; | 186 | 242 | } | |
Collapse file Copy file name to clipboardExpand all lines: lib/internal/watch_mode/files_watcher.js +29-5Lines changed: 29 additions & 5 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -26,6 +26,8 @@ class FilesWatcher extends EventEmitter { | 26 | 26 | #watchers = new SafeMap(); | 27 | 27 | #filteredFiles = new SafeSet(); | 28 | 28 | #throttling = new SafeSet(); | | 29 | + #depencencyOwners = new SafeMap(); | | 30 | + #ownerDependencies = new SafeMap(); | 29 | 31 | #throttle; | 30 | 32 | #mode; | 31 | 33 |
| @@ -74,7 +76,8 @@ class FilesWatcher extends EventEmitter { | 74 | 76 | return; | 75 | 77 | } | 76 | 78 | this.#throttling.add(trigger); | 77 | | - this.emit('changed'); | | 79 | + const owners = this.#depencencyOwners.get(trigger); | | 80 | + this.emit('changed', { owners }); | 78 | 81 | setTimeout(() => this.#throttling.delete(trigger), this.#throttle).unref(); | 79 | 82 | } | 80 | 83 |
| @@ -95,7 +98,7 @@ class FilesWatcher extends EventEmitter { | 95 | 98 | } | 96 | 99 | } | 97 | 100 |
| 98 | | - filterFile(file) { | | 101 | + filterFile(file, owner) { | 99 | 102 | if (!file) return; | 100 | 103 | if (supportsRecursiveWatching) { | 101 | 104 | this.watchPath(dirname(file)); | @@ -105,31 +108,52 @@ class FilesWatcher extends EventEmitter { | 105 | 108 | this.watchPath(file, false); | 106 | 109 | } | 107 | 110 | 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 | + } | 108 | 119 | } | 109 | | - watchChildProcessModules(child) { | | 120 | + watchChildProcessModules(child, key = null) { | 110 | 121 | if (this.#mode !== 'filter') { | 111 | 122 | return; | 112 | 123 | } | 113 | 124 | child.on('message', (message) => { | 114 | 125 | try { | 115 | 126 | if (ArrayIsArray(message['watch:require'])) { | 116 | | - ArrayPrototypeForEach(message['watch:require'], (file) => this.filterFile(file)); | | 127 | + ArrayPrototypeForEach(message['watch:require'], (file) => this.filterFile(file, key)); | 117 | 128 | } | 118 | 129 | 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)); | 120 | 131 | } | 121 | 132 | } catch { | 122 | 133 | // Failed watching file. ignore | 123 | 134 | } | 124 | 135 | }); | 125 | 136 | } | | 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 | + } | 126 | 148 | clearFileFilters() { | 127 | 149 | this.#filteredFiles.clear(); | 128 | 150 | } | 129 | 151 | clear() { | 130 | 152 | this.#watchers.forEach(this.#unwatch); | 131 | 153 | this.#watchers.clear(); | 132 | 154 | this.#filteredFiles.clear(); | | 155 | + this.#depencencyOwners.clear(); | | 156 | + this.#ownerDependencies.clear(); | 133 | 157 | } | 134 | 158 | } | 135 | 159 |
| |
Collapse file Copy file name to clipboardExpand all lines: src/node_options.cc +3-3Lines changed: 3 additions & 3 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -152,9 +152,9 @@ void EnvironmentOptions::CheckOptions(std::vector<std::string>* errors, | 152 | 152 | errors->push_back("either --test or --interactive can be used, not both"); | 153 | 153 | } | 154 | 154 |
| 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"); | 158 | 158 | } | 159 | 159 |
| 160 | 160 | #ifndef ALLOW_ATTACHING_DEBUGGER_IN_TEST_RUNNER | |
Collapse file Copy file name to clipboard +1Lines changed: 1 addition & 0 deletions | Original file line number | Diff line number | Diff line change |
|---|
| | 1 | +module.exports = {}; |
Collapse file Copy file name to clipboard +1Lines changed: 1 addition & 0 deletions | Original file line number | Diff line number | Diff line change |
|---|
| | 1 | +export const a = 1; |
Collapse file Copy file name to clipboard +3Lines changed: 3 additions & 0 deletions | Original file line number | Diff line number | Diff line change |
|---|
| | 1 | +require('./dependency.js'); | | 2 | +import('./dependency.mjs'); | | 3 | +import('data:text/javascript,'); |
Collapse file Copy file name to clipboard +46Lines changed: 46 additions & 0 deletions | Original file line number | Diff line number | Diff line change |
|---|
| | 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 | +}); |
|