| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
| 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 | |
| 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 | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -3,6 +3,7 @@ const { | |||
| 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; | |
| 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 | } | |
| 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 | ||
| 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 | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -0,0 +1 @@ | |||
| 1 | + module.exports = {}; | ||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -0,0 +1 @@ | |||
| 1 | + export const a = 1; | ||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -0,0 +1,3 @@ | |||
| 1 | + require('./dependency.js'); | ||
| 2 | + import('./dependency.mjs'); | ||
| 3 | + import('data:text/javascript,'); | ||
| Original file line number | Diff line number | Diff 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 | + }); | ||
| Back | FazBrowse Home | New Git URL |
0 commit comments