Skip to content

Commit cb8b808

Browse files
committed
test_runner: support passing globs
1 parent 0c5f253 commit cb8b808

File tree

5 files changed

+227
-126
lines changed

5 files changed

+227
-126
lines changed

lib/internal/fs/glob.js

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
'use strict';
2+
const { lstatSync, readdirSync } = require('fs');
3+
const { join, resolve } = require('path');
4+
5+
const {
6+
kEmptyObject,
7+
} = require('internal/util');
8+
const { isRegExp } = require('internal/util/types');
9+
const {
10+
validateFunction,
11+
validateObject,
12+
} = require('internal/validators');
13+
14+
const {
15+
ArrayPrototypeForEach,
16+
ArrayPrototypeMap,
17+
ArrayPrototypeFlatMap,
18+
ArrayPrototypePop,
19+
ArrayPrototypePush,
20+
SafeMap,
21+
SafeSet,
22+
} = primordials;
23+
24+
let minimatch;
25+
function lazyMinimatch() {
26+
minimatch ??= require('internal/deps/minimatch/index');
27+
return minimatch;
28+
}
29+
30+
function testPattern(pattern, path) {
31+
if (pattern === lazyMinimatch().GLOBSTAR) {
32+
return true;
33+
}
34+
if (typeof pattern === 'string') {
35+
return true;
36+
}
37+
if (typeof pattern.test === 'function') {
38+
return pattern.test(path);
39+
}
40+
}
41+
42+
class Cache {
43+
#caches = new SafeMap();
44+
#statsCache = new SafeMap();
45+
#readdirCache = new SafeMap();
46+
47+
stats(path) {
48+
if (this.#statsCache.has(path)) {
49+
return this.#statsCache.get(path);
50+
}
51+
let val;
52+
try {
53+
val = lstatSync(path);
54+
} catch {
55+
val = null;
56+
}
57+
this.#statsCache.set(path, val);
58+
return val;
59+
}
60+
readdir(path) {
61+
if (this.#readdirCache.has(path)) {
62+
return this.#readdirCache.get(path);
63+
}
64+
let val;
65+
try {
66+
val = readdirSync(path, { withFileTypes: true });
67+
ArrayPrototypeForEach(val, (dirent) => this.#statsCache.set(join(path, dirent.name), dirent));
68+
} catch {
69+
val = [];
70+
}
71+
this.#readdirCache.set(path, val);
72+
return val;
73+
}
74+
75+
seen(pattern, index, path) {
76+
return this.#caches.get(path)?.get(pattern)?.has(index);
77+
}
78+
add(pattern, index, path) {
79+
if (!this.#caches.has(path)) {
80+
this.#caches.set(path, new SafeMap([[pattern, new SafeSet([index])]]));
81+
} else if (!this.#caches.get(path)?.has(pattern)) {
82+
this.#caches.get(path)?.set(pattern, new SafeSet([index]));
83+
} else {
84+
this.#caches.get(path)?.get(pattern)?.add(index);
85+
}
86+
}
87+
88+
}
89+
90+
function glob(patterns, options = kEmptyObject) {
91+
validateObject(options, 'options');
92+
const root = options.cwd ?? '.';
93+
const { exclude } = options;
94+
if (exclude != null) {
95+
validateFunction(exclude, 'options.exclude');
96+
}
97+
98+
const { Minimatch, GLOBSTAR } = lazyMinimatch();
99+
const results = new SafeSet();
100+
const matchers = ArrayPrototypeMap(patterns, (pattern) => new Minimatch(pattern));
101+
const queue = ArrayPrototypeFlatMap(matchers, (matcher) => {
102+
return ArrayPrototypeMap(matcher.set,
103+
(pattern) => ({ __proto__: null, pattern, index: 0, path: '.', followSymlinks: true }));
104+
});
105+
const cache = new Cache(matchers);
106+
107+
while (queue.length > 0) {
108+
const { pattern, index: currentIndex, path, followSymlinks } = ArrayPrototypePop(queue);
109+
if (cache.seen(pattern, currentIndex, path)) {
110+
continue;
111+
}
112+
cache.add(pattern, currentIndex, path);
113+
114+
const currentPattern = pattern[currentIndex];
115+
const index = currentIndex + 1;
116+
const isLast = pattern.length === index || (pattern.length === index + 1 && pattern[index] === '');
117+
118+
if (currentPattern === '') {
119+
// Absolute path
120+
ArrayPrototypePush(queue, { __proto__: null, pattern, index, path: '/', followSymlinks });
121+
continue;
122+
}
123+
124+
if (typeof currentPattern === 'string') {
125+
const entryPath = join(path, currentPattern);
126+
if (isLast && cache.stats(resolve(root, entryPath))) {
127+
// last path
128+
results.add(entryPath);
129+
} else if (!isLast) {
130+
// Keep traversing, we only check file existence for the last path
131+
ArrayPrototypePush(queue, { __proto__: null, pattern, index, path: entryPath, followSymlinks });
132+
}
133+
continue;
134+
}
135+
136+
const fullpath = resolve(root, path);
137+
const stat = cache.stats(fullpath);
138+
const isDirectory = stat?.isDirectory() || (followSymlinks !== false && stat?.isSymbolicLink());
139+
140+
if (isDirectory && isRegExp(currentPattern)) {
141+
const entries = cache.readdir(fullpath);
142+
for (const entry of entries) {
143+
const entryPath = join(path, entry.name);
144+
if (cache.seen(pattern, index, entryPath)) {
145+
continue;
146+
}
147+
const matches = testPattern(currentPattern, entry.name);
148+
if (matches && isLast) {
149+
results.add(entryPath);
150+
} else if (matches) {
151+
ArrayPrototypePush(queue, { __proto__: null, pattern, index, path: entryPath, followSymlinks });
152+
}
153+
}
154+
}
155+
156+
if (currentPattern === GLOBSTAR && isDirectory) {
157+
const entries = cache.readdir(fullpath);
158+
for (const entry of entries) {
159+
if (entry.name[0] === '.' || (exclude && exclude(entry.name))) {
160+
continue;
161+
}
162+
const entryPath = join(path, entry.name);
163+
if (cache.seen(pattern, index, entryPath)) {
164+
continue;
165+
}
166+
const isSymbolicLink = entry.isSymbolicLink();
167+
const isDirectory = entry.isDirectory();
168+
if (isDirectory) {
169+
// Push child directory to queue at same pattern index
170+
ArrayPrototypePush(queue, {
171+
__proto__: null, pattern, index: currentIndex, path: entryPath, followSymlinks: !isSymbolicLink,
172+
});
173+
}
174+
175+
if (pattern.length === index || (isSymbolicLink && pattern.length === index + 1 && pattern[index] === '')) {
176+
results.add(entryPath);
177+
} else if (pattern[index] === '..') {
178+
continue;
179+
} else if (!isLast &&
180+
(isDirectory || (isSymbolicLink && (typeof pattern[index] !== 'string' || pattern[0] !== GLOBSTAR)))) {
181+
ArrayPrototypePush(queue, { __proto__: null, pattern, index, path: entryPath, followSymlinks });
182+
}
183+
}
184+
if (isLast) {
185+
results.add(path);
186+
} else {
187+
ArrayPrototypePush(queue, { __proto__: null, pattern, index, path, followSymlinks });
188+
}
189+
}
190+
}
191+
192+
return {
193+
__proto__: null,
194+
results,
195+
matchers,
196+
};
197+
}
198+
199+
module.exports = {
200+
glob,
201+
lazyMinimatch,
202+
};

lib/internal/test_runner/runner.js

Lines changed: 11 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
'use strict';
22
const {
33
ArrayFrom,
4+
ArrayPrototypeEvery,
45
ArrayPrototypeFilter,
56
ArrayPrototypeForEach,
67
ArrayPrototypeIncludes,
78
ArrayPrototypeIndexOf,
9+
ArrayPrototypeJoin,
810
ArrayPrototypePush,
911
ArrayPrototypeSlice,
1012
ArrayPrototypeSome,
@@ -25,7 +27,6 @@ const {
2527
} = primordials;
2628

2729
const { spawn } = require('child_process');
28-
const { readdirSync, statSync } = require('fs');
2930
const { finished } = require('internal/streams/end-of-stream');
3031
// TODO(aduh95): switch to internal/readline/interface when backporting to Node.js 16.x is no longer a concern.
3132
const { createInterface } = require('readline');
@@ -54,10 +55,9 @@ const { TokenKind } = require('internal/test_runner/tap_lexer');
5455

5556
const {
5657
countCompletedTest,
57-
doesPathMatchFilter,
58-
isSupportedFileType,
58+
kDefaultPattern,
5959
} = require('internal/test_runner/utils');
60-
const { basename, join, resolve } = require('path');
60+
const { glob } = require('internal/fs/glob');
6161
const { once } = require('events');
6262
const {
6363
triggerUncaughtException,
@@ -71,66 +71,18 @@ const kDiagnosticsFilterArgs = ['tests', 'suites', 'pass', 'fail', 'cancelled',
7171
const kCanceledTests = new SafeSet()
7272
.add(kCancelledByParent).add(kAborted).add(kTestTimeoutFailure);
7373

74-
// TODO(cjihrig): Replace this with recursive readdir once it lands.
75-
function processPath(path, testFiles, options) {
76-
const stats = statSync(path);
77-
78-
if (stats.isFile()) {
79-
if (options.userSupplied ||
80-
(options.underTestDir && isSupportedFileType(path)) ||
81-
doesPathMatchFilter(path)) {
82-
testFiles.add(path);
83-
}
84-
} else if (stats.isDirectory()) {
85-
const name = basename(path);
86-
87-
if (!options.userSupplied && name === 'node_modules') {
88-
return;
89-
}
90-
91-
// 'test' directories get special treatment. Recursively add all .js,
92-
// .cjs, and .mjs files in the 'test' directory.
93-
const isTestDir = name === 'test';
94-
const { underTestDir } = options;
95-
const entries = readdirSync(path);
96-
97-
if (isTestDir) {
98-
options.underTestDir = true;
99-
}
100-
101-
options.userSupplied = false;
102-
103-
for (let i = 0; i < entries.length; i++) {
104-
processPath(join(path, entries[i]), testFiles, options);
105-
}
106-
107-
options.underTestDir = underTestDir;
108-
}
109-
}
110-
11174
function createTestFileList() {
11275
const cwd = process.cwd();
113-
const hasUserSuppliedPaths = process.argv.length > 1;
114-
const testPaths = hasUserSuppliedPaths ?
115-
ArrayPrototypeSlice(process.argv, 1) : [cwd];
116-
const testFiles = new SafeSet();
117-
118-
try {
119-
for (let i = 0; i < testPaths.length; i++) {
120-
const absolutePath = resolve(testPaths[i]);
121-
122-
processPath(absolutePath, testFiles, { userSupplied: true });
123-
}
124-
} catch (err) {
125-
if (err?.code === 'ENOENT') {
126-
console.error(`Could not find '${err.path}'`);
127-
process.exit(kGenericUserError);
128-
}
76+
const hasUserSuppliedPattern = process.argv.length > 1;
77+
const patterns = hasUserSuppliedPattern ? ArrayPrototypeSlice(process.argv, 1) : [kDefaultPattern];
78+
const { results, matchers } = glob(patterns, { __proto__: null, cwd, exclude: (name) => name === 'node_modules' });
12979

130-
throw err;
80+
if (hasUserSuppliedPattern && results.size === 0 && ArrayPrototypeEvery(matchers, (m) => !m.hasMagic())) {
81+
console.error(`Could not find '${ArrayPrototypeJoin(patterns, ', ')}'`);
82+
process.exit(kGenericUserError);
13183
}
13284

133-
return ArrayPrototypeSort(ArrayFrom(testFiles));
85+
return ArrayPrototypeSort(ArrayFrom(results));
13486
}
13587

13688
function filterExecArgv(arg, i, arr) {

lib/internal/test_runner/utils.js

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ const {
1111
SafeMap,
1212
} = primordials;
1313

14-
const { basename, relative } = require('path');
14+
const { relative } = require('path');
1515
const { createWriteStream } = require('fs');
1616
const { pathToFileURL } = require('internal/url');
1717
const { createDeferredPromise } = require('internal/util');
@@ -29,16 +29,10 @@ const { compose } = require('stream');
2929

3030
const kMultipleCallbackInvocations = 'multipleCallbackInvocations';
3131
const kRegExpPattern = /^\/(.*)\/([a-z]*)$/;
32-
const kSupportedFileExtensions = /\.[cm]?js$/;
33-
const kTestFilePattern = /((^test(-.+)?)|(.+[.\-_]test))\.[cm]?js$/;
3432

35-
function doesPathMatchFilter(p) {
36-
return RegExpPrototypeExec(kTestFilePattern, basename(p)) !== null;
37-
}
33+
const kPatterns = ['test', 'test/**/*', 'test-*', '*[.\\-_]test'];
34+
const kDefaultPattern = `**/{${ArrayPrototypeJoin(kPatterns, ',')}}.?(c|m)js`;
3835

39-
function isSupportedFileType(p) {
40-
return RegExpPrototypeExec(kSupportedFileExtensions, p) !== null;
41-
}
4236

4337
function createDeferredCallback() {
4438
let calledCount = 0;
@@ -299,9 +293,8 @@ module.exports = {
299293
convertStringToRegExp,
300294
countCompletedTest,
301295
createDeferredCallback,
302-
doesPathMatchFilter,
303-
isSupportedFileType,
304296
isTestFailureError,
297+
kDefaultPattern,
305298
parseCommandLine,
306299
setupTestReporters,
307300
getCoverageReport,

0 commit comments

Comments
 (0)