GeezerVison is showing github.com — test_runner: fix support watch with run(), add globPatterns option · nodejs/node@893c864
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/test.md +6Lines changed: 6 additions & 0 deletions - Display the source diff
- Display the rich diff
| Original file line number | Diff line number | Diff line change |
|---|
@@ -1239,6 +1239,9 @@ added: | 1239 | 1239 | - v18.9.0 | 1240 | 1240 | - v16.19.0 | 1241 | 1241 | changes: | | 1242 | + - version: REPLACEME | | 1243 | + pr-url: https://github.com/nodejs/node/pull/53866 | | 1244 | + description: Added the `globPatterns` option. | 1242 | 1245 | - version: v22.0.0 | 1243 | 1246 | pr-url: https://github.com/nodejs/node/pull/52038 | 1244 | 1247 | description: Added the `forceExit` option. | @@ -1263,6 +1266,9 @@ changes: | 1263 | 1266 | * `forceExit`: {boolean} Configures the test runner to exit the process once | 1264 | 1267 | all known tests have finished executing even if the event loop would | 1265 | 1268 | otherwise remain active. **Default:** `false`. | | 1269 | + * `globPatterns`: {Array} An array containing the list of glob patterns to | | 1270 | + match test files. This option cannot be used together with `files`. | | 1271 | + **Default:** matching files from [test runner execution model][]. | 1266 | 1272 | * `inspectPort` {number|Function} Sets inspector port of test child process. | 1267 | 1273 | This can be a number, or a function that takes no arguments and returns a | 1268 | 1274 | number. If a nullish value is provided, each process gets its own port, | |
Collapse file Copy file name to clipboardExpand all lines: lib/internal/main/test_runner.js +5Lines changed: 5 additions & 0 deletions | Original file line number | Diff line number | Diff line change |
|---|
| 1 | 1 | 'use strict'; | 2 | 2 |
| | 3 | +const { | | 4 | + ArrayPrototypeSlice, | | 5 | +} = primordials; | | 6 | + | 3 | 7 | const { | 4 | 8 | prepareMainThreadExecution, | 5 | 9 | markBootstrapComplete, | @@ -42,6 +46,7 @@ const options = { | 42 | 46 | setup: setupTestReporters, | 43 | 47 | timeout: perFileTimeout, | 44 | 48 | shard, | | 49 | + globPatterns: ArrayPrototypeSlice(process.argv, 1), | 45 | 50 | }; | 46 | 51 | debug('test runner configuration:', options); | 47 | 52 | run(options).on('test:fail', (data) => { | |
Collapse file Copy file name to clipboardExpand all lines: lib/internal/test_runner/runner.js +22-8Lines changed: 22 additions & 8 deletions | Original file line number | Diff line number | Diff line change |
|---|
| 1 | 1 | 'use strict'; | | 2 | + | 2 | 3 | const { | 3 | 4 | ArrayIsArray, | 4 | 5 | ArrayPrototypeEvery, | @@ -10,7 +11,6 @@ const { | 10 | 11 | ArrayPrototypeMap, | 11 | 12 | ArrayPrototypePush, | 12 | 13 | ArrayPrototypeShift, | 13 | | - ArrayPrototypeSlice, | 14 | 14 | ArrayPrototypeSome, | 15 | 15 | ArrayPrototypeSort, | 16 | 16 | ObjectAssign, | @@ -87,10 +87,12 @@ const kCanceledTests = new SafeSet() | 87 | 87 |
| 88 | 88 | let kResistStopPropagation; | 89 | 89 |
| 90 | | -function createTestFileList() { | | 90 | +function createTestFileList(patterns) { | 91 | 91 | const cwd = process.cwd(); | 92 | | - const hasUserSuppliedPattern = process.argv.length > 1; | 93 | | - const patterns = hasUserSuppliedPattern ? ArrayPrototypeSlice(process.argv, 1) : [kDefaultPattern]; | | 92 | + const hasUserSuppliedPattern = patterns != null; | | 93 | + if (!patterns || patterns.length === 0) { | | 94 | + patterns = [kDefaultPattern]; | | 95 | + } | 94 | 96 | const glob = new Glob(patterns, { | 95 | 97 | __proto__: null, | 96 | 98 | cwd, | @@ -344,7 +346,6 @@ function runTestFile(path, filesWatcher, opts) { | 344 | 346 |
| 345 | 347 | let err; | 346 | 348 |
| 347 | | - | 348 | 349 | child.on('error', (error) => { | 349 | 350 | err = error; | 350 | 351 | }); | @@ -418,8 +419,8 @@ function watchFiles(testFiles, opts) { | 418 | 419 | opts.root.harness.watching = true; | 419 | 420 |
| 420 | 421 | watcher.on('changed', ({ owners, eventType }) => { | 421 | | - if (eventType === 'rename') { | 422 | | - const updatedTestFiles = createTestFileList(); | | 422 | + if (!opts.hasFiles && eventType === 'rename') { | | 423 | + const updatedTestFiles = createTestFileList(opts.globPatterns); | 423 | 424 |
| 424 | 425 | const newFileName = ArrayPrototypeFind(updatedTestFiles, (x) => !ArrayPrototypeIncludes(testFiles, x)); | 425 | 426 | const previousFileName = ArrayPrototypeFind(testFiles, (x) => !ArrayPrototypeIncludes(updatedTestFiles, x)); | @@ -482,6 +483,7 @@ function run(options = kEmptyObject) { | 482 | 483 | watch, | 483 | 484 | setup, | 484 | 485 | only, | | 486 | + globPatterns, | 485 | 487 | } = options; | 486 | 488 |
| 487 | 489 | if (files != null) { | @@ -502,6 +504,16 @@ function run(options = kEmptyObject) { | 502 | 504 | if (only != null) { | 503 | 505 | validateBoolean(only, 'options.only'); | 504 | 506 | } | | 507 | + if (globPatterns != null) { | | 508 | + validateArray(globPatterns, 'options.globPatterns'); | | 509 | + } | | 510 | + | | 511 | + if (globPatterns?.length > 0 && files?.length > 0) { | | 512 | + throw new ERR_INVALID_ARG_VALUE( | | 513 | + 'options.globPatterns', globPatterns, 'is not supported when specifying \'options.files\'', | | 514 | + ); | | 515 | + } | | 516 | + | 505 | 517 | if (shard != null) { | 506 | 518 | validateObject(shard, 'options.shard'); | 507 | 519 | // Avoid re-evaluating the shard object in case it's a getter | @@ -557,7 +569,7 @@ function run(options = kEmptyObject) { | 557 | 569 | root.postRun(); | 558 | 570 | return root.reporter; | 559 | 571 | } | 560 | | - let testFiles = files ?? createTestFileList(); | | 572 | + let testFiles = files ?? createTestFileList(globPatterns); | 561 | 573 |
| 562 | 574 | if (shard) { | 563 | 575 | testFiles = ArrayPrototypeFilter(testFiles, (_, index) => index % shard.total === shard.index - 1); | @@ -573,6 +585,8 @@ function run(options = kEmptyObject) { | 573 | 585 | inspectPort, | 574 | 586 | testNamePatterns, | 575 | 587 | testSkipPatterns, | | 588 | + hasFiles: files != null, | | 589 | + globPatterns, | 576 | 590 | only, | 577 | 591 | forceExit, | 578 | 592 | }; | |
Collapse file Copy file name to clipboard +24Lines changed: 24 additions & 0 deletions | Original file line number | Diff line number | Diff line change |
|---|
| | 1 | +import { run } from 'node:test'; | | 2 | +import { tap } from 'node:test/reporters'; | | 3 | +import { parseArgs } from 'node:util'; | | 4 | + | | 5 | +const options = { | | 6 | + file: { | | 7 | + type: 'string', | | 8 | + }, | | 9 | +}; | | 10 | +const { | | 11 | + values, | | 12 | + positionals, | | 13 | +} = parseArgs({ args: process.argv.slice(2), options }); | | 14 | + | | 15 | +let files; | | 16 | + | | 17 | +if (values.file) { | | 18 | + files = [values.file]; | | 19 | +} | | 20 | + | | 21 | +run({ | | 22 | + files, | | 23 | + watch: true | | 24 | +}).compose(tap).pipe(process.stdout); |
Collapse file Copy file name to clipboard +61Lines changed: 61 additions & 0 deletions | Original file line number | Diff line number | Diff line change |
|---|
| | 1 | +import * as common from '../common/index.mjs'; | | 2 | +import tmpdir from '../common/tmpdir.js'; | | 3 | +import { describe, it, run, beforeEach } from 'node:test'; | | 4 | +import { dot, spec, tap } from 'node:test/reporters'; | | 5 | +import { fork } from 'node:child_process'; | | 6 | +import assert from 'node:assert'; | | 7 | + | | 8 | +if (common.hasCrypto) { | | 9 | + console.log('1..0 # Skipped: no crypto'); | | 10 | + process.exit(0); | | 11 | +} | | 12 | + | | 13 | +if (process.env.CHILD === 'true') { | | 14 | + describe('require(\'node:test\').run with no files', { concurrency: true }, () => { | | 15 | + beforeEach(() => { | | 16 | + tmpdir.refresh(); | | 17 | + process.chdir(tmpdir.path); | | 18 | + }); | | 19 | + | | 20 | + it('should neither pass or fail', async () => { | | 21 | + const stream = run({ | | 22 | + files: undefined | | 23 | + }).compose(tap); | | 24 | + stream.on('test:fail', common.mustNotCall()); | | 25 | + stream.on('test:pass', common.mustNotCall()); | | 26 | + | | 27 | + // eslint-disable-next-line no-unused-vars | | 28 | + for await (const _ of stream); | | 29 | + }); | | 30 | + | | 31 | + it('can use the spec reporter', async () => { | | 32 | + const stream = run({ | | 33 | + files: undefined | | 34 | + }).compose(spec); | | 35 | + stream.on('test:fail', common.mustNotCall()); | | 36 | + stream.on('test:pass', common.mustNotCall()); | | 37 | + | | 38 | + // eslint-disable-next-line no-unused-vars | | 39 | + for await (const _ of stream); | | 40 | + }); | | 41 | + | | 42 | + it('can use the dot reporter', async () => { | | 43 | + const stream = run({ | | 44 | + files: undefined | | 45 | + }).compose(dot); | | 46 | + stream.on('test:fail', common.mustNotCall()); | | 47 | + stream.on('test:pass', common.mustNotCall()); | | 48 | + | | 49 | + // eslint-disable-next-line no-unused-vars | | 50 | + for await (const _ of stream); | | 51 | + }); | | 52 | + }); | | 53 | +} else if (common.isAIX) { | | 54 | + console.log('1..0 # Skipped: test runner without specifying files fails on AIX'); | | 55 | +} else { | | 56 | + fork(import.meta.filename, [], { | | 57 | + env: { CHILD: 'true' } | | 58 | + }).on('exit', common.mustCall((code) => { | | 59 | + assert.strictEqual(code, 0); | | 60 | + })); | | 61 | +} |
Collapse file Copy file name to clipboard +175Lines changed: 175 additions & 0 deletions | Original file line number | Diff line number | Diff line change |
|---|
| | 1 | +// Flags: --expose-internals | | 2 | +import * as common from '../common/index.mjs'; | | 3 | +import { describe, it, beforeEach } from 'node:test'; | | 4 | +import assert from 'node:assert'; | | 5 | +import { spawn } from 'node:child_process'; | | 6 | +import { once } from 'node:events'; | | 7 | +import { writeFileSync, renameSync, unlinkSync, existsSync } from 'node:fs'; | | 8 | +import util from 'internal/util'; | | 9 | +import tmpdir from '../common/tmpdir.js'; | | 10 | +import { join } from 'node:path'; | | 11 | + | | 12 | +if (common.isIBMi) | | 13 | + common.skip('IBMi does not support `fs.watch()`'); | | 14 | + | | 15 | +// This test updates these files repeatedly, | | 16 | +// Reading them from disk is unreliable due to race conditions. | | 17 | +const fixtureContent = { | | 18 | + 'dependency.js': 'module.exports = {};', | | 19 | + 'dependency.mjs': 'export const a = 1;', | | 20 | + 'test.js': ` | | 21 | +const test = require('node:test'); | | 22 | +require('./dependency.js'); | | 23 | +import('./dependency.mjs'); | | 24 | +import('data:text/javascript,'); | | 25 | +test('test has ran');`, | | 26 | +}; | | 27 | + | | 28 | +let fixturePaths; | | 29 | + | | 30 | +function refresh() { | | 31 | + tmpdir.refresh(); | | 32 | + | | 33 | + fixturePaths = Object.keys(fixtureContent) | | 34 | + .reduce((acc, file) => ({ ...acc, [file]: tmpdir.resolve(file) }), {}); | | 35 | + Object.entries(fixtureContent) | | 36 | + .forEach(([file, content]) => writeFileSync(fixturePaths[file], content)); | | 37 | +} | | 38 | + | | 39 | +const runner = join(import.meta.dirname, '..', 'fixtures', 'test-runner-watch.mjs'); | | 40 | + | | 41 | +async function testWatch({ fileToUpdate, file, action = 'update', cwd = tmpdir.path }) { | | 42 | + const ran1 = util.createDeferredPromise(); | | 43 | + const ran2 = util.createDeferredPromise(); | | 44 | + const args = [runner]; | | 45 | + if (file) args.push('--file', file); | | 46 | + const child = spawn(process.execPath, | | 47 | + args, | | 48 | + { encoding: 'utf8', stdio: 'pipe', cwd }); | | 49 | + let stdout = ''; | | 50 | + let currentRun = ''; | | 51 | + const runs = []; | | 52 | + | | 53 | + child.stdout.on('data', (data) => { | | 54 | + stdout += data.toString(); | | 55 | + currentRun += data.toString(); | | 56 | + const testRuns = stdout.match(/# duration_ms\s\d+/g); | | 57 | + if (testRuns?.length >= 1) ran1.resolve(); | | 58 | + if (testRuns?.length >= 2) ran2.resolve(); | | 59 | + }); | | 60 | + | | 61 | + const testUpdate = async () => { | | 62 | + await ran1.promise; | | 63 | + const content = fixtureContent[fileToUpdate]; | | 64 | + const path = fixturePaths[fileToUpdate]; | | 65 | + const interval = setInterval(() => writeFileSync(path, content), common.platformTimeout(1000)); | | 66 | + await ran2.promise; | | 67 | + runs.push(currentRun); | | 68 | + clearInterval(interval); | | 69 | + child.kill(); | | 70 | + await once(child, 'exit'); | | 71 | + for (const run of runs) { | | 72 | + assert.doesNotMatch(run, /run\(\) is being called recursively/); | | 73 | + assert.match(run, /# tests 1/); | | 74 | + assert.match(run, /# pass 1/); | | 75 | + assert.match(run, /# fail 0/); | | 76 | + assert.match(run, /# cancelled 0/); | | 77 | + } | | 78 | + }; | | 79 | + | | 80 | + const testRename = async () => { | | 81 | + await ran1.promise; | | 82 | + const fileToRenamePath = tmpdir.resolve(fileToUpdate); | | 83 | + const newFileNamePath = tmpdir.resolve(`test-renamed-${fileToUpdate}`); | | 84 | + const interval = setInterval(() => renameSync(fileToRenamePath, newFileNamePath), common.platformTimeout(1000)); | | 85 | + await ran2.promise; | | 86 | + runs.push(currentRun); | | 87 | + clearInterval(interval); | | 88 | + child.kill(); | | 89 | + await once(child, 'exit'); | | 90 | + | | 91 | + for (const run of runs) { | | 92 | + assert.doesNotMatch(run, /run\(\) is being called recursively/); | | 93 | + if (action === 'rename2') { | | 94 | + assert.match(run, /MODULE_NOT_FOUND/); | | 95 | + } else { | | 96 | + assert.doesNotMatch(run, /MODULE_NOT_FOUND/); | | 97 | + } | | 98 | + assert.match(run, /# tests 1/); | | 99 | + assert.match(run, /# pass 1/); | | 100 | + assert.match(run, /# fail 0/); | | 101 | + assert.match(run, /# cancelled 0/); | | 102 | + } | | 103 | + }; | | 104 | + | | 105 | + const testDelete = async () => { | | 106 | + await ran1.promise; | | 107 | + const fileToDeletePath = tmpdir.resolve(fileToUpdate); | | 108 | + const interval = setInterval(() => { | | 109 | + if (existsSync(fileToDeletePath)) { | | 110 | + unlinkSync(fileToDeletePath); | | 111 | + } else { | | 112 | + ran2.resolve(); | | 113 | + } | | 114 | + }, common.platformTimeout(1000)); | | 115 | + await ran2.promise; | | 116 | + runs.push(currentRun); | | 117 | + clearInterval(interval); | | 118 | + child.kill(); | | 119 | + await once(child, 'exit'); | | 120 | + | | 121 | + for (const run of runs) { | | 122 | + assert.doesNotMatch(run, /MODULE_NOT_FOUND/); | | 123 | + } | | 124 | + }; | | 125 | + | | 126 | + action === 'update' && await testUpdate(); | | 127 | + action === 'rename' && await testRename(); | | 128 | + action === 'rename2' && await testRename(); | | 129 | + action === 'delete' && await testDelete(); | | 130 | +} | | 131 | + | | 132 | +describe('test runner watch mode', () => { | | 133 | + beforeEach(refresh); | | 134 | + it('should run tests repeatedly', async () => { | | 135 | + await testWatch({ file: 'test.js', fileToUpdate: 'test.js' }); | | 136 | + }); | | 137 | + | | 138 | + it('should run tests with dependency repeatedly', async () => { | | 139 | + await testWatch({ file: 'test.js', fileToUpdate: 'dependency.js' }); | | 140 | + }); | | 141 | + | | 142 | + it('should run tests with ESM dependency', async () => { | | 143 | + await testWatch({ file: 'test.js', fileToUpdate: 'dependency.mjs' }); | | 144 | + }); | | 145 | + | | 146 | + it('should support running tests without a file', async () => { | | 147 | + await testWatch({ fileToUpdate: 'test.js' }); | | 148 | + }); | | 149 | + | | 150 | + it('should support a watched test file rename', async () => { | | 151 | + await testWatch({ fileToUpdate: 'test.js', action: 'rename' }); | | 152 | + }); | | 153 | + | | 154 | + it('should not throw when deleting a watched test file', { skip: common.isAIX }, async () => { | | 155 | + await testWatch({ fileToUpdate: 'test.js', action: 'delete' }); | | 156 | + }); | | 157 | + | | 158 | + it('should run tests with dependency repeatedly in a different cwd', async () => { | | 159 | + await testWatch({ | | 160 | + file: join(tmpdir.path, 'test.js'), | | 161 | + fileToUpdate: 'dependency.js', | | 162 | + cwd: import.meta.dirname, | | 163 | + action: 'rename2' | | 164 | + }); | | 165 | + }); | | 166 | + | | 167 | + it('should handle renames in a different cwd', async () => { | | 168 | + await testWatch({ | | 169 | + file: join(tmpdir.path, 'test.js'), | | 170 | + fileToUpdate: 'test.js', | | 171 | + cwd: import.meta.dirname, | | 172 | + action: 'rename2' | | 173 | + }); | | 174 | + }); | | 175 | +}); |
|