Skip to content

Commit e4a811c

Browse files
committed
test_runner: support passing globs
1 parent 5b4c7bd commit e4a811c

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
@@ -2,10 +2,12 @@
22
const {
33
ArrayFrom,
44
ArrayIsArray,
5+
ArrayPrototypeEvery,
56
ArrayPrototypeFilter,
67
ArrayPrototypeForEach,
78
ArrayPrototypeIncludes,
89
ArrayPrototypeMap,
10+
ArrayPrototypeJoin,
911
ArrayPrototypePush,
1012
ArrayPrototypeShift,
1113
ArrayPrototypeSlice,
@@ -29,7 +31,6 @@ const {
2931
} = primordials;
3032

3133
const { spawn } = require('child_process');
32-
const { readdirSync, statSync } = require('fs');
3334
const { finished } = require('internal/streams/end-of-stream');
3435
const { DefaultDeserializer, DefaultSerializer } = require('v8');
3536
// TODO(aduh95): switch to internal/readline/interface when backporting to Node.js 16.x is no longer a concern.
@@ -62,10 +63,9 @@ const {
6263
const {
6364
convertStringToRegExp,
6465
countCompletedTest,
65-
doesPathMatchFilter,
66-
isSupportedFileType,
66+
kDefaultPattern,
6767
} = require('internal/test_runner/utils');
68-
const { basename, join, resolve } = require('path');
68+
const { glob } = require('internal/fs/glob');
6969
const { once } = require('events');
7070
const {
7171
triggerUncaughtException,
@@ -80,66 +80,18 @@ const kSplitLine = hardenRegExp(/\r?\n/);
8080
const kCanceledTests = new SafeSet()
8181
.add(kCancelledByParent).add(kAborted).add(kTestTimeoutFailure);
8282

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

139-
throw err;
89+
if (hasUserSuppliedPattern && results.size === 0 && ArrayPrototypeEvery(matchers, (m) => !m.hasMagic())) {
90+
console.error(`Could not find '${ArrayPrototypeJoin(patterns, ', ')}'`);
91+
process.exit(kGenericUserError);
14092
}
14193

142-
return ArrayPrototypeSort(ArrayFrom(testFiles));
94+
return ArrayPrototypeSort(ArrayFrom(results));
14395
}
14496

14597
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;
@@ -304,9 +298,8 @@ module.exports = {
304298
convertStringToRegExp,
305299
countCompletedTest,
306300
createDeferredCallback,
307-
doesPathMatchFilter,
308-
isSupportedFileType,
309301
isTestFailureError,
302+
kDefaultPattern,
310303
parseCommandLine,
311304
setupTestReporters,
312305
getCoverageReport,

0 commit comments

Comments
 (0)