Skip to content

Commit 893c864

Browse files
mcollinaRafaelGSS
authored andcommitted
test_runner: fix support watch with run(), add globPatterns option
Signed-off-by: Matteo Collina <hello@matteocollina.com> PR-URL: #53866 Reviewed-By: Chemi Atlow <chemi@atlow.co.il> Reviewed-By: Colin Ihrig <cjihrig@gmail.com> Reviewed-By: Moshe Atlow <moshe@atlow.co.il>
1 parent ee97c04 commit 893c864

8 files changed

+324
-49
lines changed

doc/api/test.md

+6
Original file line numberDiff line numberDiff line change
@@ -1239,6 +1239,9 @@ added:
12391239
- v18.9.0
12401240
- v16.19.0
12411241
changes:
1242+
- version: REPLACEME
1243+
pr-url: https://github.com/nodejs/node/pull/53866
1244+
description: Added the `globPatterns` option.
12421245
- version: v22.0.0
12431246
pr-url: https://github.com/nodejs/node/pull/52038
12441247
description: Added the `forceExit` option.
@@ -1263,6 +1266,9 @@ changes:
12631266
* `forceExit`: {boolean} Configures the test runner to exit the process once
12641267
all known tests have finished executing even if the event loop would
12651268
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][].
12661272
* `inspectPort` {number|Function} Sets inspector port of test child process.
12671273
This can be a number, or a function that takes no arguments and returns a
12681274
number. If a nullish value is provided, each process gets its own port,

lib/internal/main/test_runner.js

+5
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
'use strict';
22

3+
const {
4+
ArrayPrototypeSlice,
5+
} = primordials;
6+
37
const {
48
prepareMainThreadExecution,
59
markBootstrapComplete,
@@ -42,6 +46,7 @@ const options = {
4246
setup: setupTestReporters,
4347
timeout: perFileTimeout,
4448
shard,
49+
globPatterns: ArrayPrototypeSlice(process.argv, 1),
4550
};
4651
debug('test runner configuration:', options);
4752
run(options).on('test:fail', (data) => {

lib/internal/test_runner/runner.js

+22-8
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
'use strict';
2+
23
const {
34
ArrayIsArray,
45
ArrayPrototypeEvery,
@@ -10,7 +11,6 @@ const {
1011
ArrayPrototypeMap,
1112
ArrayPrototypePush,
1213
ArrayPrototypeShift,
13-
ArrayPrototypeSlice,
1414
ArrayPrototypeSome,
1515
ArrayPrototypeSort,
1616
ObjectAssign,
@@ -87,10 +87,12 @@ const kCanceledTests = new SafeSet()
8787

8888
let kResistStopPropagation;
8989

90-
function createTestFileList() {
90+
function createTestFileList(patterns) {
9191
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+
}
9496
const glob = new Glob(patterns, {
9597
__proto__: null,
9698
cwd,
@@ -344,7 +346,6 @@ function runTestFile(path, filesWatcher, opts) {
344346

345347
let err;
346348

347-
348349
child.on('error', (error) => {
349350
err = error;
350351
});
@@ -418,8 +419,8 @@ function watchFiles(testFiles, opts) {
418419
opts.root.harness.watching = true;
419420

420421
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);
423424

424425
const newFileName = ArrayPrototypeFind(updatedTestFiles, (x) => !ArrayPrototypeIncludes(testFiles, x));
425426
const previousFileName = ArrayPrototypeFind(testFiles, (x) => !ArrayPrototypeIncludes(updatedTestFiles, x));
@@ -482,6 +483,7 @@ function run(options = kEmptyObject) {
482483
watch,
483484
setup,
484485
only,
486+
globPatterns,
485487
} = options;
486488

487489
if (files != null) {
@@ -502,6 +504,16 @@ function run(options = kEmptyObject) {
502504
if (only != null) {
503505
validateBoolean(only, 'options.only');
504506
}
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+
505517
if (shard != null) {
506518
validateObject(shard, 'options.shard');
507519
// Avoid re-evaluating the shard object in case it's a getter
@@ -557,7 +569,7 @@ function run(options = kEmptyObject) {
557569
root.postRun();
558570
return root.reporter;
559571
}
560-
let testFiles = files ?? createTestFileList();
572+
let testFiles = files ?? createTestFileList(globPatterns);
561573

562574
if (shard) {
563575
testFiles = ArrayPrototypeFilter(testFiles, (_, index) => index % shard.total === shard.index - 1);
@@ -573,6 +585,8 @@ function run(options = kEmptyObject) {
573585
inspectPort,
574586
testNamePatterns,
575587
testSkipPatterns,
588+
hasFiles: files != null,
589+
globPatterns,
576590
only,
577591
forceExit,
578592
};

test/fixtures/test-runner-watch.mjs

+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
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);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
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+
}
+175
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
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+
});

0 commit comments

Comments
 (0)