Skip to content

Commit

Permalink
test_runner: support programmatically running --test
Browse files Browse the repository at this point in the history
  • Loading branch information
MoLow committed Aug 15, 2022
1 parent d6e626d commit 6a2fd8f
Show file tree
Hide file tree
Showing 6 changed files with 192 additions and 149 deletions.
23 changes: 23 additions & 0 deletions doc/api/test.md
Expand Up @@ -316,6 +316,29 @@ Otherwise, the test is considered to be a failure. Test files must be
executable by Node.js, but are not required to use the `node:test` module
internally.

## `runFiles([options])`

<!-- YAML
added: REPLACEME
-->

* `options` {Object} Configuration options for running test files. The following
properties are supported:
* `concurrency` {number|boolean} If a number is provided,
then that many files would run in parallel.
If truthy, it would run (number of cpu cores - 1)
files in parallel.
If falsy, it would only run one file at a time.
If unspecified, subtests inherit this value from their parent.
**Default:** `true`.
* `files`: {Array} An array containing the list of files to run.
**Default** matching files from [test runner execution model][].
* `signal` {AbortSignal} Allows aborting an in-progress test file.
* `timeout` {number} A number of milliseconds the test file will fail after.
If unspecified, subtests inherit this value from their parent.
**Default:** `Infinity`.
* Returns: {Promise} Resolved with `undefined` once all test files complete.

## `test([name][, options][, fn])`

<!-- YAML
Expand Down
140 changes: 2 additions & 138 deletions lib/internal/main/test_runner.js
@@ -1,147 +1,11 @@
'use strict';
const {
ArrayFrom,
ArrayPrototypeFilter,
ArrayPrototypeIncludes,
ArrayPrototypeJoin,
ArrayPrototypePush,
ArrayPrototypeSlice,
ArrayPrototypeSort,
SafePromiseAll,
SafeSet,
} = primordials;
const {
prepareMainThreadExecution,
markBootstrapComplete
} = require('internal/process/pre_execution');
const { spawn } = require('child_process');
const { readdirSync, statSync } = require('fs');
const console = require('internal/console/global');
const {
codes: {
ERR_TEST_FAILURE,
},
} = require('internal/errors');
const { test } = require('internal/test_runner/harness');
const { kSubtestsFailed } = require('internal/test_runner/test');
const {
isSupportedFileType,
doesPathMatchFilter,
} = require('internal/test_runner/utils');
const { basename, join, resolve } = require('path');
const { once } = require('events');
const kFilterArgs = ['--test'];
const { runFiles } = require('internal/test_runner/runner');

prepareMainThreadExecution(false);
markBootstrapComplete();

// 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(1);
}

throw err;
}

return ArrayPrototypeSort(ArrayFrom(testFiles));
}

function filterExecArgv(arg) {
return !ArrayPrototypeIncludes(kFilterArgs, arg);
}

function runTestFile(path) {
return test(path, async (t) => {
const args = ArrayPrototypeFilter(process.execArgv, filterExecArgv);
ArrayPrototypePush(args, path);

const child = spawn(process.execPath, args, { signal: t.signal, encoding: 'utf8' });
// TODO(cjihrig): Implement a TAP parser to read the child's stdout
// instead of just displaying it all if the child fails.
let err;

child.on('error', (error) => {
err = error;
});

const { 0: { 0: code, 1: signal }, 1: stdout, 2: stderr } = await SafePromiseAll([
once(child, 'exit', { signal: t.signal }),
child.stdout.toArray({ signal: t.signal }),
child.stderr.toArray({ signal: t.signal }),
]);

if (code !== 0 || signal !== null) {
if (!err) {
err = new ERR_TEST_FAILURE('test failed', kSubtestsFailed);
err.exitCode = code;
err.signal = signal;
err.stdout = ArrayPrototypeJoin(stdout, '');
err.stderr = ArrayPrototypeJoin(stderr, '');
// The stack will not be useful since the failures came from tests
// in a child process.
err.stack = undefined;
}

throw err;
}
});
}

(async function main() {
const testFiles = createTestFileList();

for (let i = 0; i < testFiles.length; i++) {
runTestFile(testFiles[i]);
}
})();
runFiles();
12 changes: 9 additions & 3 deletions lib/internal/test_runner/harness.js
Expand Up @@ -3,6 +3,7 @@ const {
ArrayPrototypeForEach,
FunctionPrototypeBind,
SafeMap,
SafeWeakMap,
} = primordials;
const {
createHook,
Expand All @@ -19,7 +20,11 @@ const { kCancelledByParent, Test, ItTest, Suite } = require('internal/test_runne
const isTestRunner = getOptionValue('--test');
const testResources = new SafeMap();
const root = new Test({ __proto__: null, name: '<root>' });
let wasRootSetup = false;
const wasRootSetup = new SafeWeakMap();

function createTestTree(options = {}) {
return setup(new Test({ __proto__: null, ...options, name: '<root>' }));
}

function createProcessEventHandler(eventName, rootTest) {
return (err) => {
Expand Down Expand Up @@ -48,7 +53,7 @@ function createProcessEventHandler(eventName, rootTest) {
}

function setup(root) {
if (wasRootSetup) {
if (wasRootSetup.has(root)) {
return root;
}
const hook = createHook({
Expand Down Expand Up @@ -146,7 +151,7 @@ function setup(root) {
root.reporter.pipe(process.stdout);
root.reporter.version();

wasRootSetup = true;
wasRootSetup.set(root, true);
return root;
}

Expand Down Expand Up @@ -184,6 +189,7 @@ function hook(hook) {
}

module.exports = {
createTestTree,
test: FunctionPrototypeBind(test, root),
describe: runInParentContext(Suite),
it: runInParentContext(ItTest),
Expand Down
154 changes: 154 additions & 0 deletions lib/internal/test_runner/runner.js
@@ -0,0 +1,154 @@
'use strict';
const {
ArrayFrom,
ArrayPrototypeFilter,
ArrayPrototypeIncludes,
ArrayPrototypeJoin,
ArrayPrototypeMap,
ArrayPrototypePush,
ArrayPrototypeSlice,
ArrayPrototypeSort,
SafePromiseAll,
SafeSet,
} = primordials;

const { spawn } = require('child_process');
const { readdirSync, statSync } = require('fs');
const console = require('internal/console/global');
const {
codes: {
ERR_TEST_FAILURE,
},
} = require('internal/errors');
const { validateArray } = require('internal/validators');
const { kEmptyObject } = require('internal/util');
const { createTestTree } = require('internal/test_runner/harness');
const { kSubtestsFailed, Test } = require('internal/test_runner/test');
const {
isSupportedFileType,
doesPathMatchFilter,
} = require('internal/test_runner/utils');
const { basename, join, resolve } = require('path');
const { once } = require('events');

const kFilterArgs = ['--test'];

// 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(1);
}

throw err;
}

return ArrayPrototypeSort(ArrayFrom(testFiles));
}

function filterExecArgv(arg) {
return !ArrayPrototypeIncludes(kFilterArgs, arg);
}

function runTestFile(path, root) {
const subtest = root.createSubtest(Test, path, async (t) => {
const args = ArrayPrototypeFilter(process.execArgv, filterExecArgv);
ArrayPrototypePush(args, path);

const child = spawn(process.execPath, args, { signal: t.signal, encoding: 'utf8' });
// TODO(cjihrig): Implement a TAP parser to read the child's stdout
// instead of just displaying it all if the child fails.
let err;

child.on('error', (error) => {
err = error;
});

const { 0: { 0: code, 1: signal }, 1: stdout, 2: stderr } = await SafePromiseAll([
once(child, 'exit', { signal: t.signal }),
child.stdout.toArray({ signal: t.signal }),
child.stderr.toArray({ signal: t.signal }),
]);

if (code !== 0 || signal !== null) {
if (!err) {
err = new ERR_TEST_FAILURE('test failed', kSubtestsFailed);
err.exitCode = code;
err.signal = signal;
err.stdout = ArrayPrototypeJoin(stdout, '');
err.stderr = ArrayPrototypeJoin(stderr, '');
// The stack will not be useful since the failures came from tests
// in a child process.
err.stack = undefined;
}

throw err;
}
});
return subtest.start();
}

async function runFiles(options) {
if (options === null || typeof options !== 'object') {
options = kEmptyObject;
}
const { concurrency, timeout, signal, files } = options;
const root = createTestTree({ concurrency, timeout, signal });

if (files !== undefined) {
validateArray(options, 'options.files');
}

await ArrayPrototypeMap(files ?? createTestFileList(), (path) => runTestFile(path, root));
}

module.exports = { runFiles };
2 changes: 1 addition & 1 deletion lib/internal/test_runner/test.js
Expand Up @@ -176,7 +176,7 @@ class Test extends AsyncResource {

case 'boolean':
if (concurrency) {
this.concurrency = isTestRunner ? MathMax(cpus().length - 1, 1) : Infinity;
this.concurrency = parent === null ? MathMax(cpus().length - 1, 1) : Infinity;
} else {
this.concurrency = 1;
}
Expand Down

0 comments on commit 6a2fd8f

Please sign in to comment.