diff --git a/doc/api/cli.md b/doc/api/cli.md index c51f51d206ef92..b9d63b08aa4a28 100644 --- a/doc/api/cli.md +++ b/doc/api/cli.md @@ -1064,6 +1064,25 @@ minimum allocation from the secure heap. The minimum value is `2`. The maximum value is the lesser of `--secure-heap` or `2147483647`. The value given must be a power of two. +### `--test` + + + +Starts the Node.js command line test runner. This flag cannot be combined with +`--check`, `--eval`, `--interactive`, or the inspector. See the documentation +on [running tests from the command line][] for more details. + +### `--test-only` + + + +Configures the test runner to only execute top level tests that have the `only` +option set. + ### `--throw-deprecation` + +> Stability: 1 - Experimental + + + +The `node:test` module facilitates the creation of JavaScript tests that +report results in [TAP][] format. To access it: + +```mjs +import test from 'node:test'; +``` + +```cjs +const test = require('node:test'); +``` + +This module is only available under the `node:` scheme. The following will not +work: + +```mjs +import test from 'test'; +``` + +```cjs +const test = require('test'); +``` + +Tests created via the `test` module consist of a single function that is +processed in one of three ways: + +1. A synchronous function that is considered failing if it throws an exception, + and is considered passing otherwise. +2. A function that returns a `Promise` that is considered failing if the + `Promise` rejects, and is considered passing if the `Promise` resolves. +3. A function that receives a callback function. If the callback receives any + truthy value as its first argument, the test is considered failing. If a + falsy value is passed as the first argument to the callback, the test is + considered passing. If the test function receives a callback function and + also returns a `Promise`, the test will fail. + +The following example illustrates how tests are written using the +`test` module. + +```js +test('synchronous passing test', (t) => { + // This test passes because it does not throw an exception. + assert.strictEqual(1, 1); +}); + +test('synchronous failing test', (t) => { + // This test fails because it throws an exception. + assert.strictEqual(1, 2); +}); + +test('asynchronous passing test', async (t) => { + // This test passes because the Promise returned by the async + // function is not rejected. + assert.strictEqual(1, 1); +}); + +test('asynchronous failing test', async (t) => { + // This test fails because the Promise returned by the async + // function is rejected. + assert.strictEqual(1, 2); +}); + +test('failing test using Promises', (t) => { + // Promises can be used directly as well. + return new Promise((resolve, reject) => { + setImmediate(() => { + reject(new Error('this will cause the test to fail')); + }); + }); +}); + +test('callback passing test', (t, done) => { + // done() is the callback function. When the setImmediate() runs, it invokes + // done() with no arguments. + setImmediate(done); +}); + +test('callback failing test', (t, done) => { + // When the setImmediate() runs, done() is invoked with an Error object and + // the test fails. + setImmediate(() => { + done(new Error('callback failure')); + }); +}); +``` + +As a test file executes, TAP is written to the standard output of the Node.js +process. This output can be interpreted by any test harness that understands +the TAP format. If any tests fail, the process exit code is set to `1`. + +## Subtests + +The test context's `test()` method allows subtests to be created. This method +behaves identically to the top level `test()` function. The following example +demonstrates the creation of a top level test with two subtests. + +```js +test('top level test', async (t) => { + await t.test('subtest 1', (t) => { + assert.strictEqual(1, 1); + }); + + await t.test('subtest 2', (t) => { + assert.strictEqual(2, 2); + }); +}); +``` + +In this example, `await` is used to ensure that both subtests have completed. +This is necessary because parent tests do not wait for their subtests to +complete. Any subtests that are still outstanding when their parent finishes +are cancelled and treated as failures. Any subtest failures cause the parent +test to fail. + +## Skipping tests + +Individual tests can be skipped by passing the `skip` option to the test, or by +calling the test context's `skip()` method. Both of these options support +including a message that is displayed in the TAP output as shown in the +following example. + +```js +// The skip option is used, but no message is provided. +test('skip option', { skip: true }, (t) => { + // This code is never executed. +}); + +// The skip option is used, and a message is provided. +test('skip option with message', { skip: 'this is skipped' }, (t) => { + // This code is never executed. +}); + +test('skip() method', (t) => { + // Make sure to return here as well if the test contains additional logic. + t.skip(); +}); + +test('skip() method with message', (t) => { + // Make sure to return here as well if the test contains additional logic. + t.skip('this is skipped'); +}); +``` + +### `only` tests + +If Node.js is started with the [`--test-only`][] command-line option, it is +possible to skip all top level tests except for a selected subset by passing +the `only` option to the tests that should be run. When a test with the `only` +option set is run, all subtests are also run. The test context's `runOnly()` +method can be used to implement the same behavior at the subtest level. + +```js +// Assume Node.js is run with the --test-only command-line option. +// The 'only' option is set, so this test is run. +test('this test is run', { only: true }, async (t) => { + // Within this test, all subtests are run by default. + await t.test('running subtest'); + + // The test context can be updated to run subtests with the 'only' option. + t.runOnly(true); + await t.test('this subtest is now skipped'); + await t.test('this subtest is run', { only: true }); + + // Switch the context back to execute all tests. + t.runOnly(false); + await t.test('this subtest is now run'); + + // Explicitly do not run these tests. + await t.test('skipped subtest 3', { only: false }); + await t.test('skipped subtest 4', { skip: true }); +}); + +// The 'only' option is not set, so this test is skipped. +test('this test is not run', () => { + // This code is not run. + throw new Error('fail'); +}); +``` + +## Extraneous asynchronous activity + +Once a test function finishes executing, the TAP results are output as quickly +as possible while maintaining the order of the tests. However, it is possible +for the test function to generate asynchronous activity that outlives the test +itself. The test runner handles this type of activity, but does not delay the +reporting of test results in order to accommodate it. + +In the following example, a test completes with two `setImmediate()` +operations still outstanding. The first `setImmediate()` attempts to create a +new subtest. Because the parent test has already finished and output its +results, the new subtest is immediately marked as failed, and reported in the +top level of the file's TAP output. + +The second `setImmediate()` creates an `uncaughtException` event. +`uncaughtException` and `unhandledRejection` events originating from a completed +test are handled by the `test` module and reported as diagnostic warnings in +the top level of the file's TAP output. + +```js +test('a test that creates asynchronous activity', (t) => { + setImmediate(() => { + t.test('subtest that is created too late', (t) => { + throw new Error('error1'); + }); + }); + + setImmediate(() => { + throw new Error('error2'); + }); + + // The test finishes after this line. +}); +``` + +## Running tests from the command line + +The Node.js test runner can be invoked from the command line by passing the +[`--test`][] flag: + +```bash +node --test +``` + +By default, Node.js will recursively search the current directory for +JavaScript source files matching a specific naming convention. Matching files +are executed as test files. More information on the expected test file naming +convention and behavior can be found in the [test runner execution model][] +section. + +Alternatively, one or more paths can be provided as the final argument(s) to +the Node.js command, as shown below. + +```bash +node --test test1.js test2.mjs custom_test_dir/ +``` + +In this example, the test runner will execute the files `test1.js` and +`test2.mjs`. The test runner will also recursively search the +`custom_test_dir/` directory for test files to execute. + +### Test runner execution model + +When searching for test files to execute, the test runner behaves as follows: + +* Any files explicitly provided by the user are executed. +* If the user did not explicitly specify any paths, the current working + directory is recursively searched for files as specified in the following + steps. +* `node_modules` directories are skipped unless explicitly provided by the + user. +* If a directory named `test` is encountered, the test runner will search it + recursively for all all `.js`, `.cjs`, and `.mjs` files. All of these files + are treated as test files, and do not need to match the specific naming + convention detailed below. This is to accommodate projects that place all of + their tests in a single `test` directory. +* In all other directories, `.js`, `.cjs`, and `.mjs` files matching the + following patterns are treated as test files: + * `^test$` - Files whose basename is the string `'test'`. Examples: + `test.js`, `test.cjs`, `test.mjs`. + * `^test-.+` - Files whose basename starts with the string `'test-'` + followed by one or more characters. Examples: `test-example.js`, + `test-another-example.mjs`. + * `.+[\.\-\_]test$` - Files whose basename ends with `.test`, `-test`, or + `_test`, preceded by one or more characters. Examples: `example.test.js`, + `example-test.cjs`, `example_test.mjs`. + * Other file types understood by Node.js such as `.node` and `.json` are not + automatically executed by the test runner, but are supported if explicitly + provided on the command line. + +Each matching test file is executed in a separate child process. If the child +process finishes with an exit code of 0, the test is considered passing. +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. + +## `test([name][, options][, fn])` + + + +* `name` {string} The name of the test, which is displayed when reporting test + results. **Default:** The `name` property of `fn`, or `''` if `fn` + does not have a name. +* `options` {Object} Configuration options for the test. The following + properties are supported: + * `concurrency` {number} The number of tests that can be run at the same time. + If unspecified, subtests inherit this value from their parent. + **Default:** `1`. + * `only` {boolean} If truthy, and the test context is configured to run + `only` tests, then this test will be run. Otherwise, the test is skipped. + **Default:** `false`. + * `skip` {boolean|string} If truthy, the test is skipped. If a string is + provided, that string is displayed in the test results as the reason for + skipping the test. **Default:** `false`. + * `todo` {boolean|string} If truthy, the test marked as `TODO`. If a string + is provided, that string is displayed in the test results as the reason why + the test is `TODO`. **Default:** `false`. +* `fn` {Function|AsyncFunction} The function under test. This first argument + to this function is a [`TestContext`][] object. If the test uses callbacks, + the callback function is passed as the second argument. **Default:** A no-op + function. +* Returns: {Promise} Resolved with `undefined` once the test completes. + +The `test()` function is the value imported from the `test` module. Each +invocation of this function results in the creation of a test point in the TAP +output. + +The `TestContext` object passed to the `fn` argument can be used to perform +actions related to the current test. Examples include skipping the test, adding +additional TAP diagnostic information, or creating subtests. + +`test()` returns a `Promise` that resolves once the test completes. The return +value can usually be discarded for top level tests. However, the return value +from subtests should be used to prevent the parent test from finishing first +and cancelling the subtest as shown in the following example. + +```js +test('top level test', async (t) => { + // The setTimeout() in the following subtest would cause it to outlive its + // parent test if 'await' is removed on the next line. Once the parent test + // completes, it will cancel any outstanding subtests. + await t.test('longer running subtest', async (t) => { + return new Promise((resolve, reject) => { + setTimeout(resolve, 1000); + }); + }); +}); +``` + +## Class: `TestContext` + + + +An instance of `TestContext` is passed to each test function in order to +interact with the test runner. However, the `TestContext` constructor is not +exposed as part of the API. + +### `context.diagnostic(message)` + + + +* `message` {string} Message to be displayed as a TAP diagnostic. + +This function is used to write TAP diagnostics to the output. Any diagnostic +information is included at the end of the test's results. This function does +not return a value. + +### `context.runOnly(shouldRunOnlyTests)` + + + +* `shouldRunOnlyTests` {boolean} Whether or not to run `only` tests. + +If `shouldRunOnlyTests` is truthy, the test context will only run tests that +have the `only` option set. Otherwise, all tests are run. If Node.js was not +started with the [`--test-only`][] command-line option, this function is a +no-op. + +### `context.skip([message])` + + + +* `message` {string} Optional skip message to be displayed in TAP output. + +This function causes the test's output to indicate the test as skipped. If +`message` is provided, it is included in the TAP output. Calling `skip()` does +not terminate execution of the test function. This function does not return a +value. + +### `context.todo([message])` + + + +* `message` {string} Optional `TODO` message to be displayed in TAP output. + +This function adds a `TODO` directive to the test's output. If `message` is +provided, it is included in the TAP output. Calling `todo()` does not terminate +execution of the test function. This function does not return a value. + +### `context.test([name][, options][, fn])` + + + +* `name` {string} The name of the subtest, which is displayed when reporting + test results. **Default:** The `name` property of `fn`, or `''` if + `fn` does not have a name. +* `options` {Object} Configuration options for the subtest. The following + properties are supported: + * `concurrency` {number} The number of tests that can be run at the same time. + If unspecified, subtests inherit this value from their parent. + **Default:** `1`. + * `only` {boolean} If truthy, and the test context is configured to run + `only` tests, then this test will be run. Otherwise, the test is skipped. + **Default:** `false`. + * `skip` {boolean|string} If truthy, the test is skipped. If a string is + provided, that string is displayed in the test results as the reason for + skipping the test. **Default:** `false`. + * `todo` {boolean|string} If truthy, the test marked as `TODO`. If a string + is provided, that string is displayed in the test results as the reason why + the test is `TODO`. **Default:** `false`. +* `fn` {Function|AsyncFunction} The function under test. This first argument + to this function is a [`TestContext`][] object. If the test uses callbacks, + the callback function is passed as the second argument. **Default:** A no-op + function. +* Returns: {Promise} Resolved with `undefined` once the test completes. + +This function is used to create subtests under the current test. This function +behaves in the same fashion as the top level [`test()`][] function. + +[TAP]: https://testanything.org/ +[`--test-only`]: cli.md#--test-only +[`--test`]: cli.md#--test +[`TestContext`]: #class-testcontext +[`test()`]: #testname-options-fn +[test runner execution model]: #test-runner-execution-model diff --git a/doc/node.1 b/doc/node.1 index bcc5ae7b5cf3e2..6ace5620506db4 100644 --- a/doc/node.1 +++ b/doc/node.1 @@ -378,6 +378,13 @@ the secure heap. The default is 0. The value must be a power of two. .It Fl -secure-heap-min Ns = Ns Ar n Specify the minimum allocation from the OpenSSL secure heap. The default is 2. The value must be a power of two. . +.It Fl -test +Starts the Node.js command line test runner. +. +.It Fl -test-only +Configures the test runner to only execute top level tests that have the `only` +option set. +. .It Fl -throw-deprecation Throw errors for deprecations. . diff --git a/lib/internal/bootstrap/loaders.js b/lib/internal/bootstrap/loaders.js index f5ea60a6ab6228..41162fabcbc589 100644 --- a/lib/internal/bootstrap/loaders.js +++ b/lib/internal/bootstrap/loaders.js @@ -44,6 +44,7 @@ /* global process, getLinkedBinding, getInternalBinding, primordials */ const { + ArrayFrom, ArrayPrototypeMap, ArrayPrototypePush, ArrayPrototypeSlice, @@ -120,6 +121,11 @@ const legacyWrapperList = new SafeSet([ 'util', ]); +// Modules that can only be imported via the node: scheme. +const schemelessBlockList = new SafeSet([ + 'test', +]); + // Set up process.binding() and process._linkedBinding(). { const bindingObj = ObjectCreate(null); @@ -243,6 +249,16 @@ class NativeModule { return mod && mod.canBeRequiredByUsers; } + // Determine if a core module can be loaded without the node: prefix. This + // function does not validate if the module actually exists. + static canBeRequiredWithoutScheme(id) { + return !schemelessBlockList.has(id); + } + + static getSchemeOnlyModuleNames() { + return ArrayFrom(schemelessBlockList); + } + // Used by user-land module loaders to compile and load builtins. compileForPublicLoader() { if (!this.canBeRequiredByUsers) { diff --git a/lib/internal/errors.js b/lib/internal/errors.js index fc341c4e2b17ac..0c82c2fb355968 100644 --- a/lib/internal/errors.js +++ b/lib/internal/errors.js @@ -202,6 +202,16 @@ function isErrorStackTraceLimitWritable() { desc.set !== undefined; } +function inspectWithNoCustomRetry(obj, options) { + const utilInspect = lazyInternalUtilInspect(); + + try { + return utilInspect.inspect(obj, options); + } catch { + return utilInspect.inspect(obj, { ...options, customInspect: false }); + } +} + // A specialized Error that includes an additional info property with // additional information about the error condition. // It has the properties present in a UVException but with a custom error @@ -887,6 +897,7 @@ module.exports = { getMessage, hideInternalStackFrames, hideStackFrames, + inspectWithNoCustomRetry, isErrorStackTraceLimitWritable, isStackOverflowError, kEnhanceStackBeforeInspector, @@ -1557,6 +1568,21 @@ E('ERR_STREAM_WRAP', 'Stream has StringDecoder set or is in objectMode', Error); E('ERR_STREAM_WRITE_AFTER_END', 'write after end', Error); E('ERR_SYNTHETIC', 'JavaScript Callstack', Error); E('ERR_SYSTEM_ERROR', 'A system error occurred', SystemError); +E('ERR_TEST_FAILURE', function(error, failureType) { + hideInternalStackFrames(this); + assert(typeof failureType === 'string', + "The 'failureType' argument must be of type string."); + + let msg = error?.message ?? error; + + if (typeof msg !== 'string') { + msg = inspectWithNoCustomRetry(msg); + } + + this.failureType = failureType; + this.cause = error; + return msg; +}, Error); E('ERR_TLS_CERT_ALTNAME_FORMAT', 'Invalid subject alternative name string', SyntaxError); E('ERR_TLS_CERT_ALTNAME_INVALID', function(reason, host, cert) { diff --git a/lib/internal/main/test_runner.js b/lib/internal/main/test_runner.js new file mode 100644 index 00000000000000..71bf21782f39f3 --- /dev/null +++ b/lib/internal/main/test_runner.js @@ -0,0 +1,157 @@ +'use strict'; +const { + ArrayFrom, + ArrayPrototypeFilter, + ArrayPrototypeIncludes, + ArrayPrototypePush, + ArrayPrototypeSlice, + ArrayPrototypeSort, + Promise, + SafeSet, +} = primordials; +const { + prepareMainThreadExecution, +} = require('internal/bootstrap/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 kFilterArgs = ['--test']; + +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, () => { + return new Promise((resolve, reject) => { + const args = ArrayPrototypeFilter(process.execArgv, filterExecArgv); + ArrayPrototypePush(args, path); + + const child = spawn(process.execPath, args); + // TODO(cjihrig): Implement a TAP parser to read the child's stdout + // instead of just displaying it all if the child fails. + let stdout = ''; + let stderr = ''; + let err; + + child.on('error', (error) => { + err = error; + }); + + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + + child.stdout.on('data', (chunk) => { + stdout += chunk; + }); + + child.stderr.on('data', (chunk) => { + stderr += chunk; + }); + + child.once('exit', (code, signal) => { + if (code !== 0 || signal !== null) { + if (!err) { + err = new ERR_TEST_FAILURE('test failed', kSubtestsFailed); + err.exitCode = code; + err.signal = signal; + err.stdout = stdout; + err.stderr = stderr; + // The stack will not be useful since the failures came from tests + // in a child process. + err.stack = undefined; + } + + return reject(err); + } + + resolve(); + }); + }); + }); +} + +(async function main() { + const testFiles = createTestFileList(); + + for (let i = 0; i < testFiles.length; i++) { + runTestFile(testFiles[i]); + } +})(); diff --git a/lib/internal/modules/cjs/loader.js b/lib/internal/modules/cjs/loader.js index 24d283ebbe036c..740985a381dbaa 100644 --- a/lib/internal/modules/cjs/loader.js +++ b/lib/internal/modules/cjs/loader.js @@ -182,7 +182,8 @@ function Module(id = '', parent) { const builtinModules = []; for (const { 0: id, 1: mod } of NativeModule.map) { - if (mod.canBeRequiredByUsers) { + if (mod.canBeRequiredByUsers && + NativeModule.canBeRequiredWithoutScheme(id)) { ArrayPrototypePush(builtinModules, id); } } @@ -805,7 +806,13 @@ Module._load = function(request, parent, isMain) { } const mod = loadNativeModule(filename, request); - if (mod?.canBeRequiredByUsers) return mod.exports; + if (mod?.canBeRequiredByUsers) { + if (!NativeModule.canBeRequiredWithoutScheme(filename)) { + throw new ERR_UNKNOWN_BUILTIN_MODULE(filename); + } + + return mod.exports; + } // Don't call updateChildren(), Module constructor already does. const module = cachedModule || new Module(filename, parent); diff --git a/lib/internal/modules/esm/resolve.js b/lib/internal/modules/esm/resolve.js index f927c74e718641..7b6537ff8ba187 100644 --- a/lib/internal/modules/esm/resolve.js +++ b/lib/internal/modules/esm/resolve.js @@ -57,6 +57,7 @@ const { ERR_PACKAGE_PATH_NOT_EXPORTED, ERR_UNSUPPORTED_DIR_IMPORT, ERR_NETWORK_IMPORT_DISALLOWED, + ERR_UNKNOWN_BUILTIN_MODULE, ERR_UNSUPPORTED_ESM_URL_SCHEME, } = require('internal/errors').codes; const { Module: CJSModule } = require('internal/modules/cjs/loader'); @@ -887,8 +888,13 @@ function parsePackageName(specifier, base) { * @returns {URL} */ function packageResolve(specifier, base, conditions) { - if (NativeModule.canBeRequiredByUsers(specifier)) + if (NativeModule.canBeRequiredByUsers(specifier)) { + if (!NativeModule.canBeRequiredWithoutScheme(specifier)) { + throw new ERR_UNKNOWN_BUILTIN_MODULE(specifier); + } + return new URL('node:' + specifier); + } const { packageName, packageSubpath, isScoped } = parsePackageName(specifier, base); diff --git a/lib/internal/test_runner/harness.js b/lib/internal/test_runner/harness.js new file mode 100644 index 00000000000000..66544d91522495 --- /dev/null +++ b/lib/internal/test_runner/harness.js @@ -0,0 +1,131 @@ +'use strict'; +const { FunctionPrototypeBind, SafeMap } = primordials; +const { + createHook, + executionAsyncId, +} = require('async_hooks'); +const { + codes: { + ERR_TEST_FAILURE, + }, +} = require('internal/errors'); +const { Test } = require('internal/test_runner/test'); + +function createProcessEventHandler(eventName, rootTest, testResources) { + return (err) => { + // Check if this error is coming from a test. If it is, fail the test. + const test = testResources.get(executionAsyncId()); + + if (test !== undefined) { + if (test.finished) { + // If the test is already finished, report this as a top level + // diagnostic since this is a malformed test. + const msg = `Warning: Test "${test.name}" generated asynchronous ` + + 'activity after the test ended. This activity created the error ' + + `"${err}" and would have caused the test to fail, but instead ` + + `triggered an ${eventName} event.`; + + rootTest.diagnostic(msg); + return; + } + + test.fail(new ERR_TEST_FAILURE(err, eventName)); + test.postRun(); + } + }; +} + +function setup(root) { + const testResources = new SafeMap(); + const hook = createHook({ + init(asyncId, type, triggerAsyncId, resource) { + if (resource instanceof Test) { + testResources.set(asyncId, resource); + return; + } + + const parent = testResources.get(triggerAsyncId); + + if (parent !== undefined) { + testResources.set(asyncId, parent); + } + }, + destroy(asyncId) { + testResources.delete(asyncId); + } + }); + + hook.enable(); + + const exceptionHandler = + createProcessEventHandler('uncaughtException', root, testResources); + const rejectionHandler = + createProcessEventHandler('unhandledRejection', root, testResources); + + process.on('uncaughtException', exceptionHandler); + process.on('unhandledRejection', rejectionHandler); + process.on('beforeExit', () => { + root.postRun(); + + let passCount = 0; + let failCount = 0; + let skipCount = 0; + let todoCount = 0; + + for (let i = 0; i < root.subtests.length; i++) { + const test = root.subtests[i]; + + // Check SKIP and TODO tests first, as those should not be counted as + // failures. + if (test.skipped) { + skipCount++; + } else if (test.isTodo) { + todoCount++; + } else if (!test.passed) { + failCount++; + } else { + passCount++; + } + } + + root.reporter.plan(root.indent, root.subtests.length); + + for (let i = 0; i < root.diagnostics.length; i++) { + root.reporter.diagnostic(root.indent, root.diagnostics[i]); + } + + root.reporter.diagnostic(root.indent, `tests ${root.subtests.length}`); + root.reporter.diagnostic(root.indent, `pass ${passCount}`); + root.reporter.diagnostic(root.indent, `fail ${failCount}`); + root.reporter.diagnostic(root.indent, `skipped ${skipCount}`); + root.reporter.diagnostic(root.indent, `todo ${todoCount}`); + root.reporter.diagnostic(root.indent, `duration_ms ${process.uptime()}`); + + root.reporter.push(null); + hook.disable(); + process.removeListener('unhandledRejection', rejectionHandler); + process.removeListener('uncaughtException', exceptionHandler); + + if (failCount > 0) { + process.exitCode = 1; + } + }); + + root.reporter.pipe(process.stdout); + root.reporter.version(); +} + +function test(name, options, fn) { + // If this is the first test encountered, bootstrap the test harness. + if (this.subtests.length === 0) { + setup(this); + } + + const subtest = this.createSubtest(name, options, fn); + + return subtest.start(); +} + +const root = new Test({ name: '' }); + +module.exports = FunctionPrototypeBind(test, root); diff --git a/lib/internal/test_runner/tap_stream.js b/lib/internal/test_runner/tap_stream.js new file mode 100644 index 00000000000000..a6bfbb3367cd79 --- /dev/null +++ b/lib/internal/test_runner/tap_stream.js @@ -0,0 +1,220 @@ +'use strict'; +const { + ArrayPrototypeForEach, + ArrayPrototypeJoin, + ArrayPrototypePush, + ArrayPrototypeShift, + ObjectEntries, + StringPrototypeReplaceAll, + StringPrototypeSplit, + RegExpPrototypeSymbolReplace, +} = primordials; +const { inspectWithNoCustomRetry } = require('internal/errors'); +const Readable = require('internal/streams/readable'); +const { isError } = require('internal/util'); +const kFrameStartRegExp = /^ {4}at /; +const kLineBreakRegExp = /\n|\r\n/; +const inspectOptions = { colors: false, breakLength: Infinity }; +let testModule; // Lazy loaded due to circular dependency. + +function lazyLoadTest() { + testModule ??= require('internal/test_runner/test'); + + return testModule; +} + +class TapStream extends Readable { + #buffer; + #canPush; + + constructor() { + super(); + this.#buffer = []; + this.#canPush = true; + } + + _read() { + this.#canPush = true; + + while (this.#buffer.length > 0) { + const line = ArrayPrototypeShift(this.#buffer); + + if (!this.#tryPush(line)) { + return; + } + } + } + + bail(message) { + this.#tryPush(`Bail out!${message ? ` ${tapEscape(message)}` : ''}\n`); + } + + fail(indent, testNumber, description, directive) { + this.#test(indent, testNumber, 'not ok', description, directive); + } + + ok(indent, testNumber, description, directive) { + this.#test(indent, testNumber, 'ok', description, directive); + } + + plan(indent, count, explanation) { + const exp = `${explanation ? ` # ${tapEscape(explanation)}` : ''}`; + + this.#tryPush(`${indent}1..${count}${exp}\n`); + } + + getSkip(reason) { + return `SKIP${reason ? ` ${tapEscape(reason)}` : ''}`; + } + + getTodo(reason) { + return `TODO${reason ? ` ${tapEscape(reason)}` : ''}`; + } + + details(indent, duration, error) { + let details = `${indent} ---\n`; + + details += jsToYaml(indent, 'duration_ms', duration); + details += jsToYaml(indent, null, error); + details += `${indent} ...\n`; + this.#tryPush(details); + } + + diagnostic(indent, message) { + this.#tryPush(`${indent}# ${tapEscape(message)}\n`); + } + + version() { + this.#tryPush('TAP version 13\n'); + } + + #test(indent, testNumber, status, description, directive) { + let line = `${indent}${status} ${testNumber}`; + + if (description) { + line += ` ${tapEscape(description)}`; + } + + if (directive) { + line += ` # ${directive}`; + } + + line += '\n'; + this.#tryPush(line); + } + + #tryPush(message) { + if (this.#canPush) { + this.#canPush = this.push(message); + } else { + ArrayPrototypePush(this.#buffer, message); + } + + return this.#canPush; + } +} + +// In certain places, # and \ need to be escaped as \# and \\. +function tapEscape(input) { + return StringPrototypeReplaceAll( + StringPrototypeReplaceAll(input, '\\', '\\\\'), '#', '\\#' + ); +} + +function jsToYaml(indent, name, value) { + if (value === null || value === undefined) { + return ''; + } + + if (typeof value !== 'object') { + const prefix = `${indent} ${name}: `; + + if (typeof value !== 'string') { + return `${prefix}${inspectWithNoCustomRetry(value, inspectOptions)}\n`; + } + + const lines = StringPrototypeSplit(value, kLineBreakRegExp); + + if (lines.length === 1) { + return `${prefix}${inspectWithNoCustomRetry(value, inspectOptions)}\n`; + } + + let str = `${prefix}|-\n`; + + for (let i = 0; i < lines.length; i++) { + str += `${indent} ${lines[i]}\n`; + } + + return str; + } + + const entries = ObjectEntries(value); + const isErrorObj = isError(value); + let result = ''; + + for (let i = 0; i < entries.length; i++) { + const { 0: key, 1: value } = entries[i]; + + if (isErrorObj && (key === 'cause' || key === 'code')) { + continue; + } + + result += jsToYaml(indent, key, value); + } + + if (isErrorObj) { + const { kTestCodeFailure } = lazyLoadTest(); + const { + cause, + code, + failureType, + message, + stack, + } = value; + let errMsg = message ?? ''; + let errStack = stack; + let errCode = code; + + // If the ERR_TEST_FAILURE came from an error provided by user code, + // then try to unwrap the original error message and stack. + if (code === 'ERR_TEST_FAILURE' && failureType === kTestCodeFailure) { + errMsg = cause?.message ?? errMsg; + errStack = cause?.stack ?? errStack; + errCode = cause?.code ?? errCode; + } + + result += jsToYaml(indent, 'error', errMsg); + + if (errCode) { + result += jsToYaml(indent, 'code', errCode); + } + + if (typeof errStack === 'string') { + const frames = []; + + ArrayPrototypeForEach( + StringPrototypeSplit(errStack, kLineBreakRegExp), + (frame) => { + const processed = RegExpPrototypeSymbolReplace( + kFrameStartRegExp, frame, '' + ); + + if (processed.length > 0 && processed.length !== frame.length) { + ArrayPrototypePush(frames, processed); + } + } + ); + + if (frames.length > 0) { + const frameDelimiter = `\n${indent} `; + + result += `${indent} stack: |-${frameDelimiter}`; + result += `${ArrayPrototypeJoin(frames, `${frameDelimiter}`)}\n`; + } + } + } + + return result; +} + +module.exports = { TapStream }; diff --git a/lib/internal/test_runner/test.js b/lib/internal/test_runner/test.js new file mode 100644 index 00000000000000..d35bf6307bfb15 --- /dev/null +++ b/lib/internal/test_runner/test.js @@ -0,0 +1,450 @@ +'use strict'; +const { + ArrayPrototypePush, + ArrayPrototypeShift, + FunctionPrototype, + Number, + ObjectCreate, + SafeMap, +} = primordials; +const { AsyncResource } = require('async_hooks'); +const { + codes: { + ERR_TEST_FAILURE, + }, + kIsNodeError, +} = require('internal/errors'); +const { getOptionValue } = require('internal/options'); +const { TapStream } = require('internal/test_runner/tap_stream'); +const { createDeferredPromise } = require('internal/util'); +const { isPromise } = require('internal/util/types'); +const { isUint32 } = require('internal/validators'); +const { cpus } = require('os'); +const { bigint: hrtime } = process.hrtime; +const kCallbackAndPromisePresent = 'callbackAndPromisePresent'; +const kCancelledByParent = 'cancelledByParent'; +const kMultipleCallbackInvocations = 'multipleCallbackInvocations'; +const kParentAlreadyFinished = 'parentAlreadyFinished'; +const kSubtestsFailed = 'subtestsFailed'; +const kTestCodeFailure = 'testCodeFailure'; +const kDefaultIndent = ' '; +const noop = FunctionPrototype; +const isTestRunner = getOptionValue('--test'); +const testOnlyFlag = !isTestRunner && getOptionValue('--test-only'); +// TODO(cjihrig): Use uv_available_parallelism() once it lands. +const rootConcurrency = isTestRunner ? cpus().length : 1; + +class TestContext { + #test; + + constructor(test) { + this.#test = test; + } + + diagnostic(message) { + this.#test.diagnostic(message); + } + + runOnly(value) { + this.#test.runOnlySubtests = !!value; + } + + skip(message) { + this.#test.skip(message); + } + + todo(message) { + this.#test.todo(message); + } + + test(name, options, fn) { + const subtest = this.#test.createSubtest(name, options, fn); + + return subtest.start(); + } +} + +class Test extends AsyncResource { + constructor(options) { + super('Test'); + + let { fn, name, parent, skip } = options; + const { concurrency, only, todo } = options; + + if (typeof fn !== 'function') { + fn = noop; + } + + if (typeof name !== 'string' || name === '') { + name = fn.name || ''; + } + + if (!(parent instanceof Test)) { + parent = null; + } + + if (parent === null) { + this.concurrency = rootConcurrency; + this.indent = ''; + this.indentString = kDefaultIndent; + this.only = testOnlyFlag; + this.reporter = new TapStream(); + this.runOnlySubtests = this.only; + this.testNumber = 0; + } else { + const indent = parent.parent === null ? parent.indent : + parent.indent + parent.indentString; + + this.concurrency = parent.concurrency; + this.indent = indent; + this.indentString = parent.indentString; + this.only = only ?? !parent.runOnlySubtests; + this.reporter = parent.reporter; + this.runOnlySubtests = !this.only; + this.testNumber = parent.subtests.length + 1; + } + + if (isUint32(concurrency) && concurrency !== 0) { + this.concurrency = concurrency; + } + + if (testOnlyFlag && !this.only) { + skip = '\'only\' option not set'; + } + + if (skip) { + fn = noop; + } + + this.fn = fn; + this.name = name; + this.parent = parent; + this.cancelled = false; + this.skipped = !!skip; + this.isTodo = !!todo; + this.startTime = null; + this.endTime = null; + this.passed = false; + this.error = null; + this.diagnostics = []; + this.message = typeof skip === 'string' ? skip : + typeof todo === 'string' ? todo : null; + this.activeSubtests = 0; + this.pendingSubtests = []; + this.readySubtests = new SafeMap(); + this.subtests = []; + this.waitingOn = 0; + this.finished = false; + } + + hasConcurrency() { + return this.concurrency > this.activeSubtests; + } + + addPendingSubtest(deferred) { + this.pendingSubtests.push(deferred); + } + + async processPendingSubtests() { + while (this.pendingSubtests.length > 0 && this.hasConcurrency()) { + const deferred = ArrayPrototypeShift(this.pendingSubtests); + await deferred.test.run(); + deferred.resolve(); + } + } + + addReadySubtest(subtest) { + this.readySubtests.set(subtest.testNumber, subtest); + } + + processReadySubtestRange(canSend) { + const start = this.waitingOn; + const end = start + this.readySubtests.size; + + for (let i = start; i < end; i++) { + const subtest = this.readySubtests.get(i); + + // Check if the specified subtest is in the map. If it is not, return + // early to avoid trying to process any more tests since they would be + // out of order. + if (subtest === undefined) { + return; + } + + // Call isClearToSend() in the loop so that it is: + // - Only called if there are results to report in the correct order. + // - Guaranteed to only be called a maximum of once per call to + // processReadySubtestRange(). + canSend = canSend || this.isClearToSend(); + + if (!canSend) { + return; + } + + // Report the subtest's results and remove it from the ready map. + subtest.finalize(); + this.readySubtests.delete(i); + } + } + + createSubtest(name, options, fn) { + if (typeof name === 'function') { + fn = name; + } else if (name !== null && typeof name === 'object') { + fn = options; + options = name; + } else if (typeof options === 'function') { + fn = options; + } + + if (options === null || typeof options !== 'object') { + options = ObjectCreate(null); + } + + let parent = this; + + // If this test has already ended, attach this test to the root test so + // that the error can be properly reported. + if (this.finished) { + while (parent.parent !== null) { + parent = parent.parent; + } + } + + const test = new Test({ fn, name, parent, ...options }); + + if (parent.waitingOn === 0) { + parent.waitingOn = test.testNumber; + } + + if (this.finished) { + test.fail( + new ERR_TEST_FAILURE( + 'test could not be started because its parent finished', + kParentAlreadyFinished + ) + ); + } + + ArrayPrototypePush(parent.subtests, test); + return test; + } + + cancel() { + if (this.endTime !== null) { + return; + } + + this.fail( + new ERR_TEST_FAILURE( + 'test did not finish before its parent and was cancelled', + kCancelledByParent + ) + ); + this.cancelled = true; + } + + fail(err) { + if (this.error !== null) { + return; + } + + this.endTime = hrtime(); + this.passed = false; + this.error = err; + } + + pass() { + if (this.endTime !== null) { + return; + } + + this.endTime = hrtime(); + this.passed = true; + } + + skip(message) { + this.skipped = true; + this.message = message; + } + + todo(message) { + this.isTodo = true; + this.message = message; + } + + diagnostic(message) { + ArrayPrototypePush(this.diagnostics, message); + } + + start() { + // If there is enough available concurrency to run the test now, then do + // it. Otherwise, return a Promise to the caller and mark the test as + // pending for later execution. + if (!this.parent.hasConcurrency()) { + const deferred = createDeferredPromise(); + + deferred.test = this; + this.parent.addPendingSubtest(deferred); + return deferred.promise; + } + + return this.run(); + } + + async run() { + this.parent.activeSubtests++; + this.startTime = hrtime(); + + try { + const ctx = new TestContext(this); + + if (this.fn.length === 2) { + // This test is using legacy Node.js error first callbacks. + const { promise, resolve, reject } = createDeferredPromise(); + let calledCount = 0; + const ret = this.runInAsyncScope(this.fn, ctx, ctx, (err) => { + calledCount++; + + // If the callback is called a second time, let the user know, but + // don't let them know more than once. + if (calledCount > 1) { + if (calledCount === 2) { + throw new ERR_TEST_FAILURE( + 'callback invoked multiple times', + kMultipleCallbackInvocations + ); + } + + return; + } + + if (err) { + return reject(err); + } + + resolve(); + }); + + if (isPromise(ret)) { + this.fail(new ERR_TEST_FAILURE( + 'passed a callback but also returned a Promise', + kCallbackAndPromisePresent + )); + await ret; + } else { + await promise; + } + } else { + // This test is synchronous or using Promises. + await this.runInAsyncScope(this.fn, ctx, ctx); + } + + this.pass(); + } catch (err) { + if (err?.code === 'ERR_TEST_FAILURE' && kIsNodeError in err) { + this.fail(err); + } else { + this.fail(new ERR_TEST_FAILURE(err, kTestCodeFailure)); + } + } + + // Clean up the test. Then, try to report the results and execute any + // tests that were pending due to available concurrency. + this.postRun(); + } + + postRun() { + let failedSubtests = 0; + + // If the test was failed before it even started, then the end time will + // be earlier than the start time. Correct that here. + if (this.endTime < this.startTime) { + this.endTime = hrtime(); + } + + // The test has run, so recursively cancel any outstanding subtests and + // mark this test as failed if any subtests failed. + for (let i = 0; i < this.subtests.length; i++) { + const subtest = this.subtests[i]; + + if (!subtest.finished) { + subtest.cancel(); + subtest.postRun(); + } + + if (!subtest.passed) { + failedSubtests++; + } + } + + if (this.passed && failedSubtests > 0) { + const subtestString = `subtest${failedSubtests > 1 ? 's' : ''}`; + const msg = `${failedSubtests} ${subtestString} failed`; + + this.fail(new ERR_TEST_FAILURE(msg, kSubtestsFailed)); + } + + if (this.parent !== null) { + this.parent.activeSubtests--; + this.parent.addReadySubtest(this); + this.parent.processReadySubtestRange(false); + this.parent.processPendingSubtests(); + } + } + + isClearToSend() { + return this.parent === null || + ( + this.parent.waitingOn === this.testNumber && this.parent.isClearToSend() + ); + } + + finalize() { + // By the time this function is called, the following can be relied on: + // - The current test has completed or been cancelled. + // - All of this test's subtests have completed or been cancelled. + // - It is the current test's turn to report its results. + + // Report any subtests that have not been reported yet. Since all of the + // subtests have finished, it's safe to pass true to + // processReadySubtestRange(), which will finalize all remaining subtests. + this.processReadySubtestRange(true); + + // Output this test's results and update the parent's waiting counter. + if (this.subtests.length > 0) { + this.reporter.plan(this.subtests[0].indent, this.subtests.length); + } + + this.report(); + this.parent.waitingOn++; + this.finished = true; + } + + report() { + // Duration is recorded in BigInt nanoseconds. Convert to seconds. + const duration = Number(this.endTime - this.startTime) / 1_000_000_000; + const message = `- ${this.name}`; + let directive; + + if (this.skipped) { + directive = this.reporter.getSkip(this.message); + } else if (this.isTodo) { + directive = this.reporter.getTodo(this.message); + } + + if (this.passed) { + this.reporter.ok(this.indent, this.testNumber, message, directive); + } else { + this.reporter.fail(this.indent, this.testNumber, message, directive); + } + + this.reporter.details(this.indent, duration, this.error); + + for (let i = 0; i < this.diagnostics.length; i++) { + this.reporter.diagnostic(this.indent, this.diagnostics[i]); + } + } +} + +module.exports = { kDefaultIndent, kSubtestsFailed, kTestCodeFailure, Test }; diff --git a/lib/internal/test_runner/utils.js b/lib/internal/test_runner/utils.js new file mode 100644 index 00000000000000..09803d33aeb508 --- /dev/null +++ b/lib/internal/test_runner/utils.js @@ -0,0 +1,15 @@ +'use strict'; +const { RegExpPrototypeExec } = primordials; +const { basename } = require('path'); +const kSupportedFileExtensions = /\.[cm]?js$/; +const kTestFilePattern = /((^test(-.+)?)|(.+[.\-_]test))\.[cm]?js$/; + +function doesPathMatchFilter(p) { + return RegExpPrototypeExec(kTestFilePattern, basename(p)) !== null; +} + +function isSupportedFileType(p) { + return RegExpPrototypeExec(kSupportedFileExtensions, p) !== null; +} + +module.exports = { isSupportedFileType, doesPathMatchFilter }; diff --git a/lib/repl.js b/lib/repl.js index d84c1922d9e947..eeb8594eff4802 100644 --- a/lib/repl.js +++ b/lib/repl.js @@ -101,6 +101,7 @@ const { globalThis, } = primordials; +const { NativeModule } = require('internal/bootstrap/loaders'); const { makeRequireFunction, addBuiltinLibsToObject @@ -130,6 +131,10 @@ let _builtinLibs = ArrayPrototypeFilter( ); const nodeSchemeBuiltinLibs = ArrayPrototypeMap( _builtinLibs, (lib) => `node:${lib}`); +ArrayPrototypeForEach( + NativeModule.getSchemeOnlyModuleNames(), + (lib) => ArrayPrototypePush(nodeSchemeBuiltinLibs, `node:${lib}`), +); const domain = require('domain'); let debug = require('internal/util/debuglog').debuglog('repl', (fn) => { debug = fn; diff --git a/lib/test.js b/lib/test.js new file mode 100644 index 00000000000000..fa319fa17b37bd --- /dev/null +++ b/lib/test.js @@ -0,0 +1,8 @@ +'use strict'; +const test = require('internal/test_runner/harness'); +const { emitExperimentalWarning } = require('internal/util'); + +emitExperimentalWarning('The test runner'); + +module.exports = test; +module.exports.test = test; diff --git a/src/node.cc b/src/node.cc index 155e9a26c0b4f8..65f7767e52da76 100644 --- a/src/node.cc +++ b/src/node.cc @@ -509,6 +509,10 @@ MaybeLocal StartExecution(Environment* env, StartExecutionCallback cb) { return StartExecution(env, "internal/main/check_syntax"); } + if (env->options()->test_runner) { + return StartExecution(env, "internal/main/test_runner"); + } + if (!first_argv.empty() && first_argv != "-") { return StartExecution(env, "internal/main/run_main_module"); } diff --git a/src/node_options.cc b/src/node_options.cc index 35df01a0cfb564..3ab323e0086ac7 100644 --- a/src/node_options.cc +++ b/src/node_options.cc @@ -143,6 +143,24 @@ void EnvironmentOptions::CheckOptions(std::vector* errors) { errors->push_back("--heap-snapshot-near-heap-limit must not be negative"); } + if (test_runner) { + if (syntax_check_only) { + errors->push_back("either --test or --check can be used, not both"); + } + + if (has_eval_string) { + errors->push_back("either --test or --eval can be used, not both"); + } + + if (force_repl) { + errors->push_back("either --test or --interactive can be used, not both"); + } + + if (debug_options_.inspector_enabled) { + errors->push_back("the inspector cannot be used with --test"); + } + } + #if HAVE_INSPECTOR if (!cpu_prof) { if (!cpu_prof_name.empty()) { @@ -503,6 +521,13 @@ EnvironmentOptionsParser::EnvironmentOptionsParser() { "write warnings to file instead of stderr", &EnvironmentOptions::redirect_warnings, kAllowedInEnvironment); + AddOption("--test", + "launch test runner on startup", + &EnvironmentOptions::test_runner); + AddOption("--test-only", + "run tests with 'only' option set", + &EnvironmentOptions::test_only, + kAllowedInEnvironment); AddOption("--test-udp-no-try-send", "", // For testing only. &EnvironmentOptions::test_udp_no_try_send); AddOption("--throw-deprecation", diff --git a/src/node_options.h b/src/node_options.h index 529ccede6203ae..26f9226e0b0afe 100644 --- a/src/node_options.h +++ b/src/node_options.h @@ -149,6 +149,8 @@ class EnvironmentOptions : public Options { #endif // HAVE_INSPECTOR std::string redirect_warnings; std::string diagnostic_dir; + bool test_runner = false; + bool test_only = false; bool test_udp_no_try_send = false; bool throw_deprecation = false; bool trace_atomics_wait = false; diff --git a/test/fixtures/test-runner/index.js b/test/fixtures/test-runner/index.js new file mode 100644 index 00000000000000..fcf4b4d8eaa0ad --- /dev/null +++ b/test/fixtures/test-runner/index.js @@ -0,0 +1,2 @@ +'use strict'; +throw new Error('thrown from index.js'); diff --git a/test/fixtures/test-runner/index.test.js b/test/fixtures/test-runner/index.test.js new file mode 100644 index 00000000000000..2a722c504b9fa5 --- /dev/null +++ b/test/fixtures/test-runner/index.test.js @@ -0,0 +1,4 @@ +'use strict'; +const test = require('node:test'); + +test('this should pass'); diff --git a/test/fixtures/test-runner/node_modules/test-nm.js b/test/fixtures/test-runner/node_modules/test-nm.js new file mode 100644 index 00000000000000..30024eab1f17e4 --- /dev/null +++ b/test/fixtures/test-runner/node_modules/test-nm.js @@ -0,0 +1,2 @@ +'use strict'; +throw new Error('thrown from node_modules'); diff --git a/test/fixtures/test-runner/random.test.mjs b/test/fixtures/test-runner/random.test.mjs new file mode 100644 index 00000000000000..a87a671d006ab6 --- /dev/null +++ b/test/fixtures/test-runner/random.test.mjs @@ -0,0 +1,5 @@ +import test from 'node:test'; + +test('this should fail', () => { + throw new Error('this is a failing test'); +}); diff --git a/test/fixtures/test-runner/subdir/subdir_test.js b/test/fixtures/test-runner/subdir/subdir_test.js new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/test/fixtures/test-runner/test/random.cjs b/test/fixtures/test-runner/test/random.cjs new file mode 100644 index 00000000000000..2a722c504b9fa5 --- /dev/null +++ b/test/fixtures/test-runner/test/random.cjs @@ -0,0 +1,4 @@ +'use strict'; +const test = require('node:test'); + +test('this should pass'); diff --git a/test/message/test_runner_no_refs.js b/test/message/test_runner_no_refs.js new file mode 100644 index 00000000000000..8f2815f067af15 --- /dev/null +++ b/test/message/test_runner_no_refs.js @@ -0,0 +1,13 @@ +// Flags: --no-warnings +'use strict'; +require('../common'); +const test = require('node:test'); + +// When run alone, the test below does not keep the event loop alive. +test('does not keep event loop alive', async (t) => { + await t.test('+does not keep event loop alive', async (t) => { + return new Promise((resolve) => { + setTimeout(resolve, 1000).unref(); + }); + }); +}); diff --git a/test/message/test_runner_no_refs.out b/test/message/test_runner_no_refs.out new file mode 100644 index 00000000000000..c5407e3bd330c0 --- /dev/null +++ b/test/message/test_runner_no_refs.out @@ -0,0 +1,27 @@ +TAP version 13 + not ok 1 - +does not keep event loop alive + --- + duration_ms: * + failureType: 'cancelledByParent' + error: 'test did not finish before its parent and was cancelled' + code: 'ERR_TEST_FAILURE' + stack: |- + * + ... + 1..1 +not ok 1 - does not keep event loop alive + --- + duration_ms: * + failureType: 'cancelledByParent' + error: 'test did not finish before its parent and was cancelled' + code: 'ERR_TEST_FAILURE' + stack: |- + * + ... +1..1 +# tests 1 +# pass 0 +# fail 1 +# skipped 0 +# todo 0 +# duration_ms * diff --git a/test/message/test_runner_no_tests.js b/test/message/test_runner_no_tests.js new file mode 100644 index 00000000000000..c4d226c0bd27d6 --- /dev/null +++ b/test/message/test_runner_no_tests.js @@ -0,0 +1,7 @@ +// Flags: --no-warnings +'use strict'; +require('../common'); +const test = require('node:test'); + +// No TAP output should be generated. +console.log(test.name); diff --git a/test/message/test_runner_no_tests.out b/test/message/test_runner_no_tests.out new file mode 100644 index 00000000000000..9f84e58dc125f8 --- /dev/null +++ b/test/message/test_runner_no_tests.out @@ -0,0 +1 @@ +bound test diff --git a/test/message/test_runner_only_tests.js b/test/message/test_runner_only_tests.js new file mode 100644 index 00000000000000..b6aeb580af94b4 --- /dev/null +++ b/test/message/test_runner_only_tests.js @@ -0,0 +1,48 @@ +// Flags: --no-warnings --test-only +'use strict'; +require('../common'); +const test = require('node:test'); + +// These tests should be skipped based on the 'only' option. +test('only = undefined'); +test('only = undefined, skip = string', { skip: 'skip message' }); +test('only = undefined, skip = true', { skip: true }); +test('only = undefined, skip = false', { skip: false }); +test('only = false', { only: false }); +test('only = false, skip = string', { only: false, skip: 'skip message' }); +test('only = false, skip = true', { only: false, skip: true }); +test('only = false, skip = false', { only: false, skip: false }); + +// These tests should be skipped based on the 'skip' option. +test('only = true, skip = string', { only: true, skip: 'skip message' }); +test('only = true, skip = true', { only: true, skip: true }); + +// An 'only' test with subtests. +test('only = true, with subtests', { only: true }, async (t) => { + // These subtests should run. + await t.test('running subtest 1'); + await t.test('running subtest 2'); + + // Switch the context to only execute 'only' tests. + t.runOnly(true); + await t.test('skipped subtest 1'); + await t.test('skipped subtest 2'); + await t.test('running subtest 3', { only: true }); + + // Switch the context back to execute all tests. + t.runOnly(false); + await t.test('running subtest 4', async (t) => { + // These subtests should run. + await t.test('running sub-subtest 1'); + await t.test('running sub-subtest 2'); + + // Switch the context to only execute 'only' tests. + t.runOnly(true); + await t.test('skipped sub-subtest 1'); + await t.test('skipped sub-subtest 2'); + }); + + // Explicitly do not run these tests. + await t.test('skipped subtest 3', { only: false }); + await t.test('skipped subtest 4', { skip: true }); +}); diff --git a/test/message/test_runner_only_tests.out b/test/message/test_runner_only_tests.out new file mode 100644 index 00000000000000..76e6337bb8953d --- /dev/null +++ b/test/message/test_runner_only_tests.out @@ -0,0 +1,102 @@ +TAP version 13 +ok 1 - only = undefined # SKIP 'only' option not set + --- + duration_ms: * + ... +ok 2 - only = undefined, skip = string # SKIP 'only' option not set + --- + duration_ms: * + ... +ok 3 - only = undefined, skip = true # SKIP 'only' option not set + --- + duration_ms: * + ... +ok 4 - only = undefined, skip = false # SKIP 'only' option not set + --- + duration_ms: * + ... +ok 5 - only = false # SKIP 'only' option not set + --- + duration_ms: * + ... +ok 6 - only = false, skip = string # SKIP 'only' option not set + --- + duration_ms: * + ... +ok 7 - only = false, skip = true # SKIP 'only' option not set + --- + duration_ms: * + ... +ok 8 - only = false, skip = false # SKIP 'only' option not set + --- + duration_ms: * + ... +ok 9 - only = true, skip = string # SKIP skip message + --- + duration_ms: * + ... +ok 10 - only = true, skip = true # SKIP + --- + duration_ms: * + ... + ok 1 - running subtest 1 + --- + duration_ms: * + ... + ok 2 - running subtest 2 + --- + duration_ms: * + ... + ok 3 - skipped subtest 1 # SKIP 'only' option not set + --- + duration_ms: * + ... + ok 4 - skipped subtest 2 # SKIP 'only' option not set + --- + duration_ms: * + ... + ok 5 - running subtest 3 + --- + duration_ms: * + ... + ok 1 - running sub-subtest 1 + --- + duration_ms: * + ... + ok 2 - running sub-subtest 2 + --- + duration_ms: * + ... + ok 3 - skipped sub-subtest 1 # SKIP 'only' option not set + --- + duration_ms: * + ... + ok 4 - skipped sub-subtest 2 # SKIP 'only' option not set + --- + duration_ms: * + ... + 1..4 + ok 6 - running subtest 4 + --- + duration_ms: * + ... + ok 7 - skipped subtest 3 # SKIP 'only' option not set + --- + duration_ms: * + ... + ok 8 - skipped subtest 4 # SKIP + --- + duration_ms: * + ... + 1..8 +ok 11 - only = true, with subtests + --- + duration_ms: * + ... +1..11 +# tests 11 +# pass 1 +# fail 0 +# skipped 10 +# todo 0 +# duration_ms * diff --git a/test/message/test_runner_output.js b/test/message/test_runner_output.js new file mode 100644 index 00000000000000..d397f1ee7a252c --- /dev/null +++ b/test/message/test_runner_output.js @@ -0,0 +1,321 @@ +// Flags: --no-warnings +'use strict'; +require('../common'); +const assert = require('node:assert'); +const test = require('node:test'); +const util = require('util'); + +test('sync pass todo', (t) => { + t.todo(); +}); + +test('sync pass todo with message', (t) => { + t.todo('this is a passing todo'); +}); + +test('sync fail todo', (t) => { + t.todo(); + throw new Error('thrown from sync fail todo'); +}); + +test('sync fail todo with message', (t) => { + t.todo('this is a failing todo'); + throw new Error('thrown from sync fail todo with message'); +}); + +test('sync skip pass', (t) => { + t.skip(); +}); + +test('sync skip pass with message', (t) => { + t.skip('this is skipped'); +}); + +test('sync pass', (t) => { + t.diagnostic('this test should pass'); +}); + +test('sync throw fail', () => { + throw new Error('thrown from sync throw fail'); +}); + +test('async skip pass', async (t) => { + t.skip(); +}); + +test('async pass', async () => { + +}); + +test('async throw fail', async () => { + throw new Error('thrown from async throw fail'); +}); + +test('async skip fail', async (t) => { + t.skip(); + throw new Error('thrown from async throw fail'); +}); + +test('async assertion fail', async () => { + // Make sure the assert module is handled. + assert.strictEqual(true, false); +}); + +test('resolve pass', () => { + return Promise.resolve(); +}); + +test('reject fail', () => { + return Promise.reject(new Error('rejected from reject fail')); +}); + +test('unhandled rejection - passes but warns', () => { + Promise.reject(new Error('rejected from unhandled rejection fail')); +}); + +test('async unhandled rejection - passes but warns', async () => { + Promise.reject(new Error('rejected from async unhandled rejection fail')); +}); + +test('immediate throw - passes but warns', () => { + setImmediate(() => { + throw new Error('thrown from immediate throw fail'); + }); +}); + +test('immediate reject - passes but warns', () => { + setImmediate(() => { + Promise.reject(new Error('rejected from immediate reject fail')); + }); +}); + +test('immediate resolve pass', () => { + return new Promise((resolve) => { + setImmediate(() => { + resolve(); + }); + }); +}); + +test('subtest sync throw fail', async (t) => { + await t.test('+sync throw fail', (t) => { + t.diagnostic('this subtest should make its parent test fail'); + throw new Error('thrown from subtest sync throw fail'); + }); +}); + +test('sync throw non-error fail', async (t) => { + throw Symbol('thrown symbol from sync throw non-error fail'); +}); + +test('level 0a', { concurrency: 4 }, async (t) => { + t.test('level 1a', async (t) => { + const p1a = new Promise((resolve) => { + setTimeout(() => { + resolve(); + }, 1000); + }); + + return p1a; + }); + + t.test('level 1b', async (t) => { + const p1b = new Promise((resolve) => { + resolve(); + }); + + return p1b; + }); + + t.test('level 1c', async (t) => { + const p1c = new Promise((resolve) => { + setTimeout(() => { + resolve(); + }, 2000); + }); + + return p1c; + }); + + t.test('level 1d', async (t) => { + const p1c = new Promise((resolve) => { + setTimeout(() => { + resolve(); + }, 1500); + }); + + return p1c; + }); + + const p0a = new Promise((resolve) => { + setTimeout(() => { + resolve(); + }, 3000); + }); + + return p0a; +}); + +test('top level', { concurrency: 2 }, async (t) => { + t.test('+long running', async (t) => { + return new Promise((resolve, reject) => { + setTimeout(resolve, 3000).unref(); + }); + }); + + t.test('+short running', async (t) => { + t.test('++short running', async (t) => {}); + }); +}); + +test('invalid subtest - pass but subtest fails', (t) => { + setImmediate(() => { + t.test('invalid subtest fail', () => { + throw new Error('this should not be thrown'); + }); + }); +}); + +test('sync skip option', { skip: true }, (t) => { + throw new Error('this should not be executed'); +}); + +test('sync skip option with message', { skip: 'this is skipped' }, (t) => { + throw new Error('this should not be executed'); +}); + +test('sync skip option is false fail', { skip: false }, (t) => { + throw new Error('this should be executed'); +}); + +// A test with no arguments provided. +test(); + +// A test with only a named function provided. +test(function functionOnly() {}); + +// A test with only an anonymous function provided. +test(() => {}); + +// A test with only a name provided. +test('test with only a name provided'); + +// A test with an empty string name. +test(''); + +// A test with only options provided. +test({ skip: true }); + +// A test with only a name and options provided. +test('test with a name and options provided', { skip: true }); + +// A test with only options and a function provided. +test({ skip: true }, function functionAndOptions() {}); + +// A test whose description needs to be escaped. +test('escaped description \\ # \\#\\'); + +// A test whose skip message needs to be escaped. +test('escaped skip message', { skip: '#skip' }); + +// A test whose todo message needs to be escaped. +test('escaped todo message', { todo: '#todo' }); + +// A test with a diagnostic message that needs to be escaped. +test('escaped diagnostic', (t) => { + t.diagnostic('#diagnostic'); +}); + +test('callback pass', (t, done) => { + setImmediate(done); +}); + +test('callback fail', (t, done) => { + setImmediate(() => { + done(new Error('callback failure')); + }); +}); + +test('sync t is this in test', function(t) { + assert.strictEqual(this, t); +}); + +test('async t is this in test', async function(t) { + assert.strictEqual(this, t); +}); + +test('callback t is this in test', function(t, done) { + assert.strictEqual(this, t); + done(); +}); + +test('callback also returns a Promise', async (t, done) => { + throw new Error('thrown from callback also returns a Promise'); +}); + +test('callback throw', (t, done) => { + throw new Error('thrown from callback throw'); +}); + +test('callback called twice', (t, done) => { + done(); + done(); +}); + +test('callback called twice in different ticks', (t, done) => { + setImmediate(done); + done(); +}); + +test('callback called twice in future tick', (t, done) => { + setImmediate(() => { + done(); + done(); + }); +}); + +test('callback async throw', (t, done) => { + setImmediate(() => { + throw new Error('thrown from callback async throw'); + }); +}); + +test('callback async throw after done', (t, done) => { + setImmediate(() => { + throw new Error('thrown from callback async throw after done'); + }); + + done(); +}); + +test('only is set but not in only mode', { only: true }, async (t) => { + // All of these subtests should run. + await t.test('running subtest 1'); + t.runOnly(true); + await t.test('running subtest 2'); + await t.test('running subtest 3', { only: true }); + t.runOnly(false); + await t.test('running subtest 4'); +}); + +test('custom inspect symbol fail', () => { + const obj = { + [util.inspect.custom]() { + return 'customized'; + }, + foo: 1 + }; + + throw obj; +}); + +test('custom inspect symbol that throws fail', () => { + const obj = { + [util.inspect.custom]() { + throw new Error('bad-inspect'); + }, + foo: 1 + }; + + throw obj; +}); diff --git a/test/message/test_runner_output.out b/test/message/test_runner_output.out new file mode 100644 index 00000000000000..866b498deab105 --- /dev/null +++ b/test/message/test_runner_output.out @@ -0,0 +1,472 @@ +TAP version 13 +ok 1 - sync pass todo # TODO + --- + duration_ms: * + ... +ok 2 - sync pass todo with message # TODO this is a passing todo + --- + duration_ms: * + ... +not ok 3 - sync fail todo # TODO + --- + duration_ms: * + failureType: 'testCodeFailure' + error: 'thrown from sync fail todo' + code: 'ERR_TEST_FAILURE' + stack: |- + * + * + * + * + * + * + * + * + ... +not ok 4 - sync fail todo with message # TODO this is a failing todo + --- + duration_ms: * + failureType: 'testCodeFailure' + error: 'thrown from sync fail todo with message' + code: 'ERR_TEST_FAILURE' + stack: |- + * + * + * + * + * + * + * + * + * + * + ... +ok 5 - sync skip pass # SKIP + --- + duration_ms: * + ... +ok 6 - sync skip pass with message # SKIP this is skipped + --- + duration_ms: * + ... +ok 7 - sync pass + --- + duration_ms: * + ... +# this test should pass +not ok 8 - sync throw fail + --- + duration_ms: * + failureType: 'testCodeFailure' + error: 'thrown from sync throw fail' + code: 'ERR_TEST_FAILURE' + stack: |- + * + * + * + * + * + * + * + * + ... +ok 9 - async skip pass # SKIP + --- + duration_ms: * + ... +ok 10 - async pass + --- + duration_ms: * + ... +not ok 11 - async throw fail + --- + duration_ms: * + failureType: 'testCodeFailure' + error: 'thrown from async throw fail' + code: 'ERR_TEST_FAILURE' + stack: |- + * + * + * + * + * + * + * + * + ... +not ok 12 - async skip fail # SKIP + --- + duration_ms: * + failureType: 'testCodeFailure' + error: 'thrown from async throw fail' + code: 'ERR_TEST_FAILURE' + stack: |- + * + * + * + * + * + * + * + * + ... +not ok 13 - async assertion fail + --- + duration_ms: * + failureType: 'testCodeFailure' + error: |- + Expected values to be strictly equal: + + true !== false + + code: 'ERR_ASSERTION' + stack: |- + * + * + * + * + * + * + * + * + ... +ok 14 - resolve pass + --- + duration_ms: * + ... +not ok 15 - reject fail + --- + duration_ms: * + failureType: 'testCodeFailure' + error: 'rejected from reject fail' + code: 'ERR_TEST_FAILURE' + stack: |- + * + * + * + * + * + * + * + * + ... +ok 16 - unhandled rejection - passes but warns + --- + duration_ms: * + ... +ok 17 - async unhandled rejection - passes but warns + --- + duration_ms: * + ... +ok 18 - immediate throw - passes but warns + --- + duration_ms: * + ... +ok 19 - immediate reject - passes but warns + --- + duration_ms: * + ... +ok 20 - immediate resolve pass + --- + duration_ms: * + ... + not ok 1 - +sync throw fail + --- + duration_ms: * + failureType: 'testCodeFailure' + error: 'thrown from subtest sync throw fail' + code: 'ERR_TEST_FAILURE' + stack: |- + * + * + * + * + * + * + * + * + * + * + ... + # this subtest should make its parent test fail + 1..1 +not ok 21 - subtest sync throw fail + --- + duration_ms: * + failureType: 'subtestsFailed' + error: '1 subtest failed' + code: 'ERR_TEST_FAILURE' + ... +not ok 22 - sync throw non-error fail + --- + duration_ms: * + failureType: 'testCodeFailure' + error: 'Symbol(thrown symbol from sync throw non-error fail)' + code: 'ERR_TEST_FAILURE' + ... + ok 1 - level 1a + --- + duration_ms: * + ... + ok 2 - level 1b + --- + duration_ms: * + ... + ok 3 - level 1c + --- + duration_ms: * + ... + ok 4 - level 1d + --- + duration_ms: * + ... + 1..4 +ok 23 - level 0a + --- + duration_ms: * + ... + not ok 1 - +long running + --- + duration_ms: * + failureType: 'cancelledByParent' + error: 'test did not finish before its parent and was cancelled' + code: 'ERR_TEST_FAILURE' + ... + ok 1 - ++short running + --- + duration_ms: * + ... + 1..1 + ok 2 - +short running + --- + duration_ms: * + ... + 1..2 +not ok 24 - top level + --- + duration_ms: * + failureType: 'subtestsFailed' + error: '1 subtest failed' + code: 'ERR_TEST_FAILURE' + ... +ok 25 - invalid subtest - pass but subtest fails + --- + duration_ms: * + ... +ok 26 - sync skip option # SKIP + --- + duration_ms: * + ... +ok 27 - sync skip option with message # SKIP this is skipped + --- + duration_ms: * + ... +not ok 28 - sync skip option is false fail + --- + duration_ms: * + failureType: 'testCodeFailure' + error: 'this should be executed' + code: 'ERR_TEST_FAILURE' + stack: |- + * + * + * + * + * + * + * + ... +ok 29 - + --- + duration_ms: * + ... +ok 30 - functionOnly + --- + duration_ms: * + ... +ok 31 - + --- + duration_ms: * + ... +ok 32 - test with only a name provided + --- + duration_ms: * + ... +ok 33 - + --- + duration_ms: * + ... +ok 34 - # SKIP + --- + duration_ms: * + ... +ok 35 - test with a name and options provided # SKIP + --- + duration_ms: * + ... +ok 36 - functionAndOptions # SKIP + --- + duration_ms: * + ... +ok 37 - escaped description \\ \# \\\#\\ + --- + duration_ms: * + ... +ok 38 - escaped skip message # SKIP \#skip + --- + duration_ms: * + ... +ok 39 - escaped todo message # TODO \#todo + --- + duration_ms: * + ... +ok 40 - escaped diagnostic + --- + duration_ms: * + ... +# \#diagnostic +ok 41 - callback pass + --- + duration_ms: * + ... +not ok 42 - callback fail + --- + duration_ms: * + failureType: 'testCodeFailure' + error: 'callback failure' + code: 'ERR_TEST_FAILURE' + stack: |- + * + * + ... +ok 43 - sync t is this in test + --- + duration_ms: * + ... +ok 44 - async t is this in test + --- + duration_ms: * + ... +ok 45 - callback t is this in test + --- + duration_ms: * + ... +not ok 46 - callback also returns a Promise + --- + duration_ms: * + failureType: 'callbackAndPromisePresent' + error: 'passed a callback but also returned a Promise' + code: 'ERR_TEST_FAILURE' + ... +not ok 47 - callback throw + --- + duration_ms: * + failureType: 'testCodeFailure' + error: 'thrown from callback throw' + code: 'ERR_TEST_FAILURE' + stack: |- + * + * + * + * + * + * + * + ... +not ok 48 - callback called twice + --- + duration_ms: * + failureType: 'multipleCallbackInvocations' + error: 'callback invoked multiple times' + code: 'ERR_TEST_FAILURE' + stack: |- + * + * + ... +ok 49 - callback called twice in different ticks + --- + duration_ms: * + ... +not ok 50 - callback called twice in future tick + --- + duration_ms: * + failureType: 'uncaughtException' + error: 'callback invoked multiple times' + code: 'ERR_TEST_FAILURE' + stack: |- + * + ... +not ok 51 - callback async throw + --- + duration_ms: * + failureType: 'uncaughtException' + error: 'thrown from callback async throw' + code: 'ERR_TEST_FAILURE' + stack: |- + * + ... +ok 52 - callback async throw after done + --- + duration_ms: * + ... + ok 1 - running subtest 1 + --- + duration_ms: * + ... + ok 2 - running subtest 2 + --- + duration_ms: * + ... + ok 3 - running subtest 3 + --- + duration_ms: * + ... + ok 4 - running subtest 4 + --- + duration_ms: * + ... + 1..4 +ok 53 - only is set but not in only mode + --- + duration_ms: * + ... +not ok 54 - custom inspect symbol fail + --- + duration_ms: * + failureType: 'testCodeFailure' + error: 'customized' + code: 'ERR_TEST_FAILURE' + ... +not ok 55 - custom inspect symbol that throws fail + --- + duration_ms: * + failureType: 'testCodeFailure' + error: |- + { + foo: 1, + [Symbol(nodejs.util.inspect.custom)]: [Function: [nodejs.util.inspect.custom]] + } + code: 'ERR_TEST_FAILURE' + ... +not ok 56 - invalid subtest fail + --- + duration_ms: * + failureType: 'parentAlreadyFinished' + error: 'test could not be started because its parent finished' + code: 'ERR_TEST_FAILURE' + stack: |- + * + ... +1..56 +# Warning: Test "unhandled rejection - passes but warns" generated asynchronous activity after the test ended. This activity created the error "Error: rejected from unhandled rejection fail" and would have caused the test to fail, but instead triggered an unhandledRejection event. +# Warning: Test "async unhandled rejection - passes but warns" generated asynchronous activity after the test ended. This activity created the error "Error: rejected from async unhandled rejection fail" and would have caused the test to fail, but instead triggered an unhandledRejection event. +# Warning: Test "immediate throw - passes but warns" generated asynchronous activity after the test ended. This activity created the error "Error: thrown from immediate throw fail" and would have caused the test to fail, but instead triggered an uncaughtException event. +# Warning: Test "immediate reject - passes but warns" generated asynchronous activity after the test ended. This activity created the error "Error: rejected from immediate reject fail" and would have caused the test to fail, but instead triggered an unhandledRejection event. +# Warning: Test "callback called twice in different ticks" generated asynchronous activity after the test ended. This activity created the error "Error [ERR_TEST_FAILURE]: callback invoked multiple times" and would have caused the test to fail, but instead triggered an uncaughtException event. +# Warning: Test "callback async throw after done" generated asynchronous activity after the test ended. This activity created the error "Error: thrown from callback async throw after done" and would have caused the test to fail, but instead triggered an uncaughtException event. +# tests 56 +# pass 24 +# fail 17 +# skipped 10 +# todo 5 +# duration_ms * diff --git a/test/message/test_runner_unresolved_promise.js b/test/message/test_runner_unresolved_promise.js new file mode 100644 index 00000000000000..00d231be34b6df --- /dev/null +++ b/test/message/test_runner_unresolved_promise.js @@ -0,0 +1,8 @@ +// Flags: --no-warnings +'use strict'; +require('../common'); +const test = require('node:test'); + +test('pass'); +test('never resolving promise', () => new Promise(() => {})); +test('fail'); diff --git a/test/message/test_runner_unresolved_promise.out b/test/message/test_runner_unresolved_promise.out new file mode 100644 index 00000000000000..98f52966c33bcb --- /dev/null +++ b/test/message/test_runner_unresolved_promise.out @@ -0,0 +1,30 @@ +TAP version 13 +ok 1 - pass + --- + duration_ms: * + ... +not ok 2 - never resolving promise + --- + duration_ms: * + failureType: 'cancelledByParent' + error: 'test did not finish before its parent and was cancelled' + code: 'ERR_TEST_FAILURE' + stack: |- + * + ... +not ok 3 - fail + --- + duration_ms: * + failureType: 'cancelledByParent' + error: 'test did not finish before its parent and was cancelled' + code: 'ERR_TEST_FAILURE' + stack: |- + * + ... +1..3 +# tests 3 +# pass 1 +# fail 2 +# skipped 0 +# todo 0 +# duration_ms * diff --git a/test/parallel/test-code-cache.js b/test/parallel/test-code-cache.js index 1b151e269dcfaf..f61ed9f5c54077 100644 --- a/test/parallel/test-code-cache.js +++ b/test/parallel/test-code-cache.js @@ -16,7 +16,7 @@ const { } = internalBinding('native_module'); for (const key of canBeRequired) { - require(key); + require(`node:${key}`); } // The computation has to be delayed until we have done loading modules diff --git a/test/parallel/test-repl-tab-complete-import.js b/test/parallel/test-repl-tab-complete-import.js index 1968caa5accf54..e328d95db5986c 100644 --- a/test/parallel/test-repl-tab-complete-import.js +++ b/test/parallel/test-repl-tab-complete-import.js @@ -53,14 +53,18 @@ testMe.complete("import\t( 'n", common.mustCall((error, data) => { assert.strictEqual(data[1], 'n'); const completions = data[0]; // import(...) completions include `node:` URL modules: - publicModules.forEach((lib, index) => - assert.strictEqual(completions[index], `node:${lib}`)); - assert.strictEqual(completions[publicModules.length], ''); + let lastIndex = -1; + + publicModules.forEach((lib, index) => { + lastIndex = completions.indexOf(`node:${lib}`); + assert.notStrictEqual(lastIndex, -1); + }); + assert.strictEqual(completions[lastIndex + 1], ''); // There is only one Node.js module that starts with n: - assert.strictEqual(completions[publicModules.length + 1], 'net'); - assert.strictEqual(completions[publicModules.length + 2], ''); + assert.strictEqual(completions[lastIndex + 2], 'net'); + assert.strictEqual(completions[lastIndex + 3], ''); // It's possible to pick up non-core modules too - completions.slice(publicModules.length + 3).forEach((completion) => { + completions.slice(lastIndex + 4).forEach((completion) => { assert.match(completion, /^n/); }); })); diff --git a/test/parallel/test-repl-tab-complete.js b/test/parallel/test-repl-tab-complete.js index 4c6386816ddf2f..cc211d6da8aaef 100644 --- a/test/parallel/test-repl-tab-complete.js +++ b/test/parallel/test-repl-tab-complete.js @@ -293,14 +293,18 @@ testMe.complete("require\t( 'n", common.mustCall(function(error, data) { assert.strictEqual(data.length, 2); assert.strictEqual(data[1], 'n'); // require(...) completions include `node:`-prefixed modules: - publicModules.forEach((lib, index) => - assert.strictEqual(data[0][index], `node:${lib}`)); - assert.strictEqual(data[0][publicModules.length], ''); + let lastIndex = -1; + + publicModules.forEach((lib, index) => { + lastIndex = data[0].indexOf(`node:${lib}`); + assert.notStrictEqual(lastIndex, -1); + }); + assert.strictEqual(data[0][lastIndex + 1], ''); // There is only one Node.js module that starts with n: - assert.strictEqual(data[0][publicModules.length + 1], 'net'); - assert.strictEqual(data[0][publicModules.length + 2], ''); + assert.strictEqual(data[0][lastIndex + 2], 'net'); + assert.strictEqual(data[0][lastIndex + 3], ''); // It's possible to pick up non-core modules too - data[0].slice(publicModules.length + 3).forEach((completion) => { + data[0].slice(lastIndex + 4).forEach((completion) => { assert.match(completion, /^n/); }); })); diff --git a/test/parallel/test-runner-cli.js b/test/parallel/test-runner-cli.js new file mode 100644 index 00000000000000..8d16205cdaf1bc --- /dev/null +++ b/test/parallel/test-runner-cli.js @@ -0,0 +1,107 @@ +'use strict'; +require('../common'); +const assert = require('assert'); +const { spawnSync } = require('child_process'); +const { join } = require('path'); +const fixtures = require('../common/fixtures'); +const testFixtures = fixtures.path('test-runner'); + +{ + // File not found. + const args = ['--test', 'a-random-file-that-does-not-exist.js']; + const child = spawnSync(process.execPath, args); + + assert.strictEqual(child.status, 1); + assert.strictEqual(child.signal, null); + assert.strictEqual(child.stdout.toString(), ''); + assert(/^Could not find/.test(child.stderr.toString())); +} + +{ + // Default behavior. node_modules is ignored. Files that don't match the + // pattern are ignored except in test/ directories. + const args = ['--test', testFixtures]; + const child = spawnSync(process.execPath, args); + + assert.strictEqual(child.status, 1); + assert.strictEqual(child.signal, null); + assert.strictEqual(child.stderr.toString(), ''); + const stdout = child.stdout.toString(); + assert(/ok 1 - .+index\.test\.js/.test(stdout)); + assert(/not ok 2 - .+random\.test\.mjs/.test(stdout)); + assert(/not ok 1 - this should fail/.test(stdout)); + assert(/ok 3 - .+subdir.+subdir_test\.js/.test(stdout)); + assert(/ok 4 - .+random\.cjs/.test(stdout)); +} + +{ + // User specified files that don't match the pattern are still run. + const args = ['--test', testFixtures, join(testFixtures, 'index.js')]; + const child = spawnSync(process.execPath, args); + + assert.strictEqual(child.status, 1); + assert.strictEqual(child.signal, null); + assert.strictEqual(child.stderr.toString(), ''); + const stdout = child.stdout.toString(); + assert(/not ok 1 - .+index\.js/.test(stdout)); + assert(/ok 2 - .+index\.test\.js/.test(stdout)); + assert(/not ok 3 - .+random\.test\.mjs/.test(stdout)); + assert(/not ok 1 - this should fail/.test(stdout)); + assert(/ok 4 - .+subdir.+subdir_test\.js/.test(stdout)); +} + +{ + // Searches node_modules if specified. + const args = ['--test', join(testFixtures, 'node_modules')]; + const child = spawnSync(process.execPath, args); + + assert.strictEqual(child.status, 1); + assert.strictEqual(child.signal, null); + assert.strictEqual(child.stderr.toString(), ''); + const stdout = child.stdout.toString(); + assert(/not ok 1 - .+test-nm\.js/.test(stdout)); +} + +{ + // The current directory is used by default. + const args = ['--test']; + const options = { cwd: testFixtures }; + const child = spawnSync(process.execPath, args, options); + + assert.strictEqual(child.status, 1); + assert.strictEqual(child.signal, null); + assert.strictEqual(child.stderr.toString(), ''); + const stdout = child.stdout.toString(); + assert(/ok 1 - .+index\.test\.js/.test(stdout)); + assert(/not ok 2 - .+random\.test\.mjs/.test(stdout)); + assert(/not ok 1 - this should fail/.test(stdout)); + assert(/ok 3 - .+subdir.+subdir_test\.js/.test(stdout)); + assert(/ok 4 - .+random\.cjs/.test(stdout)); +} + +{ + // Flags that cannot be combined with --test. + const flags = [ + ['--check', '--test'], + ['--interactive', '--test'], + ['--eval', 'console.log("should not print")', '--test'], + ['--print', 'console.log("should not print")', '--test'], + ]; + + if (process.features.inspector) { + flags.push( + ['--inspect', '--test'], + ['--inspect-brk', '--test'], + ); + } + + flags.forEach((args) => { + const child = spawnSync(process.execPath, args); + + assert.notStrictEqual(child.status, 0); + assert.strictEqual(child.signal, null); + assert.strictEqual(child.stdout.toString(), ''); + const stderr = child.stderr.toString(); + assert(/--test/.test(stderr)); + }); +} diff --git a/test/parallel/test-runner-exit-code.js b/test/parallel/test-runner-exit-code.js new file mode 100644 index 00000000000000..0e72f77783e9a9 --- /dev/null +++ b/test/parallel/test-runner-exit-code.js @@ -0,0 +1,27 @@ +'use strict'; +require('../common'); +const assert = require('assert'); +const { spawnSync } = require('child_process'); + +if (process.argv[2] === 'child') { + const test = require('node:test'); + + if (process.argv[3] === 'pass') { + test('passing test', () => { + assert.strictEqual(true, true); + }); + } else { + assert.strictEqual(process.argv[3], 'fail'); + test('failing test', () => { + assert.strictEqual(true, false); + }); + } +} else { + let child = spawnSync(process.execPath, [__filename, 'child', 'pass']); + assert.strictEqual(child.status, 0); + assert.strictEqual(child.signal, null); + + child = spawnSync(process.execPath, [__filename, 'child', 'fail']); + assert.strictEqual(child.status, 1); + assert.strictEqual(child.signal, null); +} diff --git a/test/parallel/test-runner-import-no-scheme.js b/test/parallel/test-runner-import-no-scheme.js new file mode 100644 index 00000000000000..4008d7494b6670 --- /dev/null +++ b/test/parallel/test-runner-import-no-scheme.js @@ -0,0 +1,15 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); + +assert.throws( + () => require('test'), + common.expectsError({ code: 'ERR_UNKNOWN_BUILTIN_MODULE' }), +); + +(async () => { + await assert.rejects( + async () => import('test'), + common.expectsError({ code: 'ERR_UNKNOWN_BUILTIN_MODULE' }), + ); +})().then(common.mustCall()); diff --git a/test/parallel/test-runner-test-filter.js b/test/parallel/test-runner-test-filter.js new file mode 100644 index 00000000000000..b6afba22a2e814 --- /dev/null +++ b/test/parallel/test-runner-test-filter.js @@ -0,0 +1,42 @@ +// Flags: --expose-internals +'use strict'; +require('../common'); +const assert = require('assert'); +const { doesPathMatchFilter } = require('internal/test_runner/utils'); + +// Paths expected to match +[ + 'test.js', + 'test.cjs', + 'test.mjs', + 'test-foo.js', + 'test-foo.cjs', + 'test-foo.mjs', + 'foo.test.js', + 'foo.test.cjs', + 'foo.test.mjs', + 'foo-test.js', + 'foo-test.cjs', + 'foo-test.mjs', + 'foo_test.js', + 'foo_test.cjs', + 'foo_test.mjs', +].forEach((p) => { + assert.strictEqual(doesPathMatchFilter(p), true); +}); + +// Paths expected not to match +[ + 'test', + 'test.djs', + 'test.cs', + 'test.mj', + 'foo.js', + 'test-foo.sj', + 'test.foo.js', + 'test_foo.js', + 'testfoo.js', + 'foo-test1.mjs', +].forEach((p) => { + assert.strictEqual(doesPathMatchFilter(p), false); +});