Skip to content

Commit

Permalink
test_runner: support passing globs
Browse files Browse the repository at this point in the history
  • Loading branch information
MoLow committed Apr 21, 2023
1 parent f536bb0 commit daf9b42
Show file tree
Hide file tree
Showing 5 changed files with 227 additions and 126 deletions.
202 changes: 202 additions & 0 deletions lib/internal/fs/glob.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
'use strict';
const { lstatSync, readdirSync } = require('fs');
const { join, resolve } = require('path');

const {
kEmptyObject,
} = require('internal/util');
const { isRegExp } = require('internal/util/types');
const {
validateFunction,
validateObject,
} = require('internal/validators');

const {
ArrayPrototypeForEach,
ArrayPrototypeMap,
ArrayPrototypeFlatMap,
ArrayPrototypePop,
ArrayPrototypePush,
SafeMap,
SafeSet,
} = primordials;

let minimatch;
function lazyMinimatch() {
minimatch ??= require('internal/deps/minimatch/index');
return minimatch;
}

function testPattern(pattern, path) {
if (pattern === lazyMinimatch().GLOBSTAR) {
return true;
}
if (typeof pattern === 'string') {
return true;
}
if (typeof pattern.test === 'function') {
return pattern.test(path);
}
}

class Cache {
#caches = new SafeMap();
#statsCache = new SafeMap();
#readdirCache = new SafeMap();

stats(path) {
if (this.#statsCache.has(path)) {
return this.#statsCache.get(path);
}
let val;
try {
val = lstatSync(path);
} catch {
val = null;
}
this.#statsCache.set(path, val);
return val;
}
readdir(path) {
if (this.#readdirCache.has(path)) {
return this.#readdirCache.get(path);
}
let val;
try {
val = readdirSync(path, { withFileTypes: true });
ArrayPrototypeForEach(val, (dirent) => this.#statsCache.set(join(path, dirent.name), dirent));
} catch {
val = [];
}
this.#readdirCache.set(path, val);
return val;
}

seen(pattern, index, path) {
return this.#caches.get(path)?.get(pattern)?.has(index);
}
add(pattern, index, path) {
if (!this.#caches.has(path)) {
this.#caches.set(path, new SafeMap([[pattern, new SafeSet([index])]]));
} else if (!this.#caches.get(path)?.has(pattern)) {
this.#caches.get(path)?.set(pattern, new SafeSet([index]));
} else {
this.#caches.get(path)?.get(pattern)?.add(index);
}
}

}

function glob(patterns, options = kEmptyObject) {
validateObject(options, 'options');
const root = options.cwd ?? '.';
const { exclude } = options;
if (exclude != null) {
validateFunction(exclude, 'options.exclude');
}

const { Minimatch, GLOBSTAR } = lazyMinimatch();
const results = new SafeSet();
const matchers = ArrayPrototypeMap(patterns, (pattern) => new Minimatch(pattern));
const queue = ArrayPrototypeFlatMap(matchers, (matcher) => {
return ArrayPrototypeMap(matcher.set,
(pattern) => ({ __proto__: null, pattern, index: 0, path: '.', followSymlinks: true }));
});
const cache = new Cache(matchers);

while (queue.length > 0) {
const { pattern, index: currentIndex, path, followSymlinks } = ArrayPrototypePop(queue);
if (cache.seen(pattern, currentIndex, path)) {
continue;
}
cache.add(pattern, currentIndex, path);

const currentPattern = pattern[currentIndex];
const index = currentIndex + 1;
const isLast = pattern.length === index || (pattern.length === index + 1 && pattern[index] === '');

if (currentPattern === '') {
// Absolute path
ArrayPrototypePush(queue, { __proto__: null, pattern, index, path: '/', followSymlinks });
continue;
}

if (typeof currentPattern === 'string') {
const entryPath = join(path, currentPattern);
if (isLast && cache.stats(resolve(root, entryPath))) {
// last path
results.add(entryPath);
} else if (!isLast) {
// Keep traversing, we only check file existence for the last path
ArrayPrototypePush(queue, { __proto__: null, pattern, index, path: entryPath, followSymlinks });
}
continue;
}

const fullpath = resolve(root, path);
const stat = cache.stats(fullpath);
const isDirectory = stat?.isDirectory() || (followSymlinks !== false && stat?.isSymbolicLink());

if (isDirectory && isRegExp(currentPattern)) {
const entries = cache.readdir(fullpath);
for (const entry of entries) {
const entryPath = join(path, entry.name);
if (cache.seen(pattern, index, entryPath)) {
continue;
}
const matches = testPattern(currentPattern, entry.name);
if (matches && isLast) {
results.add(entryPath);
} else if (matches) {
ArrayPrototypePush(queue, { __proto__: null, pattern, index, path: entryPath, followSymlinks });
}
}
}

if (currentPattern === GLOBSTAR && isDirectory) {
const entries = cache.readdir(fullpath);
for (const entry of entries) {
if (entry.name[0] === '.' || (exclude && exclude(entry.name))) {
continue;
}
const entryPath = join(path, entry.name);
if (cache.seen(pattern, index, entryPath)) {
continue;
}
const isSymbolicLink = entry.isSymbolicLink();
const isDirectory = entry.isDirectory();
if (isDirectory) {
// Push child directory to queue at same pattern index
ArrayPrototypePush(queue, {
__proto__: null, pattern, index: currentIndex, path: entryPath, followSymlinks: !isSymbolicLink,
});
}

if (pattern.length === index || (isSymbolicLink && pattern.length === index + 1 && pattern[index] === '')) {
results.add(entryPath);
} else if (pattern[index] === '..') {
continue;
} else if (!isLast &&
(isDirectory || (isSymbolicLink && (typeof pattern[index] !== 'string' || pattern[0] !== GLOBSTAR)))) {
ArrayPrototypePush(queue, { __proto__: null, pattern, index, path: entryPath, followSymlinks });
}
}
if (isLast) {
results.add(path);
} else {
ArrayPrototypePush(queue, { __proto__: null, pattern, index, path, followSymlinks });
}
}
}

return {
__proto__: null,
results,
matchers,
};
}

module.exports = {
glob,
lazyMinimatch,
};
70 changes: 11 additions & 59 deletions lib/internal/test_runner/runner.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
'use strict';
const {
ArrayFrom,
ArrayPrototypeEvery,
ArrayPrototypeFilter,
ArrayPrototypeForEach,
ArrayPrototypeIncludes,
ArrayPrototypeIndexOf,
ArrayPrototypeJoin,
ArrayPrototypePush,
ArrayPrototypeSlice,
ArrayPrototypeSome,
Expand All @@ -25,7 +27,6 @@ const {
} = primordials;

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

const {
countCompletedTest,
doesPathMatchFilter,
isSupportedFileType,
kDefaultPattern,
} = require('internal/test_runner/utils');
const { basename, join, resolve } = require('path');
const { glob } = require('internal/fs/glob');
const { once } = require('events');
const {
triggerUncaughtException,
Expand All @@ -71,66 +71,18 @@ const kDiagnosticsFilterArgs = ['tests', 'suites', 'pass', 'fail', 'cancelled',
const kCanceledTests = new SafeSet()
.add(kCancelledByParent).add(kAborted).add(kTestTimeoutFailure);

// TODO(cjihrig): Replace this with recursive readdir once it lands.
function processPath(path, testFiles, options) {
const stats = statSync(path);

if (stats.isFile()) {
if (options.userSupplied ||
(options.underTestDir && isSupportedFileType(path)) ||
doesPathMatchFilter(path)) {
testFiles.add(path);
}
} else if (stats.isDirectory()) {
const name = basename(path);

if (!options.userSupplied && name === 'node_modules') {
return;
}

// 'test' directories get special treatment. Recursively add all .js,
// .cjs, and .mjs files in the 'test' directory.
const isTestDir = name === 'test';
const { underTestDir } = options;
const entries = readdirSync(path);

if (isTestDir) {
options.underTestDir = true;
}

options.userSupplied = false;

for (let i = 0; i < entries.length; i++) {
processPath(join(path, entries[i]), testFiles, options);
}

options.underTestDir = underTestDir;
}
}

function createTestFileList() {
const cwd = process.cwd();
const hasUserSuppliedPaths = process.argv.length > 1;
const testPaths = hasUserSuppliedPaths ?
ArrayPrototypeSlice(process.argv, 1) : [cwd];
const testFiles = new SafeSet();

try {
for (let i = 0; i < testPaths.length; i++) {
const absolutePath = resolve(testPaths[i]);

processPath(absolutePath, testFiles, { userSupplied: true });
}
} catch (err) {
if (err?.code === 'ENOENT') {
console.error(`Could not find '${err.path}'`);
process.exit(kGenericUserError);
}
const hasUserSuppliedPattern = process.argv.length > 1;
const patterns = hasUserSuppliedPattern ? ArrayPrototypeSlice(process.argv, 1) : [kDefaultPattern];
const { results, matchers } = glob(patterns, { __proto__: null, cwd, exclude: (name) => name === 'node_modules' });

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

return ArrayPrototypeSort(ArrayFrom(testFiles));
return ArrayPrototypeSort(ArrayFrom(results));
}

function filterExecArgv(arg, i, arr) {
Expand Down
15 changes: 4 additions & 11 deletions lib/internal/test_runner/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const {
SafeMap,
} = primordials;

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

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

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

function isSupportedFileType(p) {
return RegExpPrototypeExec(kSupportedFileExtensions, p) !== null;
}

function createDeferredCallback() {
let calledCount = 0;
Expand Down Expand Up @@ -299,9 +293,8 @@ module.exports = {
convertStringToRegExp,
countCompletedTest,
createDeferredCallback,
doesPathMatchFilter,
isSupportedFileType,
isTestFailureError,
kDefaultPattern,
parseCommandLine,
setupTestReporters,
getCoverageReport,
Expand Down
Loading

0 comments on commit daf9b42

Please sign in to comment.