-
-
Notifications
You must be signed in to change notification settings - Fork 32k
test_runner: fix support watch with run(), add globPatterns option #53866
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
70074ab
61675a0
5ed9505
895896f
37247a7
807e473
aaa0f99
fb8003d
95eae04
5f9b0a2
8eddb4e
d7ad6ee
51ec7e2
4a29e29
71df5bf
09785dc
182949d
c761443
d290029
9b164ab
82bf7f2
cdf2a03
bd7d8e2
689bd6d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,4 +1,5 @@ | ||
'use strict'; | ||
|
||
const { | ||
ArrayIsArray, | ||
ArrayPrototypeEvery, | ||
|
@@ -10,7 +11,6 @@ const { | |
ArrayPrototypeMap, | ||
ArrayPrototypePush, | ||
ArrayPrototypeShift, | ||
ArrayPrototypeSlice, | ||
ArrayPrototypeSome, | ||
ArrayPrototypeSort, | ||
ObjectAssign, | ||
|
@@ -88,10 +88,12 @@ const kCanceledTests = new SafeSet() | |
|
||
let kResistStopPropagation; | ||
|
||
function createTestFileList() { | ||
function createTestFileList(patterns) { | ||
const cwd = process.cwd(); | ||
const hasUserSuppliedPattern = process.argv.length > 1; | ||
const patterns = hasUserSuppliedPattern ? ArrayPrototypeSlice(process.argv, 1) : [kDefaultPattern]; | ||
const hasUserSuppliedPattern = patterns != null; | ||
if (!patterns || patterns.length === 0) { | ||
patterns = [kDefaultPattern]; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I would assign the default patterns in the |
||
} | ||
const glob = new Glob(patterns, { | ||
__proto__: null, | ||
cwd, | ||
|
@@ -345,7 +347,6 @@ function runTestFile(path, filesWatcher, opts) { | |
|
||
let err; | ||
|
||
|
||
mcollina marked this conversation as resolved.
Show resolved
Hide resolved
|
||
child.on('error', (error) => { | ||
err = error; | ||
}); | ||
|
@@ -419,8 +420,8 @@ function watchFiles(testFiles, opts) { | |
opts.root.harness.watching = true; | ||
|
||
watcher.on('changed', ({ owners, eventType }) => { | ||
if (eventType === 'rename') { | ||
const updatedTestFiles = createTestFileList(); | ||
if (!opts.hasFiles && eventType === 'rename') { | ||
const updatedTestFiles = createTestFileList(opts.globPatterns); | ||
|
||
const newFileName = ArrayPrototypeFind(updatedTestFiles, (x) => !ArrayPrototypeIncludes(testFiles, x)); | ||
const previousFileName = ArrayPrototypeFind(testFiles, (x) => !ArrayPrototypeIncludes(updatedTestFiles, x)); | ||
|
@@ -483,6 +484,7 @@ function run(options = kEmptyObject) { | |
watch, | ||
setup, | ||
only, | ||
globPatterns, | ||
} = options; | ||
|
||
if (files != null) { | ||
|
@@ -503,6 +505,16 @@ function run(options = kEmptyObject) { | |
if (only != null) { | ||
validateBoolean(only, 'options.only'); | ||
} | ||
if (globPatterns != null) { | ||
validateArray(globPatterns, 'options.globPatterns'); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We should probably validate that the array is not empty. You can add the logic here. Otherwise, I will probably do it in a follow up. |
||
} | ||
|
||
if (globPatterns?.length > 0 && files?.length > 0) { | ||
throw new ERR_INVALID_ARG_VALUE( | ||
'options.globPatterns', globPatterns, 'is not supported when specifying \'options.files\'', | ||
); | ||
} | ||
|
||
if (shard != null) { | ||
validateObject(shard, 'options.shard'); | ||
// Avoid re-evaluating the shard object in case it's a getter | ||
|
@@ -559,7 +571,7 @@ function run(options = kEmptyObject) { | |
root.postRun(); | ||
return root.reporter; | ||
} | ||
let testFiles = files ?? createTestFileList(); | ||
let testFiles = files ?? createTestFileList(globPatterns); | ||
|
||
if (shard) { | ||
testFiles = ArrayPrototypeFilter(testFiles, (_, index) => index % shard.total === shard.index - 1); | ||
|
@@ -574,6 +586,8 @@ function run(options = kEmptyObject) { | |
inspectPort, | ||
testNamePatterns, | ||
testSkipPatterns, | ||
hasFiles: files != null, | ||
globPatterns, | ||
only, | ||
forceExit, | ||
}; | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,24 @@ | ||
import { run } from 'node:test'; | ||
import { tap } from 'node:test/reporters'; | ||
import { parseArgs } from 'node:util'; | ||
|
||
const options = { | ||
file: { | ||
type: 'string', | ||
}, | ||
}; | ||
const { | ||
values, | ||
positionals, | ||
} = parseArgs({ args: process.argv.slice(2), options }); | ||
|
||
let files; | ||
|
||
if (values.file) { | ||
files = [values.file]; | ||
} | ||
|
||
run({ | ||
files, | ||
watch: true | ||
}).compose(tap).pipe(process.stdout); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,61 @@ | ||
import * as common from '../common/index.mjs'; | ||
import tmpdir from '../common/tmpdir.js'; | ||
import { describe, it, run, beforeEach } from 'node:test'; | ||
import { dot, spec, tap } from 'node:test/reporters'; | ||
import { fork } from 'node:child_process'; | ||
import assert from 'node:assert'; | ||
|
||
if (common.hasCrypto) { | ||
console.log('1..0 # Skipped: no crypto'); | ||
process.exit(0); | ||
} | ||
|
||
if (process.env.CHILD === 'true') { | ||
describe('require(\'node:test\').run with no files', { concurrency: true }, () => { | ||
beforeEach(() => { | ||
tmpdir.refresh(); | ||
process.chdir(tmpdir.path); | ||
}); | ||
|
||
it('should neither pass or fail', async () => { | ||
const stream = run({ | ||
files: undefined | ||
}).compose(tap); | ||
stream.on('test:fail', common.mustNotCall()); | ||
stream.on('test:pass', common.mustNotCall()); | ||
|
||
// eslint-disable-next-line no-unused-vars | ||
for await (const _ of stream); | ||
}); | ||
|
||
it('can use the spec reporter', async () => { | ||
const stream = run({ | ||
files: undefined | ||
}).compose(spec); | ||
stream.on('test:fail', common.mustNotCall()); | ||
stream.on('test:pass', common.mustNotCall()); | ||
|
||
// eslint-disable-next-line no-unused-vars | ||
for await (const _ of stream); | ||
}); | ||
|
||
it('can use the dot reporter', async () => { | ||
const stream = run({ | ||
files: undefined | ||
}).compose(dot); | ||
stream.on('test:fail', common.mustNotCall()); | ||
stream.on('test:pass', common.mustNotCall()); | ||
|
||
// eslint-disable-next-line no-unused-vars | ||
for await (const _ of stream); | ||
}); | ||
}); | ||
} else if (common.isAIX) { | ||
console.log('1..0 # Skipped: test runner without specifying files fails on AIX'); | ||
} else { | ||
fork(import.meta.filename, [], { | ||
env: { CHILD: 'true' } | ||
}).on('exit', common.mustCall((code) => { | ||
assert.strictEqual(code, 0); | ||
})); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,175 @@ | ||
// Flags: --expose-internals | ||
import * as common from '../common/index.mjs'; | ||
import { describe, it, beforeEach } from 'node:test'; | ||
import assert from 'node:assert'; | ||
import { spawn } from 'node:child_process'; | ||
import { once } from 'node:events'; | ||
import { writeFileSync, renameSync, unlinkSync, existsSync } from 'node:fs'; | ||
import util from 'internal/util'; | ||
import tmpdir from '../common/tmpdir.js'; | ||
import { join } from 'node:path'; | ||
|
||
if (common.isIBMi) | ||
common.skip('IBMi does not support `fs.watch()`'); | ||
|
||
// This test updates these files repeatedly, | ||
// Reading them from disk is unreliable due to race conditions. | ||
const fixtureContent = { | ||
'dependency.js': 'module.exports = {};', | ||
'dependency.mjs': 'export const a = 1;', | ||
'test.js': ` | ||
const test = require('node:test'); | ||
require('./dependency.js'); | ||
import('./dependency.mjs'); | ||
import('data:text/javascript,'); | ||
test('test has ran');`, | ||
}; | ||
|
||
let fixturePaths; | ||
|
||
function refresh() { | ||
tmpdir.refresh(); | ||
|
||
fixturePaths = Object.keys(fixtureContent) | ||
.reduce((acc, file) => ({ ...acc, [file]: tmpdir.resolve(file) }), {}); | ||
Object.entries(fixtureContent) | ||
.forEach(([file, content]) => writeFileSync(fixturePaths[file], content)); | ||
} | ||
|
||
const runner = join(import.meta.dirname, '..', 'fixtures', 'test-runner-watch.mjs'); | ||
|
||
async function testWatch({ fileToUpdate, file, action = 'update', cwd = tmpdir.path }) { | ||
const ran1 = util.createDeferredPromise(); | ||
const ran2 = util.createDeferredPromise(); | ||
MoLow marked this conversation as resolved.
Show resolved
Hide resolved
|
||
const args = [runner]; | ||
if (file) args.push('--file', file); | ||
const child = spawn(process.execPath, | ||
args, | ||
{ encoding: 'utf8', stdio: 'pipe', cwd }); | ||
let stdout = ''; | ||
let currentRun = ''; | ||
const runs = []; | ||
|
||
child.stdout.on('data', (data) => { | ||
stdout += data.toString(); | ||
currentRun += data.toString(); | ||
const testRuns = stdout.match(/# duration_ms\s\d+/g); | ||
if (testRuns?.length >= 1) ran1.resolve(); | ||
if (testRuns?.length >= 2) ran2.resolve(); | ||
}); | ||
|
||
const testUpdate = async () => { | ||
await ran1.promise; | ||
const content = fixtureContent[fileToUpdate]; | ||
const path = fixturePaths[fileToUpdate]; | ||
const interval = setInterval(() => writeFileSync(path, content), common.platformTimeout(1000)); | ||
await ran2.promise; | ||
runs.push(currentRun); | ||
clearInterval(interval); | ||
child.kill(); | ||
await once(child, 'exit'); | ||
for (const run of runs) { | ||
assert.doesNotMatch(run, /run\(\) is being called recursively/); | ||
assert.match(run, /# tests 1/); | ||
assert.match(run, /# pass 1/); | ||
assert.match(run, /# fail 0/); | ||
assert.match(run, /# cancelled 0/); | ||
} | ||
}; | ||
|
||
const testRename = async () => { | ||
await ran1.promise; | ||
const fileToRenamePath = tmpdir.resolve(fileToUpdate); | ||
const newFileNamePath = tmpdir.resolve(`test-renamed-${fileToUpdate}`); | ||
const interval = setInterval(() => renameSync(fileToRenamePath, newFileNamePath), common.platformTimeout(1000)); | ||
await ran2.promise; | ||
runs.push(currentRun); | ||
clearInterval(interval); | ||
child.kill(); | ||
await once(child, 'exit'); | ||
|
||
for (const run of runs) { | ||
assert.doesNotMatch(run, /run\(\) is being called recursively/); | ||
if (action === 'rename2') { | ||
assert.match(run, /MODULE_NOT_FOUND/); | ||
} else { | ||
assert.doesNotMatch(run, /MODULE_NOT_FOUND/); | ||
} | ||
assert.match(run, /# tests 1/); | ||
assert.match(run, /# pass 1/); | ||
assert.match(run, /# fail 0/); | ||
assert.match(run, /# cancelled 0/); | ||
} | ||
}; | ||
|
||
const testDelete = async () => { | ||
await ran1.promise; | ||
const fileToDeletePath = tmpdir.resolve(fileToUpdate); | ||
const interval = setInterval(() => { | ||
if (existsSync(fileToDeletePath)) { | ||
unlinkSync(fileToDeletePath); | ||
} else { | ||
ran2.resolve(); | ||
} | ||
}, common.platformTimeout(1000)); | ||
await ran2.promise; | ||
runs.push(currentRun); | ||
clearInterval(interval); | ||
child.kill(); | ||
await once(child, 'exit'); | ||
|
||
for (const run of runs) { | ||
assert.doesNotMatch(run, /MODULE_NOT_FOUND/); | ||
} | ||
}; | ||
|
||
action === 'update' && await testUpdate(); | ||
action === 'rename' && await testRename(); | ||
action === 'rename2' && await testRename(); | ||
action === 'delete' && await testDelete(); | ||
} | ||
|
||
describe('test runner watch mode', () => { | ||
beforeEach(refresh); | ||
it('should run tests repeatedly', async () => { | ||
await testWatch({ file: 'test.js', fileToUpdate: 'test.js' }); | ||
}); | ||
|
||
it('should run tests with dependency repeatedly', async () => { | ||
await testWatch({ file: 'test.js', fileToUpdate: 'dependency.js' }); | ||
}); | ||
|
||
it('should run tests with ESM dependency', async () => { | ||
await testWatch({ file: 'test.js', fileToUpdate: 'dependency.mjs' }); | ||
}); | ||
|
||
it('should support running tests without a file', async () => { | ||
await testWatch({ fileToUpdate: 'test.js' }); | ||
}); | ||
|
||
it('should support a watched test file rename', async () => { | ||
await testWatch({ fileToUpdate: 'test.js', action: 'rename' }); | ||
}); | ||
|
||
it('should not throw when deleting a watched test file', { skip: common.isAIX }, async () => { | ||
await testWatch({ fileToUpdate: 'test.js', action: 'delete' }); | ||
}); | ||
|
||
it('should run tests with dependency repeatedly in a different cwd', async () => { | ||
await testWatch({ | ||
file: join(tmpdir.path, 'test.js'), | ||
fileToUpdate: 'dependency.js', | ||
cwd: import.meta.dirname, | ||
action: 'rename2' | ||
}); | ||
}); | ||
|
||
it('should handle renames in a different cwd', async () => { | ||
await testWatch({ | ||
file: join(tmpdir.path, 'test.js'), | ||
fileToUpdate: 'test.js', | ||
cwd: import.meta.dirname, | ||
action: 'rename2' | ||
}); | ||
}); | ||
}); |
Uh oh!
There was an error while loading. Please reload this page.