diff --git a/README.md b/README.md index 66bc7f5..2ff9fa3 100644 --- a/README.md +++ b/README.md @@ -228,6 +228,41 @@ test('this test is not run', () => { }) ``` +## Filtering tests by name + +The [`--test-name-pattern`][] command-line option can be used to only run tests +whose name matches the provided pattern. Test name patterns are interpreted as +JavaScript regular expressions. The `--test-name-pattern` option can be +specified multiple times in order to run nested tests. For each test that is +executed, any corresponding test hooks, such as `beforeEach()`, are also +run. + +Given the following test file, starting Node.js with the +`--test-name-pattern="test [1-3]"` option would cause the test runner to execute +`test 1`, `test 2`, and `test 3`. If `test 1` did not match the test name +pattern, then its subtests would not execute, despite matching the pattern. The +same set of tests could also be executed by passing `--test-name-pattern` +multiple times (e.g. `--test-name-pattern="test 1"`, +`--test-name-pattern="test 2"`, etc.). + +```js +test('test 1', async (t) => { + await t.test('test 2'); + await t.test('test 3'); +}); +test('Test 4', async (t) => { + await t.test('Test 5'); + await t.test('test 6'); +}); +``` + +Test name patterns can also be specified using regular expression literals. This +allows regular expression flags to be used. In the previous example, starting +Node.js with `--test-name-pattern="/test [4-5]/i"` would match `Test 4` and +`Test 5` because the pattern is case-insensitive. + +Test name patterns do not change the set of files that the test runner executes. + ## Extraneous asynchronous activity Once a test function finishes executing, the TAP results are output as quickly @@ -244,8 +279,8 @@ 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. +test are marked as failed 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 => { @@ -324,6 +359,113 @@ 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. +## Mocking + +The `node:test` module supports mocking during testing via a top-level `mock` +object. The following example creates a spy on a function that adds two numbers +together. The spy is then used to assert that the function was called as +expected. + +```mjs +import assert from 'node:assert'; +import { mock, test } from 'test'; +test('spies on a function', () => { + const sum = mock.fn((a, b) => { + return a + b; + }); + assert.strictEqual(sum.mock.calls.length, 0); + assert.strictEqual(sum(3, 4), 7); + assert.strictEqual(sum.mock.calls.length, 1); + const call = sum.mock.calls[0]; + assert.deepStrictEqual(call.arguments, [3, 4]); + assert.strictEqual(call.result, 7); + assert.strictEqual(call.error, undefined); + // Reset the globally tracked mocks. + mock.reset(); +}); +``` + +```cjs +'use strict'; +const assert = require('node:assert'); +const { mock, test } = require('test'); +test('spies on a function', () => { + const sum = mock.fn((a, b) => { + return a + b; + }); + assert.strictEqual(sum.mock.calls.length, 0); + assert.strictEqual(sum(3, 4), 7); + assert.strictEqual(sum.mock.calls.length, 1); + const call = sum.mock.calls[0]; + assert.deepStrictEqual(call.arguments, [3, 4]); + assert.strictEqual(call.result, 7); + assert.strictEqual(call.error, undefined); + // Reset the globally tracked mocks. + mock.reset(); +}); +``` + +The same mocking functionality is also exposed on the [`TestContext`][] object +of each test. The following example creates a spy on an object method using the +API exposed on the `TestContext`. The benefit of mocking via the test context is +that the test runner will automatically restore all mocked functionality once +the test finishes. + +```js +test('spies on an object method', (t) => { + const number = { + value: 5, + add(a) { + return this.value + a; + }, + }; + t.mock.method(number, 'add'); + assert.strictEqual(number.add.mock.calls.length, 0); + assert.strictEqual(number.add(3), 8); + assert.strictEqual(number.add.mock.calls.length, 1); + const call = number.add.mock.calls[0]; + assert.deepStrictEqual(call.arguments, [3]); + assert.strictEqual(call.result, 8); + assert.strictEqual(call.target, undefined); + assert.strictEqual(call.this, number); +}); +``` + +## `run([options])` + + + +* `options` {Object} Configuration options for running tests. 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 execution. + * `timeout` {number} A number of milliseconds the test execution will + fail after. + If unspecified, subtests inherit this value from their parent. + **Default:** `Infinity`. + * `inspectPort` {number|Function} Sets inspector port of test child process. + This can be a number, or a function that takes no arguments and returns a + number. If a nullish value is provided, each process gets its own port, + incremented from the primary's `process.debugPort`. + **Default:** `undefined`. + +* Returns: {TapStream} + +```js +run({ files: [path.resolve('./tests/test.js')] }) + .pipe(process.stdout); +``` + ## `test([name][, options][, fn])` - `name` {string} The name of the test, which is displayed when reporting test @@ -541,6 +683,324 @@ describe('tests', async () => { }); ``` +## Class: `MockFunctionContext` + + + +The `MockFunctionContext` class is used to inspect or manipulate the behavior of +mocks created via the [`MockTracker`][] APIs. + +### `ctx.calls` + + + +* {Array} + +A getter that returns a copy of the internal array used to track calls to the +mock. Each entry in the array is an object with the following properties. + +* `arguments` {Array} An array of the arguments passed to the mock function. +* `error` {any} If the mocked function threw then this property contains the + thrown value. **Default:** `undefined`. +* `result` {any} The value returned by the mocked function. +* `stack` {Error} An `Error` object whose stack can be used to determine the + callsite of the mocked function invocation. +* `target` {Function|undefined} If the mocked function is a constructor, this + field contains the class being constructed. Otherwise this will be + `undefined`. +* `this` {any} The mocked function's `this` value. + +### `ctx.callCount()` + + + +* Returns: {integer} The number of times that this mock has been invoked. + +This function returns the number of times that this mock has been invoked. This +function is more efficient than checking `ctx.calls.length` because `ctx.calls` +is a getter that creates a copy of the internal call tracking array. + +### `ctx.mockImplementation(implementation)` + + + +* `implementation` {Function|AsyncFunction} The function to be used as the + mock's new implementation. + +This function is used to change the behavior of an existing mock. + +The following example creates a mock function using `t.mock.fn()`, calls the +mock function, and then changes the mock implementation to a different function. + +```js +test('changes a mock behavior', (t) => { + let cnt = 0; + function addOne() { + cnt++; + return cnt; + } + function addTwo() { + cnt += 2; + return cnt; + } + const fn = t.mock.fn(addOne); + assert.strictEqual(fn(), 1); + fn.mock.mockImplementation(addTwo); + assert.strictEqual(fn(), 3); + assert.strictEqual(fn(), 5); +}); +``` + +### `ctx.mockImplementationOnce(implementation[, onCall])` + + + +* `implementation` {Function|AsyncFunction} The function to be used as the + mock's implementation for the invocation number specified by `onCall`. +* `onCall` {integer} The invocation number that will use `implementation`. If + the specified invocation has already occurred then an exception is thrown. + **Default:** The number of the next invocation. + +This function is used to change the behavior of an existing mock for a single +invocation. Once invocation `onCall` has occurred, the mock will revert to +whatever behavior it would have used had `mockImplementationOnce()` not been +called. + +The following example creates a mock function using `t.mock.fn()`, calls the +mock function, changes the mock implementation to a different function for the +next invocation, and then resumes its previous behavior. + +```js +test('changes a mock behavior once', (t) => { + let cnt = 0; + function addOne() { + cnt++; + return cnt; + } + function addTwo() { + cnt += 2; + return cnt; + } + const fn = t.mock.fn(addOne); + assert.strictEqual(fn(), 1); + fn.mock.mockImplementationOnce(addTwo); + assert.strictEqual(fn(), 3); + assert.strictEqual(fn(), 4); +}); +``` + +### `ctx.restore()` + + + +Resets the implementation of the mock function to its original behavior. The +mock can still be used after calling this function. + +## Class: `MockTracker` + + + +The `MockTracker` class is used to manage mocking functionality. The test runner +module provides a top level `mock` export which is a `MockTracker` instance. +Each test also provides its own `MockTracker` instance via the test context's +`mock` property. + +### `mock.fn([original[, implementation]][, options])` + + + +* `original` {Function|AsyncFunction} An optional function to create a mock on. + **Default:** A no-op function. +* `implementation` {Function|AsyncFunction} An optional function used as the + mock implementation for `original`. This is useful for creating mocks that + exhibit one behavior for a specified number of calls and then restore the + behavior of `original`. **Default:** The function specified by `original`. +* `options` {Object} Optional configuration options for the mock function. The + following properties are supported: + * `times` {integer} The number of times that the mock will use the behavior of + `implementation`. Once the mock function has been called `times` times, it + will automatically restore the behavior of `original`. This value must be an + integer greater than zero. **Default:** `Infinity`. +* Returns: {Proxy} The mocked function. The mocked function contains a special + `mock` property, which is an instance of [`MockFunctionContext`][], and can + be used for inspecting and changing the behavior of the mocked function. + +This function is used to create a mock function. + +The following example creates a mock function that increments a counter by one +on each invocation. The `times` option is used to modify the mock behavior such +that the first two invocations add two to the counter instead of one. + +```js +test('mocks a counting function', (t) => { + let cnt = 0; + function addOne() { + cnt++; + return cnt; + } + function addTwo() { + cnt += 2; + return cnt; + } + const fn = t.mock.fn(addOne, addTwo, { times: 2 }); + assert.strictEqual(fn(), 2); + assert.strictEqual(fn(), 4); + assert.strictEqual(fn(), 5); + assert.strictEqual(fn(), 6); +}); +``` + +### `mock.getter(object, methodName[, implementation][, options])` + + + +This function is syntax sugar for [`MockTracker.method`][] with `options.getter` +set to `true`. + +### `mock.method(object, methodName[, implementation][, options])` + + + +* `object` {Object} The object whose method is being mocked. +* `methodName` {string|symbol} The identifier of the method on `object` to mock. + If `object[methodName]` is not a function, an error is thrown. +* `implementation` {Function|AsyncFunction} An optional function used as the + mock implementation for `object[methodName]`. **Default:** The original method + specified by `object[methodName]`. +* `options` {Object} Optional configuration options for the mock method. The + following properties are supported: + * `getter` {boolean} If `true`, `object[methodName]` is treated as a getter. + This option cannot be used with the `setter` option. **Default:** false. + * `setter` {boolean} If `true`, `object[methodName]` is treated as a setter. + This option cannot be used with the `getter` option. **Default:** false. + * `times` {integer} The number of times that the mock will use the behavior of + `implementation`. Once the mocked method has been called `times` times, it + will automatically restore the original behavior. This value must be an + integer greater than zero. **Default:** `Infinity`. +* Returns: {Proxy} The mocked method. The mocked method contains a special + `mock` property, which is an instance of [`MockFunctionContext`][], and can + be used for inspecting and changing the behavior of the mocked method. + +This function is used to create a mock on an existing object method. The +following example demonstrates how a mock is created on an existing object +method. + +```js +test('spies on an object method', (t) => { + const number = { + value: 5, + subtract(a) { + return this.value - a; + }, + }; + t.mock.method(number, 'subtract'); + assert.strictEqual(number.subtract.mock.calls.length, 0); + assert.strictEqual(number.subtract(3), 2); + assert.strictEqual(number.subtract.mock.calls.length, 1); + const call = number.subtract.mock.calls[0]; + assert.deepStrictEqual(call.arguments, [3]); + assert.strictEqual(call.result, 2); + assert.strictEqual(call.error, undefined); + assert.strictEqual(call.target, undefined); + assert.strictEqual(call.this, number); +}); +``` + +### `mock.reset()` + + + +This function restores the default behavior of all mocks that were previously +created by this `MockTracker` and disassociates the mocks from the +`MockTracker` instance. Once disassociated, the mocks can still be used, but the +`MockTracker` instance can no longer be used to reset their behavior or +otherwise interact with them. + +After each test completes, this function is called on the test context's +`MockTracker`. If the global `MockTracker` is used extensively, calling this +function manually is recommended. + +### `mock.restoreAll()` + + + +This function restores the default behavior of all mocks that were previously +created by this `MockTracker`. Unlike `mock.reset()`, `mock.restoreAll()` does +not disassociate the mocks from the `MockTracker` instance. + +### `mock.setter(object, methodName[, implementation][, options])` + + + +This function is syntax sugar for [`MockTracker.method`][] with `options.setter` +set to `true`. + +## Class: `TapStream` + + + +* Extends {ReadableStream} + +A successful call to [`run()`][] method will return a new {TapStream} +object, streaming a [TAP][] output +`TapStream` will emit events, in the order of the tests definition + +### Event: `'test:diagnostic'` + +* `message` {string} The diagnostic message. + +Emitted when [`context.diagnostic`][] is called. + +### Event: `'test:fail'` + +* `data` {Object} + * `details` {Object} Additional execution metadata. + * `name` {string} The test name. + * `testNumber` {number} The ordinal number of the test. + * `todo` {string|undefined} Present if [`context.todo`][] is called + * `skip` {string|undefined} Present if [`context.skip`][] is called + +Emitted when a test fails. + +### Event: `'test:pass'` + +* `data` {Object} + * `details` {Object} Additional execution metadata. + * `name` {string} The test name. + * `testNumber` {number} The ordinal number of the test. + * `todo` {string|undefined} Present if [`context.todo`][] is called + * `skip` {string|undefined} Present if [`context.skip`][] is called + +Emitted when a test passes. + ## Class: `TestContext` An instance of `TestContext` is passed to each test function in order to @@ -575,6 +1035,33 @@ test('top level test', async (t) => { }); ``` +### `context.after([fn][, options])` + + + +* `fn` {Function|AsyncFunction} The hook function. The first argument + to this function is a [`TestContext`][] object. If the hook uses callbacks, + the callback function is passed as the second argument. **Default:** A no-op + function. +* `options` {Object} Configuration options for the hook. The following + properties are supported: + * `signal` {AbortSignal} Allows aborting an in-progress hook. + * `timeout` {number} A number of milliseconds the hook will fail after. + If unspecified, subtests inherit this value from their parent. + **Default:** `Infinity`. + +This function is used to create a hook that runs after the current test +finishes. + +```js +test('top level test', async (t) => { + t.after((t) => t.diagnostic(`finished running ${t.name}`)); + assert.ok('some relevant assertion here'); +}); +``` + ### `context.afterEach([, fn][, options])` * `fn` {Function|AsyncFunction} The hook function. The first argument @@ -710,8 +1197,15 @@ The name of the suite. [`AbortSignal`]: https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal [TAP]: https://testanything.org/ +[`MockFunctionContext`]: #class-mockfunctioncontext +[`MockTracker.method`]: #mockmethodobject-methodname-implementation-options +[`MockTracker`]: #class-mocktracke [`SuiteContext`]: #class-suitecontext [`TestContext`]: #class-testcontext +[`context.diagnostic`]: #contextdiagnosticmessage +[`context.skip`]: #contextskipmessage +[`context.todo`]: #contexttodomessage +[`run()`]: #runoptions [`test()`]: #testname-options-fn [describe options]: #describename-options-fn [it options]: #testname-options-fn diff --git a/bin/node--test-name-pattern.js b/bin/node--test-name-pattern.js new file mode 100644 index 0000000..128755a --- /dev/null +++ b/bin/node--test-name-pattern.js @@ -0,0 +1,7 @@ +#!/usr/bin/env node + +const { argv } = require('#internal/options') + +argv['test-name-pattern'] = true + +require('./node-core-test.js') diff --git a/bin/node-core-test.js b/bin/node-core-test.js index 23a1445..4bc6e89 100755 --- a/bin/node-core-test.js +++ b/bin/node-core-test.js @@ -10,9 +10,14 @@ const { argv } = require('#internal/options') Object.assign(argv, minimist(process.argv.slice(2), { boolean: ['test', 'test-only'], + string: ['test-name-pattern'], default: Object.prototype.hasOwnProperty.call(argv, 'test') ? { test: argv.test } : undefined })) +if (typeof argv['test-name-pattern'] === 'string') { + argv['test-name-pattern'] = [argv['test-name-pattern']] +} + process.argv.splice(1, Infinity, ...argv._) if (argv.test) { require('#internal/main/test_runner') diff --git a/lib/internal/errors.js b/lib/internal/errors.js index 8f3a07e..ab98407 100644 --- a/lib/internal/errors.js +++ b/lib/internal/errors.js @@ -1,4 +1,4 @@ -// https://github.com/nodejs/node/blob/1aab13cad9c800f4121c1d35b554b78c1b17bdbd/lib/internal/errors.js +// https://github.com/nodejs/node/blob/f8ce9117b19702487eb600493d941f7876e00e01/lib/internal/errors.js 'use strict' @@ -346,6 +346,21 @@ module.exports = { kIsNodeError } +E('ERR_TAP_LEXER_ERROR', function (errorMsg) { + hideInternalStackFrames(this) + return errorMsg +}, Error) +E('ERR_TAP_PARSER_ERROR', function (errorMsg, details, tokenCausedError, source) { + hideInternalStackFrames(this) + this.cause = tokenCausedError + const { column, line, start, end } = tokenCausedError.location + const errorDetails = `${details} at line ${line}, column ${column} (start ${start}, end ${end})` + return errorMsg + errorDetails +}, SyntaxError) +E('ERR_TAP_VALIDATION_ERROR', function (errorMsg) { + hideInternalStackFrames(this) + return errorMsg +}, Error) E('ERR_TEST_FAILURE', function (error, failureType) { hideInternalStackFrames(this) assert(typeof failureType === 'string', diff --git a/lib/internal/main/test_runner.js b/lib/internal/main/test_runner.js index db41695..ed7c644 100644 --- a/lib/internal/main/test_runner.js +++ b/lib/internal/main/test_runner.js @@ -1,148 +1,26 @@ -// https://github.com/nodejs/node/blob/2fd4c013c221653da2a7921d08fe1aa96aaba504/lib/internal/main/test_runner.js +// https://github.com/nodejs/node/blob/a165193c5c8e4bcfbd12b2c3f6e55a81a251c258/lib/internal/main/test_runner.js 'use strict' -const { - ArrayFrom, - ArrayPrototypeFilter, - ArrayPrototypeIncludes, - ArrayPrototypeJoin, - ArrayPrototypePush, - ArrayPrototypeSlice, - ArrayPrototypeSort, - SafePromiseAll, - SafeSet -} = require('#internal/per_context/primordials') const { prepareMainThreadExecution -} = require('#internal/bootstrap/pre_execution') -const { spawn } = require('child_process') -const { readdirSync, statSync } = require('fs') -const { - codes: { - ERR_TEST_FAILURE - } -} = require('#internal/errors') -const { toArray } = require('#internal/streams/operators').promiseReturningOperators -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'] +} = require('#internal/process/pre_execution') +const { isUsingInspector } = require('#internal/util/inspector') +const { run } = 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 - } +let concurrency = true +let inspectPort - options.userSupplied = false - - for (let i = 0; i < entries.length; i++) { - processPath(join(path, entries[i]), testFiles, options) - } - - options.underTestDir = underTestDir - } +if (isUsingInspector()) { + process.emitWarning('Using the inspector with --test forces running at a concurrency of 1. ' + + 'Use the inspectPort option to run with concurrency') + concurrency = 1 + inspectPort = process.debugPort } -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 }), - toArray.call(child.stdout, { signal: t.signal }), - toArray.call(child.stderr, { 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]) - } -})() +const tapStream = run({ concurrency, inspectPort }) +tapStream.pipe(process.stdout) +tapStream.once('test:fail', () => { + process.exitCode = 1 +}) diff --git a/lib/internal/per_context/primordials.js b/lib/internal/per_context/primordials.js index e599da9..2f12a34 100644 --- a/lib/internal/per_context/primordials.js +++ b/lib/internal/per_context/primordials.js @@ -3,29 +3,44 @@ const replaceAll = require('string.prototype.replaceall') exports.ArrayFrom = (it, mapFn) => Array.from(it, mapFn) +exports.ArrayIsArray = Array.isArray +exports.ArrayPrototypeConcat = (arr, ...el) => arr.concat(...el) exports.ArrayPrototypeFilter = (arr, fn) => arr.filter(fn) +exports.ArrayPrototypeFind = (arr, fn) => arr.find(fn) exports.ArrayPrototypeForEach = (arr, fn, thisArg) => arr.forEach(fn, thisArg) exports.ArrayPrototypeIncludes = (arr, el, fromIndex) => arr.includes(el, fromIndex) exports.ArrayPrototypeJoin = (arr, str) => arr.join(str) exports.ArrayPrototypeMap = (arr, mapFn) => arr.map(mapFn) +exports.ArrayPrototypePop = arr => arr.pop() exports.ArrayPrototypePush = (arr, ...el) => arr.push(...el) exports.ArrayPrototypeReduce = (arr, fn, originalVal) => arr.reduce(fn, originalVal) exports.ArrayPrototypeShift = arr => arr.shift() exports.ArrayPrototypeSlice = (arr, offset) => arr.slice(offset) +exports.ArrayPrototypeSome = (arr, fn) => arr.some(fn) exports.ArrayPrototypeSort = (arr, fn) => arr.sort(fn) +exports.ArrayPrototypeSplice = (arr, offset, len, ...el) => arr.splice(offset, len, ...el) exports.ArrayPrototypeUnshift = (arr, ...el) => arr.unshift(...el) +exports.Boolean = Boolean exports.Error = Error exports.ErrorCaptureStackTrace = (...args) => Error.captureStackTrace(...args) exports.FunctionPrototype = Function.prototype exports.FunctionPrototypeBind = (fn, obj, ...args) => fn.bind(obj, ...args) +exports.FunctionPrototypeCall = (fn, obj, ...args) => fn.call(obj, ...args) exports.MathMax = (...args) => Math.max(...args) exports.Number = Number +exports.NumberIsInteger = Number.isInteger +exports.NumberIsNaN = Number.isNaN +exports.NumberParseInt = (str, radix) => Number.parseInt(str, radix) +exports.NumberMIN_SAFE_INTEGER = Number.MIN_SAFE_INTEGER +exports.NumberMAX_SAFE_INTEGER = Number.MAX_SAFE_INTEGER +exports.ObjectAssign = (target, ...sources) => Object.assign(target, ...sources) exports.ObjectCreate = obj => Object.create(obj) exports.ObjectDefineProperties = (obj, props) => Object.defineProperties(obj, props) exports.ObjectDefineProperty = (obj, key, descr) => Object.defineProperty(obj, key, descr) exports.ObjectEntries = obj => Object.entries(obj) exports.ObjectFreeze = obj => Object.freeze(obj) exports.ObjectGetOwnPropertyDescriptor = (obj, key) => Object.getOwnPropertyDescriptor(obj, key) +exports.ObjectGetPrototypeOf = obj => Object.getPrototypeOf(obj) exports.ObjectIsExtensible = obj => Object.isExtensible(obj) exports.ObjectPrototypeHasOwnProperty = (obj, property) => Object.prototype.hasOwnProperty.call(obj, property) exports.ObjectSeal = (obj) => Object.seal(obj) @@ -35,22 +50,34 @@ exports.PromiseAll = iterator => Promise.all(iterator) exports.PromisePrototypeThen = (promise, thenFn, catchFn) => promise.then(thenFn, catchFn) exports.PromiseResolve = val => Promise.resolve(val) exports.PromiseRace = val => Promise.race(val) +exports.Proxy = Proxy +exports.RegExpPrototypeSymbolSplit = (reg, str) => reg[Symbol.split](str) exports.SafeArrayIterator = class ArrayIterator {constructor (array) { this.array = array }[Symbol.iterator] () { return this.array.values() }} exports.SafeMap = Map exports.SafePromiseAll = (array, mapFn) => Promise.all(mapFn ? array.map(mapFn) : array) exports.SafePromiseRace = (array, mapFn) => Promise.race(mapFn ? array.map(mapFn) : array) exports.SafeSet = Set exports.SafeWeakMap = WeakMap +exports.SafeWeakSet = WeakSet +exports.String = String +exports.StringPrototypeEndsWith = (haystack, needle, index) => haystack.endsWith(needle, index) exports.StringPrototypeIncludes = (str, needle) => str.includes(needle) exports.StringPrototypeMatch = (str, reg) => str.match(reg) +exports.StringPrototypeRepeat = (str, times) => str.repeat(times) exports.StringPrototypeReplace = (str, search, replacement) => str.replace(search, replacement) exports.StringPrototypeReplaceAll = replaceAll exports.StringPrototypeStartsWith = (haystack, needle, index) => haystack.startsWith(needle, index) exports.StringPrototypeSlice = (str, ...args) => str.slice(...args) exports.StringPrototypeSplit = (str, search, limit) => str.split(search, limit) +exports.StringPrototypeSubstring = (str, ...args) => str.substring(...args) +exports.StringPrototypeToUpperCase = str => str.toUpperCase() +exports.StringPrototypeTrim = str => str.trim() exports.Symbol = Symbol exports.SymbolFor = repr => Symbol.for(repr) +exports.ReflectApply = (target, self, args) => Reflect.apply(target, self, args) +exports.ReflectConstruct = (target, args, newTarget) => Reflect.construct(target, args, newTarget) +exports.ReflectGet = (target, property, receiver) => Reflect.get(target, property, receiver) exports.RegExpPrototypeExec = (reg, str) => reg.exec(str) exports.RegExpPrototypeSymbolReplace = (regexp, str, replacement) => regexp[Symbol.replace](str, replacement) diff --git a/lib/internal/bootstrap/pre_execution.js b/lib/internal/process/pre_execution.js similarity index 100% rename from lib/internal/bootstrap/pre_execution.js rename to lib/internal/process/pre_execution.js diff --git a/lib/internal/test_runner/harness.js b/lib/internal/test_runner/harness.js index 4d6e9c4..26d35ba 100644 --- a/lib/internal/test_runner/harness.js +++ b/lib/internal/test_runner/harness.js @@ -1,9 +1,9 @@ -// https://github.com/nodejs/node/blob/659dc126932f986fc33c7f1c878cb2b57a1e2fac/lib/internal/test_runner/harness.js +// https://github.com/nodejs/node/blob/06603c44a5b0e92b1a3591ace467ce9770bf9658/lib/internal/test_runner/harness.js 'use strict' const { ArrayPrototypeForEach, - FunctionPrototypeBind, - SafeMap + SafeMap, + SafeWeakSet } = require('#internal/per_context/primordials') const { createHook, @@ -14,13 +14,18 @@ const { ERR_TEST_FAILURE } } = require('#internal/errors') +const { kEmptyObject } = require('#internal/util') const { getOptionValue } = require('#internal/options') -const { Test, ItTest, Suite } = require('#internal/test_runner/test') +const { kCancelledByParent, Test, ItTest, Suite } = require('#internal/test_runner/test') +const { bigint: hrtime } = process.hrtime -const isTestRunner = getOptionValue('--test') +const isTestRunnerCli = getOptionValue('--test') const testResources = new SafeMap() -const root = new Test({ __proto__: null, name: '' }) -let wasRootSetup = false +const wasRootSetup = new SafeWeakSet() + +function createTestTree (options = kEmptyObject) { + return setup(new Test({ __proto__: null, ...options, name: '' })) +} function createProcessEventHandler (eventName, rootTest) { return (err) => { @@ -42,6 +47,7 @@ function createProcessEventHandler (eventName, rootTest) { `triggered an ${eventName} event.` rootTest.diagnostic(msg) + process.exitCode = 1 return } @@ -51,7 +57,7 @@ function createProcessEventHandler (eventName, rootTest) { } function setup (root) { - if (wasRootSetup) { + if (wasRootSetup.has(root)) { return root } const hook = createHook({ @@ -80,54 +86,13 @@ function setup (root) { createProcessEventHandler('unhandledRejection', root) const exitHandler = () => { - root.postRun() - - let passCount = 0 - let failCount = 0 - let skipCount = 0 - let todoCount = 0 - let cancelledCount = 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.cancelled) { - cancelledCount++ - } 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.postRun(new ERR_TEST_FAILURE( + 'Promise resolution is still pending but the event loop has already resolved', + kCancelledByParent)) - 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, `cancelled ${cancelledCount}`) - 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 || cancelledCount > 0) { - process.exitCode = 1 - } } const terminationHandler = () => { @@ -138,29 +103,41 @@ function setup (root) { process.on('uncaughtException', exceptionHandler) process.on('unhandledRejection', rejectionHandler) process.on('beforeExit', exitHandler) - // TODO(MoLow): Make it configurable to hook when isTestRunner === false. - if (isTestRunner) { + // TODO(MoLow): Make it configurable to hook when isTestRunnerCli === false. + if (isTestRunnerCli) { process.on('SIGINT', terminationHandler) process.on('SIGTERM', terminationHandler) } - root.reporter.pipe(process.stdout) + root.startTime = hrtime() root.reporter.version() - wasRootSetup = true + wasRootSetup.add(root) return root } +let globalRoot +function getGlobalRoot () { + if (!globalRoot) { + globalRoot = createTestTree() + globalRoot.reporter.pipe(process.stdout) + globalRoot.reporter.once('test:fail', () => { + process.exitCode = 1 + }) + } + return globalRoot +} + function test (name, options, fn) { - const subtest = setup(root).createSubtest(Test, name, options, fn) + const subtest = getGlobalRoot().createSubtest(Test, name, options, fn) return subtest.start() } function runInParentContext (Factory) { function run (name, options, fn, overrides) { - const parent = testResources.get(executionAsyncId()) || setup(root) + const parent = testResources.get(executionAsyncId()) || getGlobalRoot() const subtest = parent.createSubtest(Factory, name, options, fn, overrides) - if (parent === root) { + if (parent === getGlobalRoot()) { subtest.start() } } @@ -179,13 +156,14 @@ function runInParentContext (Factory) { function hook (hook) { return (fn, options) => { - const parent = testResources.get(executionAsyncId()) || setup(root) + const parent = testResources.get(executionAsyncId()) || getGlobalRoot() parent.createHook(hook, fn, options) } } module.exports = { - test: FunctionPrototypeBind(test, root), + createTestTree, + test, describe: runInParentContext(Suite), it: runInParentContext(ItTest), before: hook('before'), diff --git a/lib/internal/test_runner/mock.js b/lib/internal/test_runner/mock.js new file mode 100644 index 0000000..af46e47 --- /dev/null +++ b/lib/internal/test_runner/mock.js @@ -0,0 +1,371 @@ +// https://github.com/nodejs/node/blob/929aada39d0f418193ca03cc360ced8c5b4ce553/lib/internal/test_runner/mock.js +'use strict' +const { + ArrayPrototypePush, + ArrayPrototypeSlice, + Error, + FunctionPrototypeCall, + ObjectDefineProperty, + ObjectGetOwnPropertyDescriptor, + ObjectGetPrototypeOf, + Proxy, + ReflectApply, + ReflectConstruct, + ReflectGet, + SafeMap +} = require('#internal/per_context/primordials') +const { + codes: { + ERR_INVALID_ARG_TYPE, + ERR_INVALID_ARG_VALUE + } +} = require('#internal/errors') +const { kEmptyObject } = require('#internal/util') +const { + validateBoolean, + validateFunction, + validateInteger, + validateObject +} = require('#internal/validators') + +function kDefaultFunction () {} + +class MockFunctionContext { + #calls + #mocks + #implementation + #restore + #times + + constructor (implementation, restore, times) { + this.#calls = [] + this.#mocks = new SafeMap() + this.#implementation = implementation + this.#restore = restore + this.#times = times + } + + get calls () { + return ArrayPrototypeSlice(this.#calls, 0) + } + + callCount () { + return this.#calls.length + } + + mockImplementation (implementation) { + validateFunction(implementation, 'implementation') + this.#implementation = implementation + } + + mockImplementationOnce (implementation, onCall) { + validateFunction(implementation, 'implementation') + const nextCall = this.#calls.length + const call = onCall ?? nextCall + validateInteger(call, 'onCall', nextCall) + this.#mocks.set(call, implementation) + } + + restore () { + const { descriptor, object, original, methodName } = this.#restore + + if (typeof methodName === 'string') { + // This is an object method spy. + ObjectDefineProperty(object, methodName, descriptor) + } else { + // This is a bare function spy. There isn't much to do here but make + // the mock call the original function. + this.#implementation = original + } + } + + trackCall (call) { + ArrayPrototypePush(this.#calls, call) + } + + nextImpl () { + const nextCall = this.#calls.length + const mock = this.#mocks.get(nextCall) + const impl = mock ?? this.#implementation + + if (nextCall + 1 === this.#times) { + this.restore() + } + + this.#mocks.delete(nextCall) + return impl + } +} + +const { nextImpl, restore, trackCall } = MockFunctionContext.prototype +delete MockFunctionContext.prototype.trackCall +delete MockFunctionContext.prototype.nextImpl + +class MockTracker { + #mocks = [] + + fn ( + original = function () {}, + implementation = original, + options = kEmptyObject + ) { + if (original !== null && typeof original === 'object') { + options = original + original = function () {} + implementation = original + } else if (implementation !== null && typeof implementation === 'object') { + options = implementation + implementation = original + } + + validateFunction(original, 'original') + validateFunction(implementation, 'implementation') + validateObject(options, 'options') + const { times = Infinity } = options + validateTimes(times, 'options.times') + const ctx = new MockFunctionContext(implementation, { original }, times) + return this.#setupMock(ctx, original) + } + + method ( + objectOrFunction, + methodName, + implementation = kDefaultFunction, + options = kEmptyObject + ) { + validateStringOrSymbol(methodName, 'methodName') + if (typeof objectOrFunction !== 'function') { + validateObject(objectOrFunction, 'object') + } + + if (implementation !== null && typeof implementation === 'object') { + options = implementation + implementation = kDefaultFunction + } + + validateFunction(implementation, 'implementation') + validateObject(options, 'options') + + const { + getter = false, + setter = false, + times = Infinity + } = options + + validateBoolean(getter, 'options.getter') + validateBoolean(setter, 'options.setter') + validateTimes(times, 'options.times') + + if (setter && getter) { + throw new ERR_INVALID_ARG_VALUE( + 'options.setter', setter, "cannot be used with 'options.getter'" + ) + } + const descriptor = findMethodOnPrototypeChain(objectOrFunction, methodName) + + let original + + if (getter) { + original = descriptor?.get + } else if (setter) { + original = descriptor?.set + } else { + original = descriptor?.value + } + + if (typeof original !== 'function') { + throw new ERR_INVALID_ARG_VALUE( + 'methodName', original, 'must be a method' + ) + } + + const restore = { descriptor, object: objectOrFunction, methodName } + const impl = implementation === kDefaultFunction + ? original + : implementation + const ctx = new MockFunctionContext(impl, restore, times) + const mock = this.#setupMock(ctx, original) + const mockDescriptor = { + __proto__: null, + configurable: descriptor.configurable, + enumerable: descriptor.enumerable + } + + if (getter) { + mockDescriptor.get = mock + mockDescriptor.set = descriptor.set + } else if (setter) { + mockDescriptor.get = descriptor.get + mockDescriptor.set = mock + } else { + mockDescriptor.writable = descriptor.writable + mockDescriptor.value = mock + } + + ObjectDefineProperty(objectOrFunction, methodName, mockDescriptor) + + return mock + } + + getter ( + object, + methodName, + implementation = kDefaultFunction, + options = kEmptyObject + ) { + if (implementation !== null && typeof implementation === 'object') { + options = implementation + implementation = kDefaultFunction + } else { + validateObject(options, 'options') + } + + const { getter = true } = options + + if (getter === false) { + throw new ERR_INVALID_ARG_VALUE( + 'options.getter', getter, 'cannot be false' + ) + } + + return this.method(object, methodName, implementation, { + ...options, + getter + }) + } + + setter ( + object, + methodName, + implementation = kDefaultFunction, + options = kEmptyObject + ) { + if (implementation !== null && typeof implementation === 'object') { + options = implementation + implementation = kDefaultFunction + } else { + validateObject(options, 'options') + } + + const { setter = true } = options + + if (setter === false) { + throw new ERR_INVALID_ARG_VALUE( + 'options.setter', setter, 'cannot be false' + ) + } + + return this.method(object, methodName, implementation, { + ...options, + setter + }) + } + + reset () { + this.restoreAll() + this.#mocks = [] + } + + restoreAll () { + for (let i = 0; i < this.#mocks.length; i++) { + FunctionPrototypeCall(restore, this.#mocks[i]) + } + } + + #setupMock (ctx, fnToMatch) { + const mock = new Proxy(fnToMatch, { + __proto__: null, + apply (_fn, thisArg, argList) { + const fn = FunctionPrototypeCall(nextImpl, ctx) + let result + let error + + try { + result = ReflectApply(fn, thisArg, argList) + } catch (err) { + error = err + throw err + } finally { + FunctionPrototypeCall(trackCall, ctx, { + arguments: argList, + error, + result, + // eslint-disable-next-line no-restricted-syntax + stack: new Error(), + target: undefined, + this: thisArg + }) + } + + return result + }, + construct (target, argList, newTarget) { + const realTarget = FunctionPrototypeCall(nextImpl, ctx) + let result + let error + + try { + result = ReflectConstruct(realTarget, argList, newTarget) + } catch (err) { + error = err + throw err + } finally { + FunctionPrototypeCall(trackCall, ctx, { + arguments: argList, + error, + result, + // eslint-disable-next-line no-restricted-syntax + stack: new Error(), + target, + this: result + }) + } + + return result + }, + get (target, property, receiver) { + if (property === 'mock') { + return ctx + } + + return ReflectGet(target, property, receiver) + } + }) + + ArrayPrototypePush(this.#mocks, ctx) + return mock + } +} + +function validateStringOrSymbol (value, name) { + if (typeof value !== 'string' && typeof value !== 'symbol') { + throw new ERR_INVALID_ARG_TYPE(name, ['string', 'symbol'], value) + } +} + +function validateTimes (value, name) { + if (value === Infinity) { + return + } + + validateInteger(value, name, 1) +} + +function findMethodOnPrototypeChain (instance, methodName) { + let host = instance + let descriptor + + while (host !== null) { + descriptor = ObjectGetOwnPropertyDescriptor(host, methodName) + + if (descriptor) { + break + } + + host = ObjectGetPrototypeOf(host) + } + + return descriptor +} + +module.exports = { MockTracker } diff --git a/lib/internal/test_runner/runner.js b/lib/internal/test_runner/runner.js new file mode 100644 index 0000000..42c83c6 --- /dev/null +++ b/lib/internal/test_runner/runner.js @@ -0,0 +1,285 @@ +// https://github.com/nodejs/node/blob/232efb06fe8787e9573e298ce7ac293ad23b7684/lib/internal/test_runner/runner.js +'use strict' +const { + ArrayFrom, + ArrayPrototypeFilter, + ArrayPrototypeForEach, + ArrayPrototypeIncludes, + ArrayPrototypePush, + ArrayPrototypeSlice, + ArrayPrototypeSort, + ObjectAssign, + PromisePrototypeThen, + SafePromiseAll, + SafeSet, + StringPrototypeRepeat +} = require('#internal/per_context/primordials') + +const { spawn } = require('child_process') +const { readdirSync, statSync } = require('fs') +// TODO(aduh95): switch to internal/readline/interface when backporting to Node.js 16.x is no longer a concern. +const { createInterface } = require('readline') +const { + codes: { + ERR_TEST_FAILURE + } +} = require('#internal/errors') +const { toArray } = require('#internal/streams/operators').promiseReturningOperators +const { validateArray } = require('#internal/validators') +const { getInspectPort, isUsingInspector, isInspectorMessage } = require('#internal/util/inspector') +const { kEmptyObject } = require('#internal/util') +const { createTestTree } = require('#internal/test_runner/harness') +const { kDefaultIndent, kSubtestsFailed, Test } = require('#internal/test_runner/test') +const { TapParser } = require('#internal/test_runner/tap_parser') +const { YAMLToJs } = require('#internal/test_runner/yaml_parser') +const { TokenKind } = require('#internal/test_runner/tap_lexer') +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 getRunArgs ({ path, inspectPort }) { + const argv = ArrayPrototypeFilter(process.execArgv, filterExecArgv) + if (isUsingInspector()) { + ArrayPrototypePush(argv, `--inspect-port=${getInspectPort(inspectPort)}`) + } + ArrayPrototypePush(argv, path) + return argv +} + +class FileTest extends Test { + #buffer = [] + #handleReportItem ({ kind, node, nesting = 0 }) { + const indent = StringPrototypeRepeat(kDefaultIndent, nesting + 1) + + switch (kind) { + case TokenKind.TAP_VERSION: + // TODO(manekinekko): handle TAP version coming from the parser. + // this.reporter.version(node.version); + break + + case TokenKind.TAP_PLAN: + this.reporter.plan(indent, node.end - node.start + 1) + break + + case TokenKind.TAP_SUBTEST_POINT: + this.reporter.subtest(indent, node.name) + break + + case TokenKind.TAP_TEST_POINT: + // eslint-disable-next-line no-case-declarations + const { todo, skip, pass } = node.status + // eslint-disable-next-line no-case-declarations + let directive + + if (skip) { + directive = this.reporter.getSkip(node.reason) + } else if (todo) { + directive = this.reporter.getTodo(node.reason) + } else { + directive = kEmptyObject + } + + if (pass) { + this.reporter.ok( + indent, + node.id, + node.description, + YAMLToJs(node.diagnostics), + directive + ) + } else { + this.reporter.fail( + indent, + node.id, + node.description, + YAMLToJs(node.diagnostics), + directive + ) + } + break + + case TokenKind.COMMENT: + if (indent === kDefaultIndent) { + // Ignore file top level diagnostics + break + } + this.reporter.diagnostic(indent, node.comment) + break + + case TokenKind.UNKNOWN: + this.reporter.diagnostic(indent, node.value) + break + } + } + + addToReport (ast) { + if (!this.isClearToSend()) { + ArrayPrototypePush(this.#buffer, ast) + return + } + this.reportSubtest() + this.#handleReportItem(ast) + } + + report () { + this.reportSubtest() + ArrayPrototypeForEach(this.#buffer, (ast) => this.#handleReportItem(ast)) + super.report() + } +} + +function runTestFile (path, root, inspectPort, filesWatcher) { + const subtest = root.createSubtest(FileTest, path, async (t) => { + const args = getRunArgs({ path, inspectPort }) + const stdio = ['pipe', 'pipe', 'pipe'] + const env = { ...process.env } + if (filesWatcher) { + stdio.push('ipc') + env.WATCH_REPORT_DEPENDENCIES = '1' + } + + const child = spawn(process.execPath, args, { signal: t.signal, encoding: 'utf8', env, stdio }) + + let err + + filesWatcher?.watchChildProcessModules(child, path) + + child.on('error', (error) => { + err = error + }) + + if (isUsingInspector()) { + const rl = createInterface({ input: child.stderr }) + rl.on('line', (line) => { + if (isInspectorMessage(line)) { + process.stderr.write(line + '\n') + } + }) + } + + const parser = new TapParser() + child.stderr.pipe(parser).on('data', (ast) => { + if (ast.lexeme && isInspectorMessage(ast.lexeme)) { + process.stderr.write(ast.lexeme + '\n') + } + }) + + child.stdout.pipe(parser).on('data', (ast) => { + subtest.addToReport(ast) + }) + + const { 0: { 0: code, 1: signal } } = await SafePromiseAll([ + once(child, 'exit', { signal: t.signal }), + toArray.call(child.stdout, { signal: t.signal }) + ]) + + if (code !== 0 || signal !== null) { + if (!err) { + err = ObjectAssign(new ERR_TEST_FAILURE('test failed', kSubtestsFailed), { + __proto__: null, + exitCode: code, + signal, + // The stack will not be useful since the failures came from tests + // in a child process. + stack: undefined + }) + } + + throw err + } + }) + return subtest.start() +} + +function run (options) { + if (options === null || typeof options !== 'object') { + options = kEmptyObject + } + const { concurrency, timeout, signal, files, inspectPort } = options + + if (files != null) { + validateArray(files, 'options.files') + } + + const root = createTestTree({ concurrency, timeout, signal }) + const testFiles = files ?? createTestFileList() + + PromisePrototypeThen(SafePromiseAll(testFiles, (path) => runTestFile(path, root, inspectPort)), + () => root.postRun()) + + return root.reporter +} + +module.exports = { run } diff --git a/lib/internal/test_runner/tap_checker.js b/lib/internal/test_runner/tap_checker.js new file mode 100644 index 0000000..6b25155 --- /dev/null +++ b/lib/internal/test_runner/tap_checker.js @@ -0,0 +1,156 @@ +// https://github.com/nodejs/node/blob/f8ce9117b19702487eb600493d941f7876e00e01/lib/internal/test_runner/tap_checker.js +'use strict' + +const { + ArrayPrototypeFilter, + ArrayPrototypeFind, + NumberParseInt +} = require('#internal/per_context/primordials') +const { + codes: { ERR_TAP_VALIDATION_ERROR } +} = require('#internal/errors') +const { TokenKind } = require('#internal/test_runner/tap_lexer') + +// TODO(@manekinekko): add more validation rules based on the TAP14 spec. +// See https://testanything.org/tap-version-14-specification.html +class TAPValidationStrategy { + validate (ast) { + this.#validateVersion(ast) + this.#validatePlan(ast) + this.#validateTestPoints(ast) + + return true + } + + #validateVersion (ast) { + const entry = ArrayPrototypeFind( + ast, + (node) => node.kind === TokenKind.TAP_VERSION + ) + + if (!entry) { + throw new ERR_TAP_VALIDATION_ERROR('missing TAP version') + } + + const { version } = entry.node + + // TAP14 specification is compatible with observed behavior of existing TAP13 consumers and producers + if (version !== '14' && version !== '13') { + throw new ERR_TAP_VALIDATION_ERROR('TAP version should be 13 or 14') + } + } + + #validatePlan (ast) { + const entry = ArrayPrototypeFind( + ast, + (node) => node.kind === TokenKind.TAP_PLAN + ) + + if (!entry) { + throw new ERR_TAP_VALIDATION_ERROR('missing TAP plan') + } + + const plan = entry.node + + if (!plan.start) { + throw new ERR_TAP_VALIDATION_ERROR('missing plan start') + } + + if (!plan.end) { + throw new ERR_TAP_VALIDATION_ERROR('missing plan end') + } + + const planStart = NumberParseInt(plan.start, 10) + const planEnd = NumberParseInt(plan.end, 10) + + if (planEnd !== 0 && planStart > planEnd) { + throw new ERR_TAP_VALIDATION_ERROR( + `plan start ${planStart} is greater than plan end ${planEnd}` + ) + } + } + + // TODO(@manekinekko): since we are dealing with a flat AST, we need to + // validate test points grouped by their "nesting" level. This is because a set of + // Test points belongs to a TAP document. Each new subtest block creates a new TAP document. + // https://testanything.org/tap-version-14-specification.html#subtests + #validateTestPoints (ast) { + const bailoutEntry = ArrayPrototypeFind( + ast, + (node) => node.kind === TokenKind.TAP_BAIL_OUT + ) + const planEntry = ArrayPrototypeFind( + ast, + (node) => node.kind === TokenKind.TAP_PLAN + ) + const testPointEntries = ArrayPrototypeFilter( + ast, + (node) => node.kind === TokenKind.TAP_TEST_POINT + ) + + const plan = planEntry.node + + const planStart = NumberParseInt(plan.start, 10) + const planEnd = NumberParseInt(plan.end, 10) + + if (planEnd === 0 && testPointEntries.length > 0) { + throw new ERR_TAP_VALIDATION_ERROR( + `found ${testPointEntries.length} Test Point${ + testPointEntries.length > 1 ? 's' : '' + } but plan is ${planStart}..0` + ) + } + + if (planEnd > 0) { + if (testPointEntries.length === 0) { + throw new ERR_TAP_VALIDATION_ERROR('missing Test Points') + } + + if (!bailoutEntry && testPointEntries.length !== planEnd) { + throw new ERR_TAP_VALIDATION_ERROR( + `test Points count ${testPointEntries.length} does not match plan count ${planEnd}` + ) + } + + for (let i = 0; i < testPointEntries.length; i++) { + const test = testPointEntries[i].node + const testId = NumberParseInt(test.id, 10) + + if (testId < planStart || testId > planEnd) { + throw new ERR_TAP_VALIDATION_ERROR( + `test ${testId} is out of plan range ${planStart}..${planEnd}` + ) + } + } + } + } +} + +// TAP14 and TAP13 are compatible with each other +class TAP13ValidationStrategy extends TAPValidationStrategy {} +class TAP14ValidationStrategy extends TAPValidationStrategy {} + +class TapChecker { + static TAP13 = '13' + static TAP14 = '14' + + constructor ({ specs }) { + switch (specs) { + case TapChecker.TAP13: + this.strategy = new TAP13ValidationStrategy() + break + default: + this.strategy = new TAP14ValidationStrategy() + } + } + + check (ast) { + return this.strategy.validate(ast) + } +} + +module.exports = { + TapChecker, + TAP14ValidationStrategy, + TAP13ValidationStrategy +} diff --git a/lib/internal/test_runner/tap_lexer.js b/lib/internal/test_runner/tap_lexer.js new file mode 100644 index 0000000..022f75f --- /dev/null +++ b/lib/internal/test_runner/tap_lexer.js @@ -0,0 +1,529 @@ +// https://github.com/nodejs/node/blob/2483da743cbb48f31c6b3f8cb186d89f31d73611/lib/internal/test_runner/tap_lexer.js +'use strict' + +const { + ArrayPrototypePop, + ArrayPrototypePush, + MathMax, + SafeSet, + StringPrototypeIncludes, + StringPrototypeTrim +} = require('#internal/per_context/primordials') +const { + codes: { ERR_TAP_LEXER_ERROR } +} = require('#internal/errors') + +const kEOL = '' +const kEOF = '' + +const TokenKind = { + EOF: 'EOF', + EOL: 'EOL', + NEWLINE: 'NewLine', + NUMERIC: 'Numeric', + LITERAL: 'Literal', + KEYWORD: 'Keyword', + WHITESPACE: 'Whitespace', + COMMENT: 'Comment', + DASH: 'Dash', + PLUS: 'Plus', + HASH: 'Hash', + ESCAPE: 'Escape', + UNKNOWN: 'Unknown', + + // TAP tokens + TAP: 'TAPKeyword', + TAP_VERSION: 'VersionKeyword', + TAP_PLAN: 'PlanKeyword', + TAP_TEST_POINT: 'TestPointKeyword', + TAP_SUBTEST_POINT: 'SubTestPointKeyword', + TAP_TEST_OK: 'TestOkKeyword', + TAP_TEST_NOTOK: 'TestNotOkKeyword', + TAP_YAML_START: 'YamlStartKeyword', + TAP_YAML_END: 'YamlEndKeyword', + TAP_YAML_BLOCK: 'YamlKeyword', + TAP_PRAGMA: 'PragmaKeyword', + TAP_BAIL_OUT: 'BailOutKeyword' +} + +class Token { + constructor ({ kind, value, stream }) { + const valueLength = ('' + value).length + this.kind = kind + this.value = value + this.location = { + line: stream.line, + column: MathMax(stream.column - valueLength + 1, 1), // 1 based + start: MathMax(stream.pos - valueLength, 0), // zero based + end: stream.pos - (value === '' ? 0 : 1) // zero based + } + + // EOF is a special case + if (value === TokenKind.EOF) { + const eofPosition = stream.input.length + 1 // We consider EOF to be outside the stream + this.location.start = eofPosition + this.location.end = eofPosition + this.location.column = stream.column + 1 // 1 based + } + } +} + +class InputStream { + constructor (input) { + this.input = input + this.pos = 0 + this.column = 0 + this.line = 1 + } + + eof () { + return this.peek() === undefined + } + + peek (offset = 0) { + return this.input[this.pos + offset] + } + + next () { + const char = this.peek() + if (char === undefined) { + return undefined + } + + this.pos++ + this.column++ + if (char === '\n') { + this.line++ + this.column = 0 + } + + return char + } +} + +class TapLexer { + static Keywords = new SafeSet([ + 'TAP', + 'version', + 'ok', + 'not', + '...', + '---', + '..', + 'pragma', + '-', + '+' + + // NOTE: "Skip", "Todo" and "Bail out!" literals are deferred to the parser + ]) + + #isComment = false + #source = null + #line = 1 + #column = 0 + #escapeStack = [] + #lastScannedToken = null + + constructor (source) { + this.#source = new InputStream(source) + this.#lastScannedToken = new Token({ + kind: TokenKind.EOL, + value: kEOL, + stream: this.#source + }) + } + + scan () { + const tokens = [] + let chunk = [] + while (!this.eof()) { + const token = this.#scanToken() + + // Remember the last scanned token (except for whitespace) + if (token.kind !== TokenKind.WHITESPACE) { + this.#lastScannedToken = token + } + + ArrayPrototypePush(chunk, token) + if (token.kind === TokenKind.NEWLINE) { + // Store the current chunk + NEWLINE token + ArrayPrototypePush(tokens, chunk) + chunk = [] + } + } + + if (chunk.length > 0) { + ArrayPrototypePush(chunk, this.#scanEOL()) + ArrayPrototypePush(tokens, chunk) + } + + // send EOF as a separate chunk + ArrayPrototypePush(tokens, [this.#scanEOF()]) + + return tokens + } + + next () { + return this.#source.next() + } + + eof () { + return this.#source.eof() + } + + error (message, token, expected = '') { + this.#source.error(message, token, expected) + } + + #scanToken () { + const char = this.next() + + if (this.#isEOFSymbol(char)) { + return this.#scanEOF() + } else if (this.#isNewLineSymbol(char)) { + return this.#scanNewLine(char) + } else if (this.#isNumericSymbol(char)) { + return this.#scanNumeric(char) + } else if (this.#isDashSymbol(char)) { + return this.#scanDash(char) + } else if (this.#isPlusSymbol(char)) { + return this.#scanPlus(char) + } else if (this.#isHashSymbol(char)) { + return this.#scanHash(char) + } else if (this.#isEscapeSymbol(char)) { + return this.#scanEscapeSymbol(char) + } else if (this.#isWhitespaceSymbol(char)) { + return this.#scanWhitespace(char) + } else if (this.#isLiteralSymbol(char)) { + return this.#scanLiteral(char) + } + + throw new ERR_TAP_LEXER_ERROR( + `Unexpected character: ${char} at line ${this.#line}, column ${ + this.#column + }` + ) + } + + #scanNewLine (char) { + // In case of odd number of ESCAPE symbols, we need to clear the remaining + // escape chars from the stack and start fresh for the next line. + this.#escapeStack = [] + + // We also need to reset the comment flag + this.#isComment = false + + return new Token({ + kind: TokenKind.NEWLINE, + value: char, + stream: this.#source + }) + } + + #scanEOL () { + return new Token({ + kind: TokenKind.EOL, + value: kEOL, + stream: this.#source + }) + } + + #scanEOF () { + this.#isComment = false + + return new Token({ + kind: TokenKind.EOF, + value: kEOF, + stream: this.#source + }) + } + + #scanEscapeSymbol (char) { + // If the escape symbol has been escaped (by previous symbol), + // or if the next symbol is a whitespace symbol, + // then consume it as a literal. + if ( + this.#hasTheCurrentCharacterBeenEscaped() || + this.#source.peek(1) === TokenKind.WHITESPACE + ) { + ArrayPrototypePop(this.#escapeStack) + return new Token({ + kind: TokenKind.LITERAL, + value: char, + stream: this.#source + }) + } + + // Otherwise, consume the escape symbol as an escape symbol that should be ignored by the parser + // we also need to push the escape symbol to the escape stack + // and consume the next character as a literal (done in the next turn) + ArrayPrototypePush(this.#escapeStack, char) + return new Token({ + kind: TokenKind.ESCAPE, + value: char, + stream: this.#source + }) + } + + #scanWhitespace (char) { + return new Token({ + kind: TokenKind.WHITESPACE, + value: char, + stream: this.#source + }) + } + + #scanDash (char) { + // Peek next 3 characters and check if it's a YAML start marker + const marker = char + this.#source.peek() + this.#source.peek(1) + + if (this.#isYamlStartSymbol(marker)) { + this.next() // consume second - + this.next() // consume third - + + return new Token({ + kind: TokenKind.TAP_YAML_START, + value: marker, + stream: this.#source + }) + } + + return new Token({ + kind: TokenKind.DASH, + value: char, + stream: this.#source + }) + } + + #scanPlus (char) { + return new Token({ + kind: TokenKind.PLUS, + value: char, + stream: this.#source + }) + } + + #scanHash (char) { + const lastCharacter = this.#source.peek(-2) + const nextToken = this.#source.peek() + + // If we encounter a hash symbol at the beginning of a line, + // we consider it as a comment + if (!lastCharacter || this.#isNewLineSymbol(lastCharacter)) { + this.#isComment = true + return new Token({ + kind: TokenKind.COMMENT, + value: char, + stream: this.#source + }) + } + + // The only valid case where a hash symbol is considered as a hash token + // is when it's preceded by a whitespace symbol and followed by a non-hash symbol + if ( + this.#isWhitespaceSymbol(lastCharacter) && + !this.#isHashSymbol(nextToken) + ) { + return new Token({ + kind: TokenKind.HASH, + value: char, + stream: this.#source + }) + } + + const charHasBeenEscaped = this.#hasTheCurrentCharacterBeenEscaped() + if (this.#isComment || charHasBeenEscaped) { + if (charHasBeenEscaped) { + ArrayPrototypePop(this.#escapeStack) + } + + return new Token({ + kind: TokenKind.LITERAL, + value: char, + stream: this.#source + }) + } + + // As a fallback, we consume the hash symbol as a literal + return new Token({ + kind: TokenKind.LITERAL, + value: char, + stream: this.#source + }) + } + + #scanLiteral (char) { + let word = char + while (!this.#source.eof()) { + const nextChar = this.#source.peek() + if (this.#isLiteralSymbol(nextChar)) { + word += this.#source.next() + } else { + break + } + } + + word = StringPrototypeTrim(word) + + if (TapLexer.Keywords.has(word)) { + const token = this.#scanTAPKeyword(word) + if (token) { + return token + } + } + + if (this.#isYamlEndSymbol(word)) { + return new Token({ + kind: TokenKind.TAP_YAML_END, + value: word, + stream: this.#source + }) + } + + return new Token({ + kind: TokenKind.LITERAL, + value: word, + stream: this.#source + }) + } + + #scanTAPKeyword (word) { + const isLastScannedTokenEOLorNewLine = + TokenKind.EOL === this.#lastScannedToken.kind || + TokenKind.NEWLINE === this.#lastScannedToken.kind + + if (word === 'TAP' && isLastScannedTokenEOLorNewLine) { + return new Token({ + kind: TokenKind.TAP, + value: word, + stream: this.#source + }) + } + + if (word === 'version' && this.#lastScannedToken.kind === TokenKind.TAP) { + return new Token({ + kind: TokenKind.TAP_VERSION, + value: word, + stream: this.#source + }) + } + + if (word === '..' && this.#lastScannedToken.kind === TokenKind.NUMERIC) { + return new Token({ + kind: TokenKind.TAP_PLAN, + value: word, + stream: this.#source + }) + } + + if (word === 'not' && isLastScannedTokenEOLorNewLine) { + return new Token({ + kind: TokenKind.TAP_TEST_NOTOK, + value: word, + stream: this.#source + }) + } + + if ( + word === 'ok' && + (this.#lastScannedToken.kind === TokenKind.TAP_TEST_NOTOK || + isLastScannedTokenEOLorNewLine) + ) { + return new Token({ + kind: TokenKind.TAP_TEST_OK, + value: word, + stream: this.#source + }) + } + + if (word === 'pragma' && isLastScannedTokenEOLorNewLine) { + return new Token({ + kind: TokenKind.TAP_PRAGMA, + value: word, + stream: this.#source + }) + } + + return null + } + + #scanNumeric (char) { + let number = char + while (!this.#source.eof()) { + const nextChar = this.#source.peek() + if (this.#isNumericSymbol(nextChar)) { + number += nextChar + this.#source.next() + } else { + break + } + } + return new Token({ + kind: TokenKind.NUMERIC, + value: number, + stream: this.#source + }) + } + + #hasTheCurrentCharacterBeenEscaped () { + // Use the escapeStack to keep track of the escape characters + return this.#escapeStack.length > 0 + } + + #isNumericSymbol (char) { + return char >= '0' && char <= '9' + } + + #isLiteralSymbol (char) { + return ( + (char >= 'a' && char <= 'z') || + (char >= 'A' && char <= 'Z') || + this.#isSpecialCharacterSymbol(char) + ) + } + + #isSpecialCharacterSymbol (char) { + // We deliberately do not include "# \ + -"" in this list + // these are used for comments/reasons explanations, pragma and escape characters + // whitespace is not included because it is handled separately + return StringPrototypeIncludes('!"$%&\'()*,./:;<=>?@[]^_`{|}~', char) + } + + #isWhitespaceSymbol (char) { + return char === ' ' || char === '\t' + } + + #isEOFSymbol (char) { + return char === undefined + } + + #isNewLineSymbol (char) { + return char === '\n' || char === '\r' + } + + #isHashSymbol (char) { + return char === '#' + } + + #isDashSymbol (char) { + return char === '-' + } + + #isPlusSymbol (char) { + return char === '+' + } + + #isEscapeSymbol (char) { + return char === '\\' + } + + #isYamlStartSymbol (char) { + return char === '---' + } + + #isYamlEndSymbol (char) { + return char === '...' + } +} + +module.exports = { TapLexer, TokenKind } diff --git a/lib/internal/test_runner/tap_parser.js b/lib/internal/test_runner/tap_parser.js new file mode 100644 index 0000000..a309bff --- /dev/null +++ b/lib/internal/test_runner/tap_parser.js @@ -0,0 +1,987 @@ +// https://github.com/nodejs/node/blob/7a42a206ac37d95060640b4812aaef32535937e1/lib/internal/test_runner/tap_parser.js +'use strict' + +const { + ArrayPrototypeFilter, + ArrayPrototypeForEach, + ArrayPrototypeIncludes, + ArrayPrototypeJoin, + ArrayPrototypeMap, + ArrayPrototypePop, + ArrayPrototypePush, + Boolean, + Number, + RegExpPrototypeExec, + String, + StringPrototypeEndsWith, + StringPrototypeReplaceAll, + StringPrototypeSlice, + StringPrototypeSplit, + StringPrototypeTrim +} = require('#internal/per_context/primordials') +const Transform = require('stream').Transform +const { TapLexer, TokenKind } = require('#internal/test_runner/tap_lexer') +const { TapChecker } = require('#internal/test_runner/tap_checker') +const { + codes: { ERR_TAP_VALIDATION_ERROR, ERR_TAP_PARSER_ERROR } +} = require('#internal/errors') +const { kEmptyObject } = require('#internal/util') +/** + * + * TAP14 specifications + * + * See https://testanything.org/tap-version-14-specification.html + * + * Note that the following grammar is intended as a rough "pseudocode" guidance. + * It is not strict EBNF: + * + * TAPDocument := Version Plan Body | Version Body Plan + * Version := "TAP version 14\n" + * Plan := "1.." (Number) (" # " Reason)? "\n" + * Body := (TestPoint | BailOut | Pragma | Comment | Anything | Empty | Subtest)* + * TestPoint := ("not ")? "ok" (" " Number)? ((" -")? (" " Description) )? (" " Directive)? "\n" (YAMLBlock)? + * Directive := " # " ("todo" | "skip") (" " Reason)? + * YAMLBlock := " ---\n" (YAMLLine)* " ...\n" + * YAMLLine := " " (YAML)* "\n" + * BailOut := "Bail out!" (" " Reason)? "\n" + * Reason := [^\n]+ + * Pragma := "pragma " [+-] PragmaKey "\n" + * PragmaKey := ([a-zA-Z0-9_-])+ + * Subtest := ("# Subtest" (": " SubtestName)?)? "\n" SubtestDocument TestPoint + * Comment := ^ (" ")* "#" [^\n]* "\n" + * Empty := [\s\t]* "\n" + * Anything := [^\n]+ "\n" + * + */ + +/** + * An LL(1) parser for TAP14/TAP13. + */ +class TapParser extends Transform { + #checker = null + #lexer = null + #currentToken = null + + #input = '' + #currentChunkAsString = '' + #lastLine = '' + + #tokens = [[]] + #flatAST = [] + #bufferedComments = [] + #bufferedTestPoints = [] + #lastTestPointDetails = {} + #yamlBlockBuffer = [] + + #currentTokenIndex = 0 + #currentTokenChunk = 0 + #subTestNestingLevel = 0 + #yamlCurrentIndentationLevel = 0 + #kSubtestBlockIndentationFactor = 4 + + #isYAMLBlock = false + #isSyncParsingEnabled = false + + constructor ({ specs = TapChecker.TAP13 } = kEmptyObject) { + super({ __proto__: null, readableObjectMode: true }) + + this.#checker = new TapChecker({ specs }) + } + + // ----------------------------------------------------------------------// + // ----------------------------- Public API -----------------------------// + // ----------------------------------------------------------------------// + + parse (chunkAsString = '', callback = null) { + this.#isSyncParsingEnabled = false + this.#currentTokenChunk = 0 + this.#currentTokenIndex = 0 + // Note: we are overwriting the input on each stream call + // This is fine because we don't want to parse previous chunks + this.#input = chunkAsString + this.#lexer = new TapLexer(chunkAsString) + + try { + this.#tokens = this.#scanTokens() + this.#parseTokens(callback) + } catch (error) { + callback(null, error) + } + } + + parseSync (input = '', callback = null) { + if (typeof input !== 'string' || input === '') { + return [] + } + + this.#isSyncParsingEnabled = true + this.#input = input + this.#lexer = new TapLexer(input) + this.#tokens = this.#scanTokens() + + this.#parseTokens(callback) + + if (this.#isYAMLBlock) { + // Looks like we have a non-ending YAML block + this.#error('Expected end of YAML block') + } + + // Manually flush the remaining buffered comments and test points + this._flush() + + return this.#flatAST + } + + // Check if the TAP content is semantically valid + // Note: Validating the TAP content requires the whole AST to be available. + check () { + if (this.#isSyncParsingEnabled) { + return this.#checker.check(this.#flatAST) + } + + // TODO(@manekinekko): when running in async mode, it doesn't make sense to + // validate the current chunk. Validation needs to whole AST to be available. + throw new ERR_TAP_VALIDATION_ERROR( + 'TAP validation is not supported for async parsing' + ) + } + // ----------------------------------------------------------------------// + // --------------------------- Transform API ----------------------------// + // ----------------------------------------------------------------------// + + processChunk (chunk) { + const str = this.#lastLine + chunk.toString('utf8') + const lines = StringPrototypeSplit(str, '\n') + this.#lastLine = ArrayPrototypePop(lines) + + let chunkAsString = ArrayPrototypeJoin(lines, '\n') + // Special case where chunk is emitted by a child process + chunkAsString = StringPrototypeReplaceAll( + chunkAsString, + '[out] ', + '' + ) + chunkAsString = StringPrototypeReplaceAll( + chunkAsString, + '[err] ', + '' + ) + if (StringPrototypeEndsWith(chunkAsString, '\n')) { + chunkAsString = StringPrototypeSlice(chunkAsString, 0, -1) + } + if (StringPrototypeEndsWith(chunkAsString, 'EOF')) { + chunkAsString = StringPrototypeSlice(chunkAsString, 0, -3) + } + + return chunkAsString + } + + _transform (chunk, _encoding, next) { + const chunkAsString = this.processChunk(chunk) + + if (!chunkAsString) { + // Ignore empty chunks + next() + return + } + + this.parse(chunkAsString, (node, error) => { + if (error) { + next(error) + return + } + + if (node.kind === TokenKind.EOF) { + // Emit when the current chunk is fully processed and consumed + next() + } + }) + } + + // Flush the remaining buffered comments and test points + // This will be called automatically when the stream is closed + // We also call this method manually when we reach the end of the sync parsing + _flush (next = null) { + if (!this.#lastLine) { + this.#__flushPendingTestPointsAndComments() + next?.() + return + } + // Parse the remaining line + this.parse(this.#lastLine, (node, error) => { + this.#lastLine = '' + + if (error) { + next?.(error) + return + } + + if (node.kind === TokenKind.EOF) { + this.#__flushPendingTestPointsAndComments() + next?.() + } + }) + } + + #__flushPendingTestPointsAndComments () { + ArrayPrototypeForEach(this.#bufferedTestPoints, (node) => { + this.#emit(node) + }) + ArrayPrototypeForEach(this.#bufferedComments, (node) => { + this.#emit(node) + }) + + // Clean up + this.#bufferedTestPoints = [] + this.#bufferedComments = [] + } + + // ----------------------------------------------------------------------// + // ----------------------------- Private API ----------------------------// + // ----------------------------------------------------------------------// + + #scanTokens () { + return this.#lexer.scan() + } + + #parseTokens (callback = null) { + for (let index = 0; index < this.#tokens.length; index++) { + const chunk = this.#tokens[index] + this.#parseChunk(chunk) + } + + callback?.({ kind: TokenKind.EOF }) // eslint-disable-line n/no-callback-literal + } + + #parseChunk (chunk) { + this.#subTestNestingLevel = this.#getCurrentIndentationLevel(chunk) + // We compute the current index of the token in the chunk + // based on the indentation level (number of spaces). + // We also need to take into account if we are in a YAML block or not. + // If we are in a YAML block, we compute the current index of the token + // based on the indentation level of the YAML block (start block). + + if (this.#isYAMLBlock) { + this.#currentTokenIndex = + this.#yamlCurrentIndentationLevel * + this.#kSubtestBlockIndentationFactor + } else { + this.#currentTokenIndex = + this.#subTestNestingLevel * this.#kSubtestBlockIndentationFactor + this.#yamlCurrentIndentationLevel = this.#subTestNestingLevel + } + + // Parse current chunk + const node = this.#TAPDocument(chunk) + + // Emit the parsed node to both the stream and the AST + this.#emitOrBufferCurrentNode(node) + + // Move pointers to the next chunk and reset the current token index + this.#currentTokenChunk++ + this.#currentTokenIndex = 0 + } + + #error (message) { + if (!this.#isSyncParsingEnabled) { + // When async parsing is enabled, don't throw. + // Unrecognized tokens would be ignored. + return + } + + const token = this.#currentToken || { value: '', kind: '' } + // Escape NewLine characters + if (token.value === '\n') { + token.value = '\\n' + } + + throw new ERR_TAP_PARSER_ERROR( + message, + `, received "${token.value}" (${token.kind})`, + token, + this.#input + ) + } + + #peek (shouldSkipBlankTokens = true) { + if (shouldSkipBlankTokens) { + this.#skip(TokenKind.WHITESPACE) + } + + return this.#tokens[this.#currentTokenChunk][this.#currentTokenIndex] + } + + #next (shouldSkipBlankTokens = true) { + if (shouldSkipBlankTokens) { + this.#skip(TokenKind.WHITESPACE) + } + + if (this.#tokens[this.#currentTokenChunk]) { + this.#currentToken = + this.#tokens[this.#currentTokenChunk][this.#currentTokenIndex++] + } else { + this.#currentToken = null + } + + return this.#currentToken + } + + // Skip the provided tokens in the current chunk + #skip (...tokensToSkip) { + let token = this.#tokens[this.#currentTokenChunk][this.#currentTokenIndex] + while (token && ArrayPrototypeIncludes(tokensToSkip, token.kind)) { + // pre-increment to skip current tokens but make sure we don't advance index on the last iteration + token = this.#tokens[this.#currentTokenChunk][++this.#currentTokenIndex] + } + } + + #readNextLiterals () { + const literals = [] + let nextToken = this.#peek(false) + + // Read all literal, numeric, whitespace and escape tokens until we hit a different token + // or reach end of current chunk + while ( + nextToken && + ArrayPrototypeIncludes( + [ + TokenKind.LITERAL, + TokenKind.NUMERIC, + TokenKind.DASH, + TokenKind.PLUS, + TokenKind.WHITESPACE, + TokenKind.ESCAPE + ], + nextToken.kind + ) + ) { + const word = this.#next(false).value + + // Don't output escaped characters + if (nextToken.kind !== TokenKind.ESCAPE) { + ArrayPrototypePush(literals, word) + } + + nextToken = this.#peek(false) + } + + return ArrayPrototypeJoin(literals, '') + } + + #countLeadingSpacesInCurrentChunk (chunk) { + // Count the number of whitespace tokens in the chunk, starting from the first token + let whitespaceCount = 0 + while (chunk?.[whitespaceCount]?.kind === TokenKind.WHITESPACE) { + whitespaceCount++ + } + return whitespaceCount + } + + #addDiagnosticsToLastTestPoint (currentNode) { + const { length, [length - 1]: lastTestPoint } = this.#bufferedTestPoints + + // Diagnostic nodes are only added to Test points of the same nesting level + if (lastTestPoint && lastTestPoint.nesting === currentNode.nesting) { + lastTestPoint.node.time = this.#lastTestPointDetails.duration + + // TODO(@manekinekko): figure out where to put the other diagnostic properties + // See https://github.com/nodejs/node/pull/44952 + lastTestPoint.node.diagnostics = lastTestPoint.node.diagnostics || [] + + ArrayPrototypeForEach(currentNode.node.diagnostics, (diagnostic) => { + // Avoid adding empty diagnostics + if (diagnostic) { + ArrayPrototypePush(lastTestPoint.node.diagnostics, diagnostic) + } + }) + + this.#bufferedTestPoints = [] + } + + return lastTestPoint + } + + #flushBufferedTestPointNode (shouldClearBuffer = true) { + if (this.#bufferedTestPoints.length > 0) { + this.#emit(this.#bufferedTestPoints[0]) + + if (shouldClearBuffer) { + this.#bufferedTestPoints = [] + } + } + } + + #addCommentsToCurrentNode (currentNode) { + if (this.#bufferedComments.length > 0) { + currentNode.comments = ArrayPrototypeMap( + this.#bufferedComments, + (c) => c.node.comment + ) + this.#bufferedComments = [] + } + + return currentNode + } + + #flushBufferedComments (shouldClearBuffer = true) { + if (this.#bufferedComments.length > 0) { + ArrayPrototypeForEach(this.#bufferedComments, (node) => { + this.#emit(node) + }) + + if (shouldClearBuffer) { + this.#bufferedComments = [] + } + } + } + + #getCurrentIndentationLevel (chunk) { + const whitespaceCount = this.#countLeadingSpacesInCurrentChunk(chunk) + return (whitespaceCount / this.#kSubtestBlockIndentationFactor) | 0 + } + + #emit (node) { + if (node.kind !== TokenKind.EOF) { + ArrayPrototypePush(this.#flatAST, node) + this.push({ + __proto__: null, + ...node + }) + } + } + + #emitOrBufferCurrentNode (currentNode) { + currentNode = { + ...currentNode, + nesting: this.#subTestNestingLevel, + lexeme: this.#currentChunkAsString + } + + switch (currentNode.kind) { + // Emit these nodes + case TokenKind.UNKNOWN: + if (!currentNode.node.value) { + // Ignore unrecognized and empty nodes + break + } + + // Otherwise continue and process node + // eslint no-fallthrough + + case TokenKind.TAP_PLAN: + case TokenKind.TAP_PRAGMA: + case TokenKind.TAP_VERSION: + case TokenKind.TAP_BAIL_OUT: + case TokenKind.TAP_SUBTEST_POINT: + // Check if we have a buffered test point, and if so, emit it + this.#flushBufferedTestPointNode() + + // If we have buffered comments, add them to the current node + currentNode = this.#addCommentsToCurrentNode(currentNode) + + // Emit the current node + this.#emit(currentNode) + break + + // By default, we buffer the next test point node in case we have a diagnostic + // to add to it in the next iteration + // Note: in case we hit and EOF, we flush the comments buffer (see _flush()) + case TokenKind.TAP_TEST_POINT: + // In case of an already buffered test point, we flush it and buffer the current one + // Because diagnostic nodes are only added to the last processed test point + this.#flushBufferedTestPointNode() + + // Buffer this node (and also add any pending comments to it) + ArrayPrototypePush( + this.#bufferedTestPoints, + this.#addCommentsToCurrentNode(currentNode) + ) + break + + // Keep buffering comments until we hit a non-comment node, then add them to the that node + // Note: in case we hit and EOF, we flush the comments buffer (see _flush()) + case TokenKind.COMMENT: + ArrayPrototypePush(this.#bufferedComments, currentNode) + break + + // Diagnostic nodes are added to Test points of the same nesting level + case TokenKind.TAP_YAML_END: + // Emit either the last updated test point (w/ diagnostics) or the current diagnostics node alone + this.#emit( + this.#addDiagnosticsToLastTestPoint(currentNode) || currentNode + ) + break + + // In case we hit an EOF, we emit it to indicate the end of the stream + case TokenKind.EOF: + this.#emit(currentNode) + break + } + } + + #serializeChunk (chunk) { + return ArrayPrototypeJoin( + ArrayPrototypeMap( + // Exclude NewLine and EOF tokens + ArrayPrototypeFilter( + chunk, + (token) => + token.kind !== TokenKind.NEWLINE && token.kind !== TokenKind.EOF + ), + (token) => token.value + ), + '' + ) + } + + // --------------------------------------------------------------------------// + // ------------------------------ Parser rules ------------------------------// + // --------------------------------------------------------------------------// + + // TAPDocument := Version Plan Body | Version Body Plan + #TAPDocument (tokenChunks) { + this.#currentChunkAsString = this.#serializeChunk(tokenChunks) + const firstToken = this.#peek(false) + + if (firstToken) { + const { kind } = firstToken + + switch (kind) { + case TokenKind.TAP: + return this.#Version() + case TokenKind.NUMERIC: + return this.#Plan() + case TokenKind.TAP_TEST_OK: + case TokenKind.TAP_TEST_NOTOK: + return this.#TestPoint() + case TokenKind.COMMENT: + case TokenKind.HASH: + return this.#Comment() + case TokenKind.TAP_PRAGMA: + return this.#Pragma() + case TokenKind.WHITESPACE: + return this.#YAMLBlock() + case TokenKind.LITERAL: + // Check for "Bail out!" literal (case insensitive) + if ( + RegExpPrototypeExec(/^Bail\s+out!/i, this.#currentChunkAsString) + ) { + return this.#Bailout() + } else if (this.#isYAMLBlock) { + return this.#YAMLBlock() + } + + // Read token because error needs the last token details + this.#next(false) + this.#error('Expected a valid token') + + break + case TokenKind.EOF: + return firstToken + + case TokenKind.NEWLINE: + // Consume and ignore NewLine token + return this.#next(false) + default: + // Read token because error needs the last token details + this.#next(false) + this.#error('Expected a valid token') + } + } + + const node = { + kind: TokenKind.UNKNOWN, + node: { + value: this.#currentChunkAsString + } + } + + // We make sure the emitted node has the same shape + // both in sync and async parsing (for the stream interface) + return node + } + + // ----------------Version---------------- + // Version := "TAP version Number\n" + #Version () { + const tapToken = this.#peek() + + if (tapToken.kind === TokenKind.TAP) { + this.#next() // Consume the TAP token + } else { + this.#error('Expected "TAP" keyword') + } + + const versionToken = this.#peek() + if (versionToken?.kind === TokenKind.TAP_VERSION) { + this.#next() // Consume the version token + } else { + this.#error('Expected "version" keyword') + } + + const numberToken = this.#peek() + if (numberToken?.kind === TokenKind.NUMERIC) { + const version = this.#next().value + const node = { kind: TokenKind.TAP_VERSION, node: { version } } + return node + } + this.#error('Expected a version number') + } + + // ----------------Plan---------------- + // Plan := "1.." (Number) (" # " Reason)? "\n" + #Plan () { + // Even if specs mention plan starts at 1, we need to make sure we read the plan start value + // in case of a missing or invalid plan start value + const planStart = this.#next() + + if (planStart.kind !== TokenKind.NUMERIC) { + this.#error('Expected a plan start count') + } + + const planToken = this.#next() + if (planToken?.kind !== TokenKind.TAP_PLAN) { + this.#error('Expected ".." symbol') + } + + const planEnd = this.#next() + if (planEnd?.kind !== TokenKind.NUMERIC) { + this.#error('Expected a plan end count') + } + + const plan = { + start: planStart.value, + end: planEnd.value + } + + // Read optional reason + const hashToken = this.#peek() + if (hashToken) { + if (hashToken.kind === TokenKind.HASH) { + this.#next() // skip hash + plan.reason = StringPrototypeTrim(this.#readNextLiterals()) + } else if (hashToken.kind === TokenKind.LITERAL) { + this.#error('Expected "#" symbol before a reason') + } + } + + const node = { + kind: TokenKind.TAP_PLAN, + node: plan + } + + return node + } + + // ----------------TestPoint---------------- + // TestPoint := ("not ")? "ok" (" " Number)? ((" -")? (" " Description) )? (" " Directive)? "\n" (YAMLBlock)? + // Directive := " # " ("todo" | "skip") (" " Reason)? + // YAMLBlock := " ---\n" (YAMLLine)* " ...\n" + // YAMLLine := " " (YAML)* "\n" + + // Test Status: ok/not ok (required) + // Test number (recommended) + // Description (recommended, prefixed by " - ") + // Directive (only when necessary) + #TestPoint () { + const notToken = this.#peek() + let isTestFailed = false + + if (notToken.kind === TokenKind.TAP_TEST_NOTOK) { + this.#next() // skip "not" token + isTestFailed = true + } + + const okToken = this.#next() + if (okToken.kind !== TokenKind.TAP_TEST_OK) { + this.#error('Expected "ok" or "not ok" keyword') + } + + // Read optional test number + let numberToken = this.#peek() + if (numberToken && numberToken.kind === TokenKind.NUMERIC) { + numberToken = this.#next().value + } else { + numberToken = '' // Set an empty ID to indicate that the test hasn't provider an ID + } + + const test = { + // Output both failed and passed properties to make it easier for the checker to detect the test status + status: { + fail: isTestFailed, + pass: !isTestFailed, + todo: false, + skip: false + }, + id: numberToken, + description: '', + reason: '', + time: 0, + diagnostics: [] + } + + // Read optional description prefix " - " + const descriptionDashToken = this.#peek() + if (descriptionDashToken && descriptionDashToken.kind === TokenKind.DASH) { + this.#next() // skip dash + } + + // Read optional description + if (this.#peek()) { + const description = StringPrototypeTrim(this.#readNextLiterals()) + if (description) { + test.description = description + } + } + + // Read optional directive and reason + const hashToken = this.#peek() + if (hashToken && hashToken.kind === TokenKind.HASH) { + this.#next() // skip hash + } + + let todoOrSkipToken = this.#peek() + if (todoOrSkipToken && todoOrSkipToken.kind === TokenKind.LITERAL) { + if (RegExpPrototypeExec(/todo/i, todoOrSkipToken.value)) { + todoOrSkipToken = 'todo' + this.#next() // skip token + } else if (RegExpPrototypeExec(/skip/i, todoOrSkipToken.value)) { + todoOrSkipToken = 'skip' + this.#next() // skip token + } + } + + const reason = StringPrototypeTrim(this.#readNextLiterals()) + if (todoOrSkipToken) { + if (reason) { + test.reason = reason + } + + test.status.todo = todoOrSkipToken === 'todo' + test.status.skip = todoOrSkipToken === 'skip' + } + + const node = { + kind: TokenKind.TAP_TEST_POINT, + node: test + } + + return node + } + + // ----------------Bailout---------------- + // BailOut := "Bail out!" (" " Reason)? "\n" + #Bailout () { + this.#next() // skip "Bail" + this.#next() // skip "out!" + + // Read optional reason + const hashToken = this.#peek() + if (hashToken && hashToken.kind === TokenKind.HASH) { + this.#next() // skip hash + } + + const reason = StringPrototypeTrim(this.#readNextLiterals()) + + const node = { + kind: TokenKind.TAP_BAIL_OUT, + node: { bailout: true, reason } + } + + return node + } + + // ----------------Comment---------------- + // Comment := ^ (" ")* "#" [^\n]* "\n" + #Comment () { + const commentToken = this.#next() + if ( + commentToken.kind !== TokenKind.COMMENT && + commentToken.kind !== TokenKind.HASH + ) { + this.#error('Expected "#" symbol') + } + + const commentContent = this.#peek() + if (commentContent) { + if (RegExpPrototypeExec(/^Subtest:/i, commentContent.value) !== null) { + this.#next() // skip subtest keyword + const name = StringPrototypeTrim(this.#readNextLiterals()) + const node = { + kind: TokenKind.TAP_SUBTEST_POINT, + node: { + name + } + } + + return node + } + + const comment = StringPrototypeTrim(this.#readNextLiterals()) + const node = { + kind: TokenKind.COMMENT, + node: { comment } + } + + return node + } + + // If there is no comment content, then we ignore the current node + } + + // ----------------YAMLBlock---------------- + // YAMLBlock := " ---\n" (YAMLLine)* " ...\n" + #YAMLBlock () { + const space1 = this.#peek(false) + if (space1 && space1.kind === TokenKind.WHITESPACE) { + this.#next(false) // skip 1st space + } + + const space2 = this.#peek(false) + if (space2 && space2.kind === TokenKind.WHITESPACE) { + this.#next(false) // skip 2nd space + } + + const yamlBlockSymbol = this.#peek(false) + + if (yamlBlockSymbol.kind === TokenKind.WHITESPACE) { + if (this.#isYAMLBlock === false) { + this.#next(false) // skip 3rd space + this.#error('Expected valid YAML indentation (2 spaces)') + } + } + + if (yamlBlockSymbol.kind === TokenKind.TAP_YAML_START) { + if (this.#isYAMLBlock) { + // Looks like we have another YAML start block, but we didn't close the previous one + this.#error('Unexpected YAML start marker') + } + + this.#isYAMLBlock = true + this.#yamlCurrentIndentationLevel = this.#subTestNestingLevel + this.#lastTestPointDetails = {} + + // Consume the YAML start marker + this.#next(false) // skip "---" + + // No need to pass this token to the stream interface + return + } else if (yamlBlockSymbol.kind === TokenKind.TAP_YAML_END) { + this.#next(false) // skip "..." + + if (!this.#isYAMLBlock) { + // Looks like we have an YAML end block, but we didn't encounter any YAML start marker + this.#error('Unexpected YAML end marker') + } + + this.#isYAMLBlock = false + + const diagnostics = this.#yamlBlockBuffer + this.#yamlBlockBuffer = [] // Free the buffer for the next YAML block + + const node = { + kind: TokenKind.TAP_YAML_END, + node: { + diagnostics + } + } + + return node + } + + if (this.#isYAMLBlock) { + this.#YAMLLine() + } else { + return { + kind: TokenKind.UNKNOWN, + node: { + value: yamlBlockSymbol.value + } + } + } + } + + // ----------------YAMLLine---------------- + // YAMLLine := " " (YAML)* "\n" + #YAMLLine () { + const yamlLiteral = this.#readNextLiterals() + const { 0: key, 1: value } = StringPrototypeSplit(yamlLiteral, ':', 2) + + // Note that this.#lastTestPointDetails has been cleared when we encounter a YAML start marker + + switch (key) { + case 'duration_ms': + this.#lastTestPointDetails.duration = Number(value) + break + // Below are diagnostic properties introduced in https://github.com/nodejs/node/pull/44952 + case 'expected': + this.#lastTestPointDetails.expected = Boolean(value) + break + case 'actual': + this.#lastTestPointDetails.actual = Boolean(value) + break + case 'operator': + this.#lastTestPointDetails.operator = String(value) + break + } + + ArrayPrototypePush(this.#yamlBlockBuffer, yamlLiteral) + } + + // ----------------PRAGMA---------------- + // Pragma := "pragma " [+-] PragmaKey "\n" + // PragmaKey := ([a-zA-Z0-9_-])+ + // TODO(@manekinekko): pragmas are parsed but not used yet! TapChecker() should take care of that. + #Pragma () { + const pragmaToken = this.#next() + if (pragmaToken.kind !== TokenKind.TAP_PRAGMA) { + this.#error('Expected "pragma" keyword') + } + + const pragmas = {} + + let nextToken = this.#peek() + while ( + nextToken && + ArrayPrototypeIncludes( + [TokenKind.NEWLINE, TokenKind.EOF, TokenKind.EOL], + nextToken.kind + ) === false + ) { + let isEnabled = true + const pragmaKeySign = this.#next() + if (pragmaKeySign.kind === TokenKind.PLUS) { + isEnabled = true + } else if (pragmaKeySign.kind === TokenKind.DASH) { + isEnabled = false + } else { + this.#error('Expected "+" or "-" before pragma keys') + } + + const pragmaKeyToken = this.#peek() + if (pragmaKeyToken.kind !== TokenKind.LITERAL) { + this.#error('Expected pragma key') + } + + let pragmaKey = this.#next().value + + // In some cases, pragma key can be followed by a comma separator, + // so we need to remove it + pragmaKey = StringPrototypeReplaceAll(pragmaKey, ',', '') + + pragmas[pragmaKey] = isEnabled + nextToken = this.#peek() + } + + const node = { + kind: TokenKind.TAP_PRAGMA, + node: { + pragmas + } + } + + return node + } +} + +module.exports = { TapParser } diff --git a/lib/internal/test_runner/tap_stream.js b/lib/internal/test_runner/tap_stream.js index e240598..97a4918 100644 --- a/lib/internal/test_runner/tap_stream.js +++ b/lib/internal/test_runner/tap_stream.js @@ -1,27 +1,29 @@ -// https://github.com/nodejs/node/blob/0d46cf6af8977d1e2e4c4886bf0f7e0dbe76d21c/lib/internal/test_runner/tap_stream.js +// https://github.com/nodejs/node/blob/232efb06fe8787e9573e298ce7ac293ad23b7684/lib/internal/test_runner/tap_stream.js 'use strict' const { ArrayPrototypeForEach, ArrayPrototypeJoin, + ArrayPrototypeMap, ArrayPrototypePush, ArrayPrototypeShift, ObjectEntries, StringPrototypeReplaceAll, + StringPrototypeToUpperCase, StringPrototypeSplit, RegExpPrototypeSymbolReplace } = require('#internal/per_context/primordials') const { inspectWithNoCustomRetry } = require('#internal/errors') const Readable = require('#internal/streams/readable') -const { isError } = require('#internal/util') +const { isError, kEmptyObject } = require('#internal/util') const kFrameStartRegExp = /^ {4}at / const kLineBreakRegExp = /\n|\r\n/ +const kDefaultTAPVersion = 13 const inspectOptions = { colors: false, breakLength: Infinity } let testModule // Lazy loaded due to circular dependency. function lazyLoadTest () { - // Node.js 14.x does not support Logical_nullish_assignment operator testModule = testModule ?? require('#internal/test_runner/test') return testModule @@ -53,12 +55,16 @@ class TapStream extends Readable { this.#tryPush(`Bail out!${message ? ` ${tapEscape(message)}` : ''}\n`) } - fail (indent, testNumber, description, directive) { - this.#test(indent, testNumber, 'not ok', description, directive) + fail (indent, testNumber, name, details, directive) { + this.emit('test:fail', { __proto__: null, name, testNumber, details, ...directive }) + this.#test(indent, testNumber, 'not ok', name, directive) + this.#details(indent, details) } - ok (indent, testNumber, description, directive) { - this.#test(indent, testNumber, 'ok', description, directive) + ok (indent, testNumber, name, details, directive) { + this.emit('test:pass', { __proto__: null, name, testNumber, details, ...directive }) + this.#test(indent, testNumber, 'ok', name, directive) + this.#details(indent, details) } plan (indent, count, explanation) { @@ -68,46 +74,49 @@ class TapStream extends Readable { } getSkip (reason) { - return `SKIP${reason ? ` ${tapEscape(reason)}` : ''}` + return { __proto__: null, skip: reason } } getTodo (reason) { - return `TODO${reason ? ` ${tapEscape(reason)}` : ''}` + return { __proto__: null, todo: reason } } subtest (indent, name) { this.#tryPush(`${indent}# Subtest: ${tapEscape(name)}\n`) } - details (indent, duration, error) { + #details (indent, data = kEmptyObject) { + const { error, duration_ms } = data // eslint-disable-line camelcase let details = `${indent} ---\n` - details += jsToYaml(indent, 'duration_ms', duration) + details += jsToYaml(indent, 'duration_ms', duration_ms) details += jsToYaml(indent, null, error) details += `${indent} ...\n` this.#tryPush(details) } diagnostic (indent, message) { + this.emit('test:diagnostic', message) this.#tryPush(`${indent}# ${tapEscape(message)}\n`) } - version () { - this.#tryPush('TAP version 13\n') + version (spec = kDefaultTAPVersion) { + this.#tryPush(`TAP version ${spec}\n`) } - #test (indent, testNumber, status, description, directive) { + #test (indent, testNumber, status, name, directive = kEmptyObject) { let line = `${indent}${status} ${testNumber}` - if (description) { - line += ` ${tapEscape(description)}` + if (name) { + line += ` ${tapEscape(`- ${name}`)}` } - if (directive) { - line += ` # ${directive}` - } + line += ArrayPrototypeJoin(ArrayPrototypeMap(ObjectEntries(directive), ({ 0: key, 1: value }) => ( + ` # ${StringPrototypeToUpperCase(key)}${value ? ` ${tapEscape(value)}` : ''}` + )), '') line += '\n' + this.#tryPush(line) } @@ -124,9 +133,15 @@ class TapStream extends Readable { // In certain places, # and \ need to be escaped as \# and \\. function tapEscape (input) { - return StringPrototypeReplaceAll( - StringPrototypeReplaceAll(input, '\\', '\\\\'), '#', '\\#' - ) + let result = StringPrototypeReplaceAll(input, '\\', '\\\\') + result = StringPrototypeReplaceAll(result, '#', '\\#') + result = StringPrototypeReplaceAll(result, '\b', '\\b') + result = StringPrototypeReplaceAll(result, '\f', '\\f') + result = StringPrototypeReplaceAll(result, '\t', '\\t') + result = StringPrototypeReplaceAll(result, '\n', '\\n') + result = StringPrototypeReplaceAll(result, '\r', '\\r') + result = StringPrototypeReplaceAll(result, '\v', '\\v') + return result } function jsToYaml (indent, name, value) { @@ -171,23 +186,36 @@ function jsToYaml (indent, name, value) { } if (isErrorObj) { - const { kTestCodeFailure, kHookFailure } = lazyLoadTest() + const { kTestCodeFailure, kUnwrapErrors } = lazyLoadTest() const { cause, code, failureType, message, + expected, + actual, + operator, stack } = value let errMsg = message ?? '' let errStack = stack let errCode = code + let errExpected = expected + let errActual = actual + let errOperator = operator + let errIsAssertion = isAssertionLike(value) // 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 || failureType === kHookFailure)) { + if (code === 'ERR_TEST_FAILURE' && kUnwrapErrors.has(failureType)) { errStack = cause?.stack ?? errStack errCode = cause?.code ?? errCode + if (isAssertionLike(cause)) { + errExpected = cause.expected + errActual = cause.actual + errOperator = cause.operator ?? errOperator + errIsAssertion = true + } if (failureType === kTestCodeFailure) { errMsg = cause?.message ?? errMsg } @@ -199,6 +227,14 @@ function jsToYaml (indent, name, value) { result += jsToYaml(indent, 'code', errCode) } + if (errIsAssertion) { + result += jsToYaml(indent, 'expected', errExpected) + result += jsToYaml(indent, 'actual', errActual) + if (errOperator) { + result += jsToYaml(indent, 'operator', errOperator) + } + } + if (typeof errStack === 'string') { const frames = [] @@ -206,7 +242,9 @@ function jsToYaml (indent, name, value) { StringPrototypeSplit(errStack, kLineBreakRegExp), (frame) => { const processed = RegExpPrototypeSymbolReplace( - kFrameStartRegExp, frame, '' + kFrameStartRegExp, + frame, + '' ) if (processed.length > 0 && processed.length !== frame.length) { @@ -219,7 +257,7 @@ function jsToYaml (indent, name, value) { const frameDelimiter = `\n${indent} ` result += `${indent} stack: |-${frameDelimiter}` - result += `${ArrayPrototypeJoin(frames, `${frameDelimiter}`)}\n` + result += `${ArrayPrototypeJoin(frames, frameDelimiter)}\n` } } } @@ -227,4 +265,8 @@ function jsToYaml (indent, name, value) { return result } +function isAssertionLike (value) { + return value && typeof value === 'object' && 'expected' in value && 'actual' in value +} + module.exports = { TapStream } diff --git a/lib/internal/test_runner/test.js b/lib/internal/test_runner/test.js index 89b2f35..10cf1fc 100644 --- a/lib/internal/test_runner/test.js +++ b/lib/internal/test_runner/test.js @@ -1,12 +1,14 @@ -// https://github.com/nodejs/node/blob/0d46cf6af8977d1e2e4c4886bf0f7e0dbe76d21c/lib/internal/test_runner/test.js +// https://github.com/nodejs/node/blob/385d595a4f1d887f6d4221e6071571132498d57c/lib/internal/test_runner/test.js 'use strict' const { + ArrayPrototypeMap, ArrayPrototypePush, ArrayPrototypeReduce, ArrayPrototypeShift, ArrayPrototypeSlice, + ArrayPrototypeSome, ArrayPrototypeUnshift, FunctionPrototype, MathMax, @@ -15,7 +17,9 @@ const { PromisePrototypeThen, PromiseResolve, ReflectApply, + RegExpPrototypeExec, SafeMap, + SafeSet, SafePromiseAll, SafePromiseRace, Symbol @@ -31,11 +35,17 @@ const { AbortError } = require('#internal/errors') const { getOptionValue } = require('#internal/options') +const { MockTracker } = require('#internal/test_runner/mock') const { TapStream } = require('#internal/test_runner/tap_stream') -const { createDeferredCallback, isTestFailureError } = require('#internal/test_runner/utils') +const { + convertStringToRegExp, + createDeferredCallback, + isTestFailureError +} = require('#internal/test_runner/utils') const { createDeferredPromise, - kEmptyObject + kEmptyObject, + once: runOnce } = require('#internal/util') const { isPromise } = require('#internal/util/types') const { @@ -60,11 +70,20 @@ const kDefaultTimeout = null 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 ? MathMax(cpus().length - 1, 1) : 1 +const testNamePatternFlag = isTestRunner + ? null + : getOptionValue('--test-name-pattern') +const testNamePatterns = testNamePatternFlag?.length > 0 + ? ArrayPrototypeMap( + testNamePatternFlag, + (re) => convertStringToRegExp(re, '--test-name-pattern') + ) + : null const kShouldAbort = Symbol('kShouldAbort') -const kRunHook = Symbol('kRunHook') const kHookNames = ObjectSeal(['before', 'after', 'beforeEach', 'afterEach']) +const kUnwrapErrors = new SafeSet() + .add(kTestCodeFailure).add(kHookFailure) + .add('uncaughtException').add('unhandledRejection') function stopTest (timeout, signal) { if (timeout === kDefaultTimeout) { @@ -97,6 +116,11 @@ class TestContext { this.#test.diagnostic(message) } + get mock () { + this.#test.mock = this.#test.mock ?? new MockTracker() + return this.#test.mock + } + runOnly (value) { this.#test.runOnlySubtests = !!value } @@ -116,6 +140,10 @@ class TestContext { return subtest.start() } + after (fn, options) { + this.#test.createHook('after', fn, options) + } + beforeEach (fn, options) { this.#test.createHook('beforeEach', fn, options) } @@ -128,6 +156,7 @@ class TestContext { class Test extends AsyncResource { #abortController #outerSignal + #reportedSubtest constructor (options) { super('Test') @@ -148,9 +177,8 @@ class Test extends AsyncResource { } if (parent === null) { - this.concurrency = rootConcurrency + this.concurrency = 1 this.indent = '' - this.indentString = kDefaultIndent this.only = testOnlyFlag this.reporter = new TapStream() this.runOnlySubtests = this.only @@ -159,11 +187,10 @@ class Test extends AsyncResource { } else { const indent = parent.parent === null ? parent.indent - : parent.indent + parent.indentString + : parent.indent + kDefaultIndent this.concurrency = parent.concurrency this.indent = indent - this.indentString = parent.indentString this.only = only ?? !parent.runOnlySubtests this.reporter = parent.reporter this.runOnlySubtests = !this.only @@ -179,7 +206,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 } @@ -194,6 +221,18 @@ class Test extends AsyncResource { this.timeout = timeout } + if (testNamePatterns !== null) { + // eslint-disable-next-line no-use-before-define + const match = this instanceof TestHook || ArrayPrototypeSome( + testNamePatterns, + (re) => RegExpPrototypeExec(re, name) !== null + ) + + if (!match) { + skip = 'test name does not match pattern' + } + } + if (testOnlyFlag && !this.only) { skip = '\'only\' option not set' } @@ -210,6 +249,7 @@ class Test extends AsyncResource { this.#outerSignal?.addEventListener('abort', this.#abortHandler) this.fn = fn + this.mock = null this.name = name this.parent = parent this.cancelled = false @@ -282,7 +322,7 @@ class Test extends AsyncResource { } if (i === 1 && this.parent !== null) { - this.reporter.subtest(this.indent, this.name) + this.reportSubtest() } // Report the subtest's results and remove it from the ready map. @@ -427,7 +467,7 @@ class Test extends AsyncResource { return { ctx, args: [ctx] } } - async [kRunHook] (hook, args) { + async runHook (hook, args) { validateOneOf(hook, 'hook name', kHookNames) try { await ArrayPrototypeReduce(this.hooks[hook], async (prev, hook) => { @@ -455,10 +495,21 @@ class Test extends AsyncResource { return } + const { args, ctx } = this.getRunArgs() + const after = runOnce(async () => { + if (this.hooks.after.length > 0) { + await this.runHook('after', { args, ctx }) + } + }) + const afterEach = runOnce(async () => { + if (this.parent?.hooks.afterEach.length > 0) { + await this.parent.runHook('afterEach', { args, ctx }) + } + }) + try { - const { args, ctx } = this.getRunArgs() if (this.parent?.hooks.beforeEach.length > 0) { - await this.parent[kRunHook]('beforeEach', { args, ctx }) + await this.parent.runHook('beforeEach', { args, ctx }) } const stopPromise = stopTest(this.timeout, this.signal) const runArgs = ArrayPrototypeSlice(args) @@ -491,12 +542,12 @@ class Test extends AsyncResource { return } - if (this.parent?.hooks.afterEach.length > 0) { - await this.parent[kRunHook]('afterEach', { args, ctx }) - } - + await after() + await afterEach() this.pass() } catch (err) { + try { await after() } catch { /* Ignore error. */ } + try { await afterEach() } catch { /* test is already failing, let's the error */ } if (isTestFailureError(err)) { if (err.failureType === kTestTimeoutFailure) { this.cancel(err) @@ -513,8 +564,8 @@ class Test extends AsyncResource { this.postRun() } - postRun () { - let failedSubtests = 0 + postRun (pendingSubtestsError) { + const counters = { __proto__: null, failed: 0, passed: 0, cancelled: 0, skipped: 0, todo: 0, totalFailed: 0 } // If the test was failed before it even started, then the end time will // be earlier than the start time. Correct that here. @@ -530,29 +581,60 @@ class Test extends AsyncResource { const subtest = this.subtests[i] if (!subtest.finished) { - subtest.cancel() - subtest.postRun() + subtest.cancel(pendingSubtestsError) + subtest.postRun(pendingSubtestsError) + } + + // Check SKIP and TODO tests first, as those should not be counted as + // failures. + if (subtest.skipped) { + counters.skipped++ + } else if (subtest.isTodo) { + counters.todo++ + } else if (subtest.cancelled) { + counters.cancelled++ + } else if (!subtest.passed) { + counters.failed++ + } else { + counters.passed++ } if (!subtest.passed) { - failedSubtests++ + counters.totalFailed++ } } - if (this.passed && failedSubtests > 0) { - const subtestString = `subtest${failedSubtests > 1 ? 's' : ''}` - const msg = `${failedSubtests} ${subtestString} failed` + if ((this.passed || this.parent === null) && counters.totalFailed > 0) { + const subtestString = `subtest${counters.totalFailed > 1 ? 's' : ''}` + const msg = `${counters.totalFailed} ${subtestString} failed` this.fail(new ERR_TEST_FAILURE(msg, kSubtestsFailed)) } this.#outerSignal?.removeEventListener('abort', this.#abortHandler) + this.mock?.reset() if (this.parent !== null) { this.parent.activeSubtests-- this.parent.addReadySubtest(this) this.parent.processReadySubtestRange(false) this.parent.processPendingSubtests() + } else if (!this.reported) { + this.reported = true + this.reporter.plan(this.indent, this.subtests.length) + + for (let i = 0; i < this.diagnostics.length; i++) { + this.reporter.diagnostic(this.indent, this.diagnostics[i]) + } + + this.reporter.diagnostic(this.indent, `tests ${this.subtests.length}`) + this.reporter.diagnostic(this.indent, `pass ${counters.passed}`) + this.reporter.diagnostic(this.indent, `fail ${counters.failed}`) + this.reporter.diagnostic(this.indent, `cancelled ${counters.cancelled}`) + this.reporter.diagnostic(this.indent, `skipped ${counters.skipped}`) + this.reporter.diagnostic(this.indent, `todo ${counters.todo}`) + this.reporter.diagnostic(this.indent, `duration_ms ${this.#duration()}`) + this.reporter.push(null) } } @@ -575,22 +657,24 @@ class Test extends AsyncResource { 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) - } else { - this.reporter.subtest(this.indent, this.name) - } - this.report() this.parent.waitingOn++ this.finished = true } + #duration () { + // Duration is recorded in BigInt nanoseconds. Convert to milliseconds. + return Number(this.endTime - this.startTime) / 1_000_000 + } + 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}` + if (this.subtests.length > 0) { + this.reporter.plan(this.subtests[0].indent, this.subtests.length) + } else { + this.reportSubtest() + } let directive + const details = { __proto__: null, duration_ms: this.#duration() } if (this.skipped) { directive = this.reporter.getSkip(this.message) @@ -599,17 +683,25 @@ class Test extends AsyncResource { } if (this.passed) { - this.reporter.ok(this.indent, this.testNumber, message, directive) + this.reporter.ok(this.indent, this.testNumber, this.name, details, directive) } else { - this.reporter.fail(this.indent, this.testNumber, message, directive) + details.error = this.error + this.reporter.fail(this.indent, this.testNumber, this.name, details, 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]) } } + + reportSubtest () { + if (this.#reportedSubtest || this.parent === null) { + return + } + this.#reportedSubtest = true + this.parent.reportSubtest() + this.reporter.subtest(this.indent, this.name) + } } class TestHook extends Test { @@ -630,6 +722,9 @@ class TestHook extends Test { getRunArgs () { return this.#args } + + postRun () { + } } class ItTest extends Test { @@ -638,6 +733,7 @@ class ItTest extends Test { return { ctx: { signal: this.signal, name: this.name }, args: [] } } } + class Suite extends Test { constructor (options) { super(options) @@ -662,6 +758,13 @@ class Suite extends Test { } async run () { + const hookArgs = this.getRunArgs() + const afterEach = runOnce(async () => { + if (this.parent?.hooks.afterEach.length > 0) { + await this.parent.runHook('afterEach', hookArgs) + } + }) + try { this.parent.activeSubtests++ await this.buildSuite @@ -673,16 +776,23 @@ class Suite extends Test { return } - const hookArgs = this.getRunArgs() - await this[kRunHook]('before', hookArgs) + if (this.parent?.hooks.beforeEach.length > 0) { + await this.parent.runHook('beforeEach', hookArgs) + } + + await this.runHook('before', hookArgs) + const stopPromise = stopTest(this.timeout, this.signal) const subtests = this.skipped || this.error ? [] : this.subtests const promise = SafePromiseAll(subtests, (subtests) => subtests.start()) await SafePromiseRace([promise, stopPromise]) - await this[kRunHook]('after', hookArgs) + await this.runHook('after', hookArgs) + await afterEach() + this.pass() } catch (err) { + try { await afterEach() } catch { /* test is already failing, let's the error */ } if (isTestFailureError(err)) { this.fail(err) } else { @@ -698,9 +808,9 @@ module.exports = { ItTest, kCancelledByParent, kDefaultIndent, - kHookFailure, kSubtestsFailed, kTestCodeFailure, + kUnwrapErrors, Suite, Test } diff --git a/lib/internal/test_runner/utils.js b/lib/internal/test_runner/utils.js index 595431e..c2919d8 100644 --- a/lib/internal/test_runner/utils.js +++ b/lib/internal/test_runner/utils.js @@ -1,16 +1,18 @@ -// https://github.com/nodejs/node/blob/659dc126932f986fc33c7f1c878cb2b57a1e2fac/lib/internal/test_runner/utils.js +// https://github.com/nodejs/node/blob/87170c3f9271da947a7b33d0696ec4cf8aab6eb6/lib/internal/test_runner/utils.js 'use strict' const { RegExpPrototypeExec } = require('#internal/per_context/primordials') const { basename } = require('path') const { createDeferredPromise } = require('#internal/util') const { codes: { + ERR_INVALID_ARG_VALUE, ERR_TEST_FAILURE }, kIsNodeError } = require('#internal/errors') const kMultipleCallbackInvocations = 'multipleCallbackInvocations' +const kRegExpPattern = /^\/(.*)\/([a-z]*)$/ const kSupportedFileExtensions = /\.[cm]?js$/ const kTestFilePattern = /((^test(-.+)?)|(.+[.\-_]test))\.[cm]?js$/ @@ -55,7 +57,26 @@ function isTestFailureError (err) { return err?.code === 'ERR_TEST_FAILURE' && kIsNodeError in err } +function convertStringToRegExp (str, name) { + const match = RegExpPrototypeExec(kRegExpPattern, str) + const pattern = match?.[1] ?? str + const flags = match?.[2] || '' + + try { + return new RegExp(pattern, flags) + } catch (err) { + const msg = err?.message + + throw new ERR_INVALID_ARG_VALUE( + name, + str, + `is an invalid regular expression.${msg ? ` ${msg}` : ''}` + ) + } +} + module.exports = { + convertStringToRegExp, createDeferredCallback, doesPathMatchFilter, isSupportedFileType, diff --git a/lib/internal/test_runner/yaml_parser.js b/lib/internal/test_runner/yaml_parser.js new file mode 100644 index 0000000..2e85db8 --- /dev/null +++ b/lib/internal/test_runner/yaml_parser.js @@ -0,0 +1,121 @@ +// https://github.com/nodejs/node/blob/232efb06fe8787e9573e298ce7ac293ad23b7684/lib/internal/test_runner/yaml_parser.js +'use strict' +const { + codes: { + ERR_TEST_FAILURE + } +} = require('#internal/errors') +const AssertionError = require('assert').AssertionError +const { + ArrayPrototypeJoin, + ArrayPrototypePush, + Error, + Number, + NumberIsNaN, + RegExpPrototypeExec, + StringPrototypeEndsWith, + StringPrototypeRepeat, + StringPrototypeSlice, + StringPrototypeStartsWith, + StringPrototypeSubstring +} = require('#internal/per_context/primordials') + +const kYamlKeyRegex = /^(\s+)?(\w+):(\s)+([>|][-+])?(.*)$/ +const kStackDelimiter = ' at ' + +function reConstructError (parsedYaml) { + if (!('error' in parsedYaml)) { + return parsedYaml + } + const isAssertionError = parsedYaml.code === 'ERR_ASSERTION' || + 'actual' in parsedYaml || 'expected' in parsedYaml || 'operator' in parsedYaml + const isTestFailure = parsedYaml.code === 'ERR_TEST_FAILURE' || 'failureType' in parsedYaml + const stack = parsedYaml.stack ? kStackDelimiter + ArrayPrototypeJoin(parsedYaml.stack, `\n${kStackDelimiter}`) : '' + let error, cause + + if (isAssertionError) { + cause = new AssertionError({ + message: parsedYaml.error, + actual: parsedYaml.actual, + expected: parsedYaml.expected, + operator: parsedYaml.operator + }) + } else { + // eslint-disable-next-line no-restricted-syntax + cause = new Error(parsedYaml.error) + cause.code = parsedYaml.code + } + cause.stack = stack + + if (isTestFailure) { + error = new ERR_TEST_FAILURE(cause, parsedYaml.failureType) + error.stack = stack + } + + parsedYaml.error = error ?? cause + delete parsedYaml.stack + delete parsedYaml.code + delete parsedYaml.failureType + delete parsedYaml.actual + delete parsedYaml.expected + delete parsedYaml.operator + + return parsedYaml +} + +function getYamlValue (value) { + if (StringPrototypeStartsWith(value, "'") && StringPrototypeEndsWith(value, "'")) { + return StringPrototypeSlice(value, 1, -1) + } + if (value === 'true') { + return true + } + if (value === 'false') { + return false + } + if (value !== '') { + const valueAsNumber = Number(value) + return NumberIsNaN(valueAsNumber) ? value : valueAsNumber + } + return value +} + +// This parses the YAML generated by the built-in TAP reporter, +// which is a subset of the full YAML spec. There are some +// YAML features that won't be parsed here. This function should not be exposed publicly. +function YAMLToJs (lines) { + if (lines == null) { + return undefined + } + const result = { __proto__: null } + let isInYamlBlock = false + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + if (isInYamlBlock && !StringPrototypeStartsWith(line, StringPrototypeRepeat(' ', isInYamlBlock.indent))) { + result[isInYamlBlock.key] = isInYamlBlock.key === 'stack' + ? result[isInYamlBlock.key] + : ArrayPrototypeJoin(result[isInYamlBlock.key], '\n') + isInYamlBlock = false + } + if (isInYamlBlock) { + const blockLine = StringPrototypeSubstring(line, isInYamlBlock.indent) + ArrayPrototypePush(result[isInYamlBlock.key], blockLine) + continue + } + const match = RegExpPrototypeExec(kYamlKeyRegex, line) + if (match !== null) { + const { 1: leadingSpaces, 2: key, 4: block, 5: value } = match + if (block) { + isInYamlBlock = { key, indent: (leadingSpaces?.length ?? 0) + 2 } + result[key] = [] + } else { + result[key] = getYamlValue(value) + } + } + } + return reConstructError(result) +} + +module.exports = { + YAMLToJs +} diff --git a/lib/internal/util.js b/lib/internal/util.js index 413a655..9cbb706 100644 --- a/lib/internal/util.js +++ b/lib/internal/util.js @@ -1,9 +1,10 @@ -// https://github.com/nodejs/node/blob/a9b1fd3987fae5ad5340859a6088b86179b576c5/lib/internal/util.js +// https://github.com/nodejs/node/blob/3759935ee29d8042d917d3ceaa768521c14413ff/lib/internal/util.js 'use strict' const { ObjectCreate, - ObjectFreeze + ObjectFreeze, + ReflectApply } = require('#internal/per_context/primordials') const { types: { isNativeError } @@ -27,10 +28,20 @@ function isError (e) { return isNativeError(e) || e instanceof Error } +function once (callback) { + let called = false + return function (...args) { + if (called) return + called = true + return ReflectApply(callback, this, args) + } +} + const kEmptyObject = ObjectFreeze(ObjectCreate(null)) module.exports = { createDeferredPromise, isError, - kEmptyObject + kEmptyObject, + once } diff --git a/lib/internal/util/inspector.js b/lib/internal/util/inspector.js new file mode 100644 index 0000000..b80129f --- /dev/null +++ b/lib/internal/util/inspector.js @@ -0,0 +1,48 @@ +// https://github.com/nodejs/node/blob/a165193c5c8e4bcfbd12b2c3f6e55a81a251c258/lib/internal/util/inspector.js +const { + ArrayPrototypeSome, + RegExpPrototypeExec +} = require('#internal/per_context/primordials') + +const { validatePort } = require('#internal/validators') + +const kMinPort = 1024 +const kMaxPort = 65535 +const kInspectArgRegex = /--inspect(?:-brk|-port)?|--debug-port/ +const kInspectMsgRegex = /Debugger listening on ws:\/\/\[?(.+?)\]?:(\d+)\/|Debugger attached|Waiting for the debugger to disconnect\.\.\./ + +let _isUsingInspector +function isUsingInspector () { + // Node.js 14.x does not support Logical_nullish_assignment operator + _isUsingInspector = _isUsingInspector ?? + (ArrayPrototypeSome(process.execArgv, (arg) => RegExpPrototypeExec(kInspectArgRegex, arg) !== null) || + RegExpPrototypeExec(kInspectArgRegex, process.env.NODE_OPTIONS) !== null) + return _isUsingInspector +} + +let debugPortOffset = 1 +function getInspectPort (inspectPort) { + if (!isUsingInspector()) { + return null + } + if (typeof inspectPort === 'function') { + inspectPort = inspectPort() + } else if (inspectPort == null) { + inspectPort = process.debugPort + debugPortOffset + if (inspectPort > kMaxPort) { inspectPort = inspectPort - kMaxPort + kMinPort - 1 } + debugPortOffset++ + } + validatePort(inspectPort) + + return inspectPort +} + +function isInspectorMessage (string) { + return isUsingInspector() && RegExpPrototypeExec(kInspectMsgRegex, string) !== null +} + +module.exports = { + isUsingInspector, + getInspectPort, + isInspectorMessage +} diff --git a/lib/internal/validators.js b/lib/internal/validators.js index 511e61b..8149840 100644 --- a/lib/internal/validators.js +++ b/lib/internal/validators.js @@ -1,8 +1,13 @@ // https://github.com/nodejs/node/blob/60da0a1b364efdd84870269d23b39faa12fb46d8/lib/internal/validators.js const { + ArrayIsArray, ArrayPrototypeIncludes, ArrayPrototypeJoin, - ArrayPrototypeMap + ArrayPrototypeMap, + NumberIsInteger, + NumberMAX_SAFE_INTEGER, // eslint-disable-line camelcase + NumberMIN_SAFE_INTEGER, // eslint-disable-line camelcase + ObjectPrototypeHasOwnProperty } = require('#internal/per_context/primordials') const { ERR_INVALID_ARG_TYPE, @@ -64,10 +69,59 @@ const validateOneOf = (value, name, oneOf) => { } } +const validateArray = (value, name, minLength = 0) => { + if (!ArrayIsArray(value)) { + throw new ERR_INVALID_ARG_TYPE(name, 'Array', value) + } + if (value.length < minLength) { + const reason = `must be longer than ${minLength}` + throw new ERR_INVALID_ARG_VALUE(name, value, reason) + } +} + +function getOwnPropertyValueOrDefault (options, key, defaultValue) { + return options == null || !ObjectPrototypeHasOwnProperty(options, key) + ? defaultValue + : options[key] +} + +const validateObject = (value, name, options = null) => { + const allowArray = getOwnPropertyValueOrDefault(options, 'allowArray', false) + const allowFunction = getOwnPropertyValueOrDefault(options, 'allowFunction', false) + const nullable = getOwnPropertyValueOrDefault(options, 'nullable', false) + if ((!nullable && value === null) || + (!allowArray && ArrayIsArray(value)) || + (typeof value !== 'object' && ( + !allowFunction || typeof value !== 'function' + ))) { + throw new ERR_INVALID_ARG_TYPE(name, 'Object', value) + } +} + +const validateFunction = (value, name) => { + if (typeof value !== 'function') { throw new ERR_INVALID_ARG_TYPE(name, 'Function', value) } +} + +function validateBoolean (value, name) { + if (typeof value !== 'boolean') { throw new ERR_INVALID_ARG_TYPE(name, 'boolean', value) } +} + +const validateInteger = + (value, name, min = NumberMIN_SAFE_INTEGER, max = NumberMAX_SAFE_INTEGER) => { + if (typeof value !== 'number') { throw new ERR_INVALID_ARG_TYPE(name, 'number', value) } + if (!NumberIsInteger(value)) { throw new ERR_OUT_OF_RANGE(name, 'an integer', value) } + if (value < min || value > max) { throw new ERR_OUT_OF_RANGE(name, `>= ${min} && <= ${max}`, value) } + } + module.exports = { isUint32, validateAbortSignal, + validateArray, + validateBoolean, + validateFunction, + validateInteger, validateNumber, + validateObject, validateOneOf, validateUint32 } diff --git a/lib/test.js b/lib/test.js index a65fb3c..7e92edb 100644 --- a/lib/test.js +++ b/lib/test.js @@ -1,12 +1,34 @@ -// https://github.com/nodejs/node/blob/659dc126932f986fc33c7f1c878cb2b57a1e2fac/lib/test.js +// https://github.com/nodejs/node/blob/7c6682957b3c5f86d0616cebc0ad09cc2a1fd50d/lib/test.js 'use strict' +const { ObjectAssign, ObjectDefineProperty } = require('#internal/per_context/primordials') const { test, describe, it, before, after, beforeEach, afterEach } = require('#internal/test_runner/harness') +const { run } = require('#internal/test_runner/runner') module.exports = test -module.exports.test = test -module.exports.describe = describe -module.exports.it = it -module.exports.before = before -module.exports.after = after -module.exports.beforeEach = beforeEach -module.exports.afterEach = afterEach +ObjectAssign(module.exports, { + after, + afterEach, + before, + beforeEach, + describe, + it, + run, + test +}) + +let lazyMock + +ObjectDefineProperty(module.exports, 'mock', { + __proto__: null, + configurable: true, + enumerable: true, + get () { + if (lazyMock === undefined) { + const { MockTracker } = require('#internal/test_runner/mock') + + lazyMock = new MockTracker() + } + + return lazyMock + } +}) diff --git a/package-lock.json b/package-lock.json index 3c3344b..3642736 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ }, "bin": { "node--test": "bin/node--test.js", + "node--test-name-pattern": "bin/node--test-name-pattern.js", "node--test-only": "bin/node--test-only.js", "test": "bin/node-core-test.js" }, diff --git a/package.json b/package.json index 8d787e1..b1e00a7 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "bin": { "node--test": "./bin/node--test.js", "node--test-only": "./bin/node--test-only.js", + "node--test-name-pattern": "./bin/node--test-name-pattern.js", "test": "./bin/node-core-test.js" }, "imports": { diff --git a/test/common/index.js b/test/common/index.js index 91fcd41..a6269ac 100644 --- a/test/common/index.js +++ b/test/common/index.js @@ -37,6 +37,30 @@ function mustCall (fn, exact) { return _mustCallInner(fn, exact, 'exact') } +function getCallSite (top) { + const originalStackFormatter = Error.prepareStackTrace + Error.prepareStackTrace = (err, stack) => // eslint-disable-line n/handle-callback-err + `${stack[0].getFileName()}:${stack[0].getLineNumber()}` + const err = new Error() + Error.captureStackTrace(err, top) + // With the V8 Error API, the stack is not formatted until it is accessed + err.stack // eslint-disable-line no-unused-expressions + Error.prepareStackTrace = originalStackFormatter + return err.stack +} + +function mustNotCall (msg) { + const callSite = getCallSite(mustNotCall) + return function mustNotCall (...args) { + const argsInfo = args.length > 0 + ? `\ncalled with arguments: ${args.map((arg) => util.inspect(arg)).join(', ')}` + : '' + assert.fail( + `${msg || 'function should not have been called'} at ${callSite}` + + argsInfo) + } +} + function _mustCallInner (fn, criteria = 1, field) { if (process._exiting) { throw new Error('Cannot use common.mustCall*() in process exit handler') } if (typeof fn === 'number') { @@ -145,5 +169,6 @@ if (typeof AbortSignal !== 'undefined' && (process.version.startsWith('v14.') || module.exports = { expectsError, isWindow: process.platform === 'win32', - mustCall + mustCall, + mustNotCall } diff --git a/test/fixtures/test-runner/extraneous_set_immediate_async.mjs b/test/fixtures/test-runner/extraneous_set_immediate_async.mjs new file mode 100644 index 0000000..241e262 --- /dev/null +++ b/test/fixtures/test-runner/extraneous_set_immediate_async.mjs @@ -0,0 +1,6 @@ +// https://github.com/nodejs/node/blob/06603c44a5b0e92b1a3591ace467ce9770bf9658/test/fixtures/test-runner/extraneous_set_immediate_async.mjs +import test from '#node:test' + +test('extraneous async activity test', () => { + setImmediate(() => { throw new Error() }) +}) diff --git a/test/fixtures/test-runner/extraneous_set_timeout_async.mjs b/test/fixtures/test-runner/extraneous_set_timeout_async.mjs new file mode 100644 index 0000000..2b5ee72 --- /dev/null +++ b/test/fixtures/test-runner/extraneous_set_timeout_async.mjs @@ -0,0 +1,6 @@ +// https://github.com/nodejs/node/blob/06603c44a5b0e92b1a3591ace467ce9770bf9658/test/fixtures/test-runner/extraneous_set_timeout_async.mjs +import test from '#node:test' + +test('extraneous async activity test', () => { + setTimeout(() => { throw new Error() }, 100) +}) diff --git a/test/fixtures/test-runner/invalid-tap.js b/test/fixtures/test-runner/invalid-tap.js new file mode 100644 index 0000000..1073bd8 --- /dev/null +++ b/test/fixtures/test-runner/invalid-tap.js @@ -0,0 +1,3 @@ +// https://github.com/nodejs/node/blob/f8ce9117b19702487eb600493d941f7876e00e01/test/fixtures/test-runner/invalid-tap.js + +console.log('invalid tap output') diff --git a/test/fixtures/test-runner/nested.js b/test/fixtures/test-runner/nested.js new file mode 100644 index 0000000..3d01534 --- /dev/null +++ b/test/fixtures/test-runner/nested.js @@ -0,0 +1,23 @@ +// https://github.com/nodejs/node/blob/f8ce9117b19702487eb600493d941f7876e00e01/test/fixtures/test-runner/nested.js +'use strict' +const test = require('#node:test') + +test('level 0a', { concurrency: 4 }, async (t) => { + t.test('level 1a', async (t) => { + }) + + t.test('level 1b', async (t) => { + throw new Error('level 1b error') + }) + + t.test('level 1c', { skip: 'aaa' }, async (t) => { + }) + + t.test('level 1d', async (t) => { + t.diagnostic('level 1d diagnostic') + }) +}) + +test('level 0b', async (t) => { + throw new Error('level 0b error') +}) diff --git a/test/message.js b/test/message.js index 4520448..b2b6234 100755 --- a/test/message.js +++ b/test/message.js @@ -13,7 +13,7 @@ const binPath = resolve(__dirname, '..', bin.test) const MESSAGE_FOLDER = join(__dirname, './message/') const WAIT_FOR_ELLIPSIS = Symbol('wait for ellispis') -const TEST_RUNNER_FLAGS = ['--test', '--test-only'] +const TEST_RUNNER_FLAGS = ['--test', '--test-only', '--test-name-pattern'] function readLines (file) { return createInterface({ @@ -27,6 +27,7 @@ const stackTraceLine = /^\s+\*$/ const stackTraceEndLine = /^\s+\.\.\.$/ const nodejs14NotEmittedWarn = /^# Warning:.*\breject/ +const nodejs14NotEmittedUnhandledRejection = /'unhandledRejection'/ // https://github.com/nodejs/node/blob/1aab13cad9c800f4121c1d35b554b78c1b17bdbd/test/message/testcfg.py#L53 async function IsFailureOutput (self, output) { @@ -38,6 +39,10 @@ async function IsFailureOutput (self, output) { // Node.js 14 doesn't emit some warnings if (process.version.startsWith('v14.') && nodejs14NotEmittedWarn.test(line)) continue + if (process.version.startsWith('v14.') && nodejs14NotEmittedUnhandledRejection.test(line)) { + patterns.push(WAIT_FOR_ELLIPSIS) + continue + } // Sometimes Node.js won't have any stack trace, but we would if (stackTraceEndLine.test(line) && patterns[patterns.length - 1].toString().endsWith("code: 'ERR_TEST_FAILURE'$")) { @@ -104,8 +109,8 @@ const main = async () => { ) .toString().split(' ') - const nodeFlags = flags.filter(flag => !TEST_RUNNER_FLAGS.includes(flag)).join(' ') - const testRunnerFlags = flags.filter(flag => TEST_RUNNER_FLAGS.includes(flag)).join(' ') + const nodeFlags = flags.filter(flag => !TEST_RUNNER_FLAGS.find(f => flag.startsWith(f))).join(' ') + const testRunnerFlags = flags.filter(flag => TEST_RUNNER_FLAGS.find(f => flag.startsWith(f))).join(' ') const command = testRunnerFlags.length ? `${process.execPath} ${nodeFlags} ${binPath} ${testRunnerFlags} ${filePath}` diff --git a/test/message/test_runner_describe_nested.js b/test/message/test_runner_describe_nested.js new file mode 100644 index 0000000..e60ebf6 --- /dev/null +++ b/test/message/test_runner_describe_nested.js @@ -0,0 +1,11 @@ +// https://github.com/nodejs/node/blob/3e57891ee2fde0971e18fc383c25acf8f90def05/test/message/test_runner_describe_nested.js +// Flags: --no-warnings +'use strict' +require('../common') +const { describe, it } = require('#node:test') + +describe('nested - no tests', () => { + describe('nested', () => { + it('nested', () => {}) + }) +}) diff --git a/test/message/test_runner_describe_nested.out b/test/message/test_runner_describe_nested.out new file mode 100644 index 0000000..1d3fe31 --- /dev/null +++ b/test/message/test_runner_describe_nested.out @@ -0,0 +1,26 @@ +TAP version 13 +# Subtest: nested - no tests + # Subtest: nested + # Subtest: nested + ok 1 - nested + --- + duration_ms: * + ... + 1..1 + ok 1 - nested + --- + duration_ms: * + ... + 1..1 +ok 1 - nested - no tests + --- + duration_ms: * + ... +1..1 +# tests 1 +# pass 1 +# fail 0 +# cancelled 0 +# skipped 0 +# todo 0 +# duration_ms * diff --git a/test/message/test_runner_desctibe_it.out b/test/message/test_runner_desctibe_it.out index 2d58215..ec9337e 100644 --- a/test/message/test_runner_desctibe_it.out +++ b/test/message/test_runner_desctibe_it.out @@ -42,8 +42,6 @@ not ok 4 - sync fail todo with message # TODO this is a failing todo * * * - * - * ... # Subtest: sync skip pass ok 5 - sync skip pass # SKIP @@ -123,6 +121,9 @@ not ok 13 - async assertion fail true !== false code: 'ERR_ASSERTION' + expected: false + actual: true + operator: 'strictEqual' stack: |- * * @@ -421,6 +422,7 @@ not ok 49 - callback async throw code: 'ERR_TEST_FAILURE' stack: |- * + * ... # Subtest: callback async throw after done ok 50 - callback async throw after done diff --git a/test/message/test_runner_hooks.js b/test/message/test_runner_hooks.js index 836ef0e..afd6e32 100644 --- a/test/message/test_runner_hooks.js +++ b/test/message/test_runner_hooks.js @@ -1,7 +1,7 @@ -// https://github.com/nodejs/node/blob/659dc126932f986fc33c7f1c878cb2b57a1e2fac/test/message/test_runner_hooks.js +// https://github.com/nodejs/node/blob/385d595a4f1d887f6d4221e6071571132498d57c/test/message/test_runner_hooks.js // Flags: --no-warnings 'use strict' -require('../common') +const common = require('../common') const assert = require('assert') const { test, describe, it, before, after, beforeEach, afterEach } = require('#node:test') @@ -16,10 +16,12 @@ describe('describe hooks', () => { 'before describe hooks', 'beforeEach 1', '1', 'afterEach 1', 'beforeEach 2', '2', 'afterEach 2', + 'beforeEach nested', 'before nested', 'beforeEach nested 1', 'nested 1', 'afterEach nested 1', 'beforeEach nested 2', 'nested 2', 'afterEach nested 2', 'after nested', + 'afterEach nested', 'after describe hooks' ]) }) @@ -75,8 +77,22 @@ describe('afterEach throws', () => { it('2', () => {}) }) +describe('afterEach when test fails', () => { + afterEach(common.mustCall(2)) + it('1', () => { throw new Error('test') }) + it('2', () => {}) +}) + +describe('afterEach throws and test fails', () => { + afterEach(() => { throw new Error('afterEach') }) + it('1', () => { throw new Error('test') }) + it('2', () => {}) +}) + test('test hooks', async (t) => { const testArr = [] + + t.after(common.mustCall((t) => testArr.push('after ' + t.name))) t.beforeEach((t) => testArr.push('beforeEach ' + t.name)) t.afterEach((t) => testArr.push('afterEach ' + t.name)) await t.test('1', () => testArr.push('1')) @@ -100,13 +116,36 @@ test('test hooks', async (t) => { }) test('t.beforeEach throws', async (t) => { + t.after(common.mustCall()) t.beforeEach(() => { throw new Error('beforeEach') }) await t.test('1', () => {}) await t.test('2', () => {}) }) test('t.afterEach throws', async (t) => { + t.after(common.mustCall()) t.afterEach(() => { throw new Error('afterEach') }) await t.test('1', () => {}) await t.test('2', () => {}) }) + +test('afterEach when test fails', async (t) => { + t.after(common.mustCall()) + t.afterEach(common.mustCall(2)) + await t.test('1', () => { throw new Error('test') }) + await t.test('2', () => {}) +}) + +test('afterEach throws and test fails', async (t) => { + t.after(common.mustCall()) + t.afterEach(() => { throw new Error('afterEach') }) + await t.test('1', () => { throw new Error('test') }) + await t.test('2', () => {}) +}) + +test('t.after() is called if test body throws', (t) => { + t.after(() => { + t.diagnostic('- after() called') + }) + throw new Error('bye') +}) diff --git a/test/message/test_runner_hooks.out b/test/message/test_runner_hooks.out index 57008a4..6bb1705 100644 --- a/test/message/test_runner_hooks.out +++ b/test/message/test_runner_hooks.out @@ -189,6 +189,86 @@ not ok 5 - afterEach throws error: '2 subtests failed' code: 'ERR_TEST_FAILURE' ... +# Subtest: afterEach when test fails + # Subtest: 1 + not ok 1 - 1 + --- + duration_ms: * + failureType: 'testCodeFailure' + error: 'test' + code: 'ERR_TEST_FAILURE' + stack: |- + * + * + * + * + * + * + * + * + * + * + ... + # Subtest: 2 + ok 2 - 2 + --- + duration_ms: * + ... + 1..2 +not ok 6 - afterEach when test fails + --- + duration_ms: * + failureType: 'subtestsFailed' + error: '1 subtest failed' + code: 'ERR_TEST_FAILURE' + ... +# Subtest: afterEach throws and test fails + # Subtest: 1 + not ok 1 - 1 + --- + duration_ms: * + failureType: 'testCodeFailure' + error: 'test' + code: 'ERR_TEST_FAILURE' + stack: |- + * + * + * + * + * + * + * + * + * + * + ... + # Subtest: 2 + not ok 2 - 2 + --- + duration_ms: * + failureType: 'hookFailed' + error: 'failed running afterEach hook' + code: 'ERR_TEST_FAILURE' + stack: |- + * + * + * + * + * + * + * + * + * + * + ... + 1..2 +not ok 7 - afterEach throws and test fails + --- + duration_ms: * + failureType: 'subtestsFailed' + error: '2 subtests failed' + code: 'ERR_TEST_FAILURE' + ... # Subtest: test hooks # Subtest: 1 ok 1 - 1 @@ -217,7 +297,7 @@ not ok 5 - afterEach throws duration_ms: * ... 1..3 -ok 6 - test hooks +ok 8 - test hooks --- duration_ms: * ... @@ -261,7 +341,7 @@ ok 6 - test hooks * ... 1..2 -not ok 7 - t.beforeEach throws +not ok 9 - t.beforeEach throws --- duration_ms: * failureType: 'subtestsFailed' @@ -308,17 +388,115 @@ not ok 7 - t.beforeEach throws * ... 1..2 -not ok 8 - t.afterEach throws +not ok 10 - t.afterEach throws --- duration_ms: * failureType: 'subtestsFailed' error: '2 subtests failed' code: 'ERR_TEST_FAILURE' ... -1..8 -# tests 8 +# Subtest: afterEach when test fails + # Subtest: 1 + not ok 1 - 1 + --- + duration_ms: * + failureType: 'testCodeFailure' + error: 'test' + code: 'ERR_TEST_FAILURE' + stack: |- + * + * + * + * + * + * + * + * + * + * + ... + # Subtest: 2 + ok 2 - 2 + --- + duration_ms: * + ... + 1..2 +not ok 11 - afterEach when test fails + --- + duration_ms: * + failureType: 'subtestsFailed' + error: '1 subtest failed' + code: 'ERR_TEST_FAILURE' + ... +# Subtest: afterEach throws and test fails + # Subtest: 1 + not ok 1 - 1 + --- + duration_ms: * + failureType: 'testCodeFailure' + error: 'test' + code: 'ERR_TEST_FAILURE' + stack: |- + * + * + * + * + * + * + * + * + * + * + ... + # Subtest: 2 + not ok 2 - 2 + --- + duration_ms: * + failureType: 'hookFailed' + error: 'failed running afterEach hook' + code: 'ERR_TEST_FAILURE' + stack: |- + * + * + * + * + * + * + * + * + * + * + ... + 1..2 +not ok 12 - afterEach throws and test fails + --- + duration_ms: * + failureType: 'subtestsFailed' + error: '2 subtests failed' + code: 'ERR_TEST_FAILURE' + ... +# Subtest: t.after() is called if test body throws +not ok 13 - t.after() is called if test body throws + --- + duration_ms: * + failureType: 'testCodeFailure' + error: 'bye' + code: 'ERR_TEST_FAILURE' + stack: |- + * + * + * + * + * + * + * + * + ... +# - after() called +1..13 +# tests 13 # pass 2 -# fail 6 +# fail 11 # cancelled 0 # skipped 0 # todo 0 diff --git a/test/message/test_runner_no_refs.out b/test/message/test_runner_no_refs.out index 63b79cd..e8560c5 100644 --- a/test/message/test_runner_no_refs.out +++ b/test/message/test_runner_no_refs.out @@ -5,7 +5,7 @@ TAP version 13 --- duration_ms: * failureType: 'cancelledByParent' - error: 'test did not finish before its parent and was cancelled' + error: 'Promise resolution is still pending but the event loop has already resolved' code: 'ERR_TEST_FAILURE' stack: |- * @@ -15,7 +15,7 @@ not ok 1 - does not keep event loop alive --- duration_ms: * failureType: 'cancelledByParent' - error: 'test did not finish before its parent and was cancelled' + error: 'Promise resolution is still pending but the event loop has already resolved' code: 'ERR_TEST_FAILURE' stack: |- * diff --git a/test/message/test_runner_no_tests.out b/test/message/test_runner_no_tests.out index 9f84e58..9daeafb 100644 --- a/test/message/test_runner_no_tests.out +++ b/test/message/test_runner_no_tests.out @@ -1 +1 @@ -bound test +test diff --git a/test/message/test_runner_output.js b/test/message/test_runner_output.js index e4edf0a..a85c16e 100644 --- a/test/message/test_runner_output.js +++ b/test/message/test_runner_output.js @@ -1,4 +1,4 @@ -// https://github.com/nodejs/node/blob/a3e110820ff98702e1761831e7beaf0f5f1f75e7/test/message/test_runner_output.js +// https://github.com/nodejs/node/blob/22dc987fde29734c5bcbb7c33da20d184ff61627/test/message/test_runner_output.js // Flags: --no-warnings 'use strict' require('../common') @@ -214,7 +214,7 @@ test('test with a name and options provided', { skip: true }) test({ skip: true }, function functionAndOptions () {}) // A test whose description needs to be escaped. -test('escaped description \\ # \\#\\') +test('escaped description \\ # \\#\\ \n \t \f \v \b \r') // A test whose skip message needs to be escaped. test('escaped skip message', { skip: '#skip' }) @@ -371,3 +371,15 @@ test('rejected thenable', () => { } } }) + +test('unfinished test with uncaughtException', async () => { + await new Promise(() => { + setTimeout(() => { throw new Error('foo') }) + }) +}) + +test('unfinished test with unhandledRejection', async () => { + await new Promise(() => { + setTimeout(() => Promise.reject(new Error('bar'))) + }) +}) diff --git a/test/message/test_runner_output.out b/test/message/test_runner_output.out index 49a19fe..14479c7 100644 --- a/test/message/test_runner_output.out +++ b/test/message/test_runner_output.out @@ -42,8 +42,6 @@ not ok 4 - sync fail todo with message # TODO this is a failing todo * * * - * - * ... # Subtest: sync skip pass ok 5 - sync skip pass # SKIP @@ -129,10 +127,13 @@ not ok 13 - async assertion fail failureType: 'testCodeFailure' error: |- Expected values to be strictly equal: - + true !== false - + code: 'ERR_ASSERTION' + expected: false + actual: true + operator: 'strictEqual' stack: |- * * @@ -352,8 +353,8 @@ ok 36 - functionAndOptions # SKIP --- duration_ms: * ... -# Subtest: escaped description \\ \# \\\#\\ -ok 37 - escaped description \\ \# \\\#\\ +# Subtest: escaped description \\ \# \\\#\\ \n \t \f \v \b \r +ok 37 - escaped description \\ \# \\\#\\ \n \t \f \v \b \r --- duration_ms: * ... @@ -463,6 +464,7 @@ not ok 51 - callback async throw code: 'ERR_TEST_FAILURE' stack: |- * + * ... # Subtest: callback async throw after done ok 52 - callback async throw after done @@ -601,8 +603,32 @@ not ok 62 - rejected thenable error: 'custom error' code: 'ERR_TEST_FAILURE' ... +# Subtest: unfinished test with uncaughtException +not ok 63 - unfinished test with uncaughtException + --- + duration_ms: * + failureType: 'uncaughtException' + error: 'foo' + code: 'ERR_TEST_FAILURE' + stack: |- + * + * + * + ... +# Subtest: unfinished test with unhandledRejection +not ok 64 - unfinished test with unhandledRejection + --- + duration_ms: * + failureType: 'unhandledRejection' + error: 'bar' + code: 'ERR_TEST_FAILURE' + stack: |- + * + * + * + ... # Subtest: invalid subtest fail -not ok 63 - invalid subtest fail +not ok 65 - invalid subtest fail --- duration_ms: * failureType: 'parentAlreadyFinished' @@ -611,16 +637,16 @@ not ok 63 - invalid subtest fail stack: |- * ... -1..63 +1..65 # 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 63 +# tests 65 # pass 27 -# fail 19 +# fail 21 # cancelled 2 # skipped 10 # todo 5 diff --git a/test/message/test_runner_test_name_pattern.js b/test/message/test_runner_test_name_pattern.js new file mode 100644 index 0000000..5f7c25b --- /dev/null +++ b/test/message/test_runner_test_name_pattern.js @@ -0,0 +1,48 @@ +// https://github.com/nodejs/node/blob/a69a30016cf3395b0bd775c1340ab6ecbac58296/test/message/test_runner_test_name_pattern.js +// Flags: --no-warnings --test-name-pattern=enabled --test-name-pattern=/pattern/i +'use strict' +const common = require('../common') +const { + after, + afterEach, + before, + beforeEach, + describe, + it, + test +} = require('#node:test') + +test('top level test disabled', common.mustNotCall()) +test('top level skipped test disabled', { skip: true }, common.mustNotCall()) +test('top level skipped test enabled', { skip: true }, common.mustNotCall()) +it('top level it enabled', common.mustCall()) +it('top level it disabled', common.mustNotCall()) +it.skip('top level skipped it disabled', common.mustNotCall()) +it.skip('top level skipped it enabled', common.mustNotCall()) +describe('top level describe disabled', common.mustNotCall()) +describe.skip('top level skipped describe disabled', common.mustNotCall()) +describe.skip('top level skipped describe enabled', common.mustNotCall()) +test('top level runs because name includes PaTtErN', common.mustCall()) + +test('top level test enabled', common.mustCall(async (t) => { + t.beforeEach(common.mustCall()) + t.afterEach(common.mustCall()) + await t.test( + 'nested test runs because name includes PATTERN', + common.mustCall() + ) +})) + +describe('top level describe enabled', () => { + before(common.mustCall()) + beforeEach(common.mustCall(4)) + afterEach(common.mustCall(4)) + after(common.mustCall()) + + it('nested it disabled', common.mustNotCall()) + it('nested it enabled', common.mustCall()) + describe('nested describe disabled', common.mustNotCall()) + describe('nested describe enabled', common.mustCall(() => { + it('is enabled', common.mustCall()) + })) +}) diff --git a/test/message/test_runner_test_name_pattern.out b/test/message/test_runner_test_name_pattern.out new file mode 100644 index 0000000..be548ad --- /dev/null +++ b/test/message/test_runner_test_name_pattern.out @@ -0,0 +1,107 @@ +TAP version 13 +# Subtest: top level test disabled +ok 1 - top level test disabled # SKIP test name does not match pattern + --- + duration_ms: * + ... +# Subtest: top level skipped test disabled +ok 2 - top level skipped test disabled # SKIP test name does not match pattern + --- + duration_ms: * + ... +# Subtest: top level skipped test enabled +ok 3 - top level skipped test enabled # SKIP + --- + duration_ms: * + ... +# Subtest: top level it enabled +ok 4 - top level it enabled + --- + duration_ms: * + ... +# Subtest: top level it disabled +ok 5 - top level it disabled # SKIP test name does not match pattern + --- + duration_ms: * + ... +# Subtest: top level skipped it disabled +ok 6 - top level skipped it disabled # SKIP test name does not match pattern + --- + duration_ms: * + ... +# Subtest: top level skipped it enabled +ok 7 - top level skipped it enabled # SKIP + --- + duration_ms: * + ... +# Subtest: top level describe disabled +ok 8 - top level describe disabled # SKIP test name does not match pattern + --- + duration_ms: * + ... +# Subtest: top level skipped describe disabled +ok 9 - top level skipped describe disabled # SKIP test name does not match pattern + --- + duration_ms: * + ... +# Subtest: top level skipped describe enabled +ok 10 - top level skipped describe enabled # SKIP + --- + duration_ms: * + ... +# Subtest: top level runs because name includes PaTtErN +ok 11 - top level runs because name includes PaTtErN + --- + duration_ms: * + ... +# Subtest: top level test enabled + # Subtest: nested test runs because name includes PATTERN + ok 1 - nested test runs because name includes PATTERN + --- + duration_ms: * + ... + 1..1 +ok 12 - top level test enabled + --- + duration_ms: * + ... +# Subtest: top level describe enabled + # Subtest: nested it disabled + ok 1 - nested it disabled # SKIP test name does not match pattern + --- + duration_ms: * + ... + # Subtest: nested it enabled + ok 2 - nested it enabled + --- + duration_ms: * + ... + # Subtest: nested describe disabled + ok 3 - nested describe disabled # SKIP test name does not match pattern + --- + duration_ms: * + ... + # Subtest: nested describe enabled + # Subtest: is enabled + ok 1 - is enabled + --- + duration_ms: * + ... + 1..1 + ok 4 - nested describe enabled + --- + duration_ms: * + ... + 1..4 +ok 13 - top level describe enabled + --- + duration_ms: * + ... +1..13 +# tests 13 +# pass 4 +# fail 0 +# cancelled 0 +# skipped 9 +# todo 0 +# duration_ms * diff --git a/test/message/test_runner_test_name_pattern_with_only.js b/test/message/test_runner_test_name_pattern_with_only.js new file mode 100644 index 0000000..4f2d6fb --- /dev/null +++ b/test/message/test_runner_test_name_pattern_with_only.js @@ -0,0 +1,14 @@ +// https://github.com/nodejs/node/blob/87170c3f9271da947a7b33d0696ec4cf8aab6eb6/test/message/test_runner_test_name_pattern_with_only.js +// Flags: --no-warnings --test-only --test-name-pattern=enabled +'use strict' +const common = require('../common') +const { test } = require('#node:test') + +test('enabled and only', { only: true }, common.mustCall(async (t) => { + await t.test('enabled', common.mustCall()) + await t.test('disabled', common.mustNotCall()) +})) + +test('enabled but not only', common.mustNotCall()) +test('only does not match pattern', { only: true }, common.mustNotCall()) +test('not only and does not match pattern', common.mustNotCall()) diff --git a/test/message/test_runner_test_name_pattern_with_only.out b/test/message/test_runner_test_name_pattern_with_only.out new file mode 100644 index 0000000..2e10064 --- /dev/null +++ b/test/message/test_runner_test_name_pattern_with_only.out @@ -0,0 +1,40 @@ +TAP version 13 +# Subtest: enabled and only + # Subtest: enabled + ok 1 - enabled + --- + duration_ms: * + ... + # Subtest: disabled + ok 2 - disabled # SKIP test name does not match pattern + --- + duration_ms: * + ... + 1..2 +ok 1 - enabled and only + --- + duration_ms: * + ... +# Subtest: enabled but not only +ok 2 - enabled but not only # SKIP 'only' option not set + --- + duration_ms: * + ... +# Subtest: only does not match pattern +ok 3 - only does not match pattern # SKIP test name does not match pattern + --- + duration_ms: * + ... +# Subtest: not only and does not match pattern +ok 4 - not only and does not match pattern # SKIP 'only' option not set + --- + duration_ms: * + ... +1..4 +# tests 4 +# pass 1 +# fail 0 +# cancelled 0 +# skipped 3 +# todo 0 +# duration_ms * diff --git a/test/message/test_runner_unresolved_promise.out b/test/message/test_runner_unresolved_promise.out index 2bb543c..b4d6cba 100644 --- a/test/message/test_runner_unresolved_promise.out +++ b/test/message/test_runner_unresolved_promise.out @@ -9,7 +9,7 @@ not ok 2 - never resolving promise --- duration_ms: * failureType: 'cancelledByParent' - error: 'test did not finish before its parent and was cancelled' + error: 'Promise resolution is still pending but the event loop has already resolved' code: 'ERR_TEST_FAILURE' stack: |- * @@ -19,7 +19,7 @@ not ok 3 - fail --- duration_ms: 0 failureType: 'cancelledByParent' - error: 'test did not finish before its parent and was cancelled' + error: 'Promise resolution is still pending but the event loop has already resolved' code: 'ERR_TEST_FAILURE' stack: |- * diff --git a/test/parallel.mjs b/test/parallel.mjs index 48e0f61..e0a5050 100755 --- a/test/parallel.mjs +++ b/test/parallel.mjs @@ -10,7 +10,7 @@ const PARALLEL_DIR = new URL('./parallel/', import.meta.url) const dir = await fs.opendir(PARALLEL_DIR) for await (const { name } of dir) { - if (!name.endsWith('.js')) continue + if (!name.endsWith('.js') && !name.endsWith('.mjs')) continue const cp = spawn( process.execPath, [fileURLToPath(new URL(name, PARALLEL_DIR))], diff --git a/test/parallel/test-runner-cli.js b/test/parallel/test-runner-cli.js index d8aad56..397ea82 100644 --- a/test/parallel/test-runner-cli.js +++ b/test/parallel/test-runner-cli.js @@ -1,4 +1,4 @@ -// https://github.com/nodejs/node/blob/1aab13cad9c800f4121c1d35b554b78c1b17bdbd/test/parallel/test-runner-cli.js +// https://github.com/nodejs/node/blob/f8ce9117b19702487eb600493d941f7876e00e01/test/parallel/test-runner-cli.js 'use strict' require('../common') const assert = require('assert') @@ -106,13 +106,6 @@ const testFixtures = fixtures.path('test-runner') // ['--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) @@ -123,3 +116,55 @@ const testFixtures = fixtures.path('test-runner') // assert.match(stderr, /--test/) // }) // } + +{ + // Test combined stream outputs + const args = [ + '--test', + 'test/fixtures/test-runner/index.test.js', + 'test/fixtures/test-runner/nested.js', + 'test/fixtures/test-runner/invalid-tap.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.match(stdout, /# Subtest: .+index\.test\.js/) + assert.match(stdout, / {4}# Subtest: this should pass/) + assert.match(stdout, / {4}ok 1 - this should pass/) + assert.match(stdout, / {6}---/) + assert.match(stdout, / {6}duration_ms: .*/) + assert.match(stdout, / {6}\.\.\./) + assert.match(stdout, / {4}1\.\.1/) + + assert.match(stdout, /ok 1 - .+index\.test\.js/) + + assert.match(stdout, /# Subtest: .+invalid-tap\.js/) + assert.match(stdout, / {4}# invalid tap output/) + assert.match(stdout, /ok 2 - .+invalid-tap\.js/) + + assert.match(stdout, /# Subtest: .+nested\.js/) + assert.match(stdout, / {4}# Subtest: level 0a/) + assert.match(stdout, / {8}# Subtest: level 1a/) + assert.match(stdout, / {8}ok 1 - level 1a/) + assert.match(stdout, / {8}# Subtest: level 1b/) + assert.match(stdout, / {8}not ok 2 - level 1b/) + assert.match(stdout, / {10}code: 'ERR_TEST_FAILURE'/) + assert.match(stdout, / {10}stack: |-'/) + assert.match(stdout, / {12}TestContext\. .*/) + assert.match(stdout, / {8}# Subtest: level 1c/) + assert.match(stdout, / {8}ok 3 - level 1c # SKIP aaa/) + assert.match(stdout, / {8}# Subtest: level 1d/) + assert.match(stdout, / {8}ok 4 - level 1d/) + assert.match(stdout, / {4}not ok 1 - level 0a/) + assert.match(stdout, / {6}error: '1 subtest failed'/) + assert.match(stdout, / {4}# Subtest: level 0b/) + assert.match(stdout, / {4}not ok 2 - level 0b/) + assert.match(stdout, / {6}error: 'level 0b error'/) + assert.match(stdout, /not ok 3 - .+nested\.js/) + assert.match(stdout, /# tests 3/) + assert.match(stdout, /# pass 2/) + assert.match(stdout, /# fail 1/) +} diff --git a/test/parallel/test-runner-extraneous-async-activity.js b/test/parallel/test-runner-extraneous-async-activity.js new file mode 100644 index 0000000..da826c4 --- /dev/null +++ b/test/parallel/test-runner-extraneous-async-activity.js @@ -0,0 +1,32 @@ +// https://github.com/nodejs/node/blob/06603c44a5b0e92b1a3591ace467ce9770bf9658/test/parallel/test-runner-extraneous-async-activity.js +'use strict' +require('../common') +const fixtures = require('../common/fixtures') +const assert = require('assert') +const { spawnSync } = require('child_process') + +{ + const child = spawnSync(process.execPath, [ + '--test', + fixtures.path('test-runner', 'extraneous_set_immediate_async.mjs') + ]) + const stdout = child.stdout.toString() + assert.match(stdout, /^# pass 0$/m) + assert.match(stdout, /^# fail 1$/m) + assert.match(stdout, /^# cancelled 0$/m) + assert.strictEqual(child.status, 1) + assert.strictEqual(child.signal, null) +} + +{ + const child = spawnSync(process.execPath, [ + '--test', + fixtures.path('test-runner', 'extraneous_set_timeout_async.mjs') + ]) + const stdout = child.stdout.toString() + assert.match(stdout, /^# pass 0$/m) + assert.match(stdout, /^# fail 1$/m) + assert.match(stdout, /^# cancelled 0$/m) + assert.strictEqual(child.status, 1) + assert.strictEqual(child.signal, null) +} diff --git a/test/parallel/test-runner-mocking.js b/test/parallel/test-runner-mocking.js new file mode 100644 index 0000000..f956c52 --- /dev/null +++ b/test/parallel/test-runner-mocking.js @@ -0,0 +1,1039 @@ +// https://github.com/nodejs/node/blob/929aada39d0f418193ca03cc360ced8c5b4ce553/test/parallel/test-runner-mocking.js +'use strict' +const common = require('../common') +const assert = require('node:assert') +const { mock, test } = require('#node:test') + +test('spies on a function', (t) => { + const sum = t.mock.fn((arg1, arg2) => { + return arg1 + arg2 + }) + + assert.strictEqual(sum.mock.calls.length, 0) + assert.strictEqual(sum(3, 4), 7) + assert.strictEqual(sum.call(1000, 9, 1), 10) + assert.strictEqual(sum.mock.calls.length, 2) + + let call = sum.mock.calls[0] + assert.deepStrictEqual(call.arguments, [3, 4]) + assert.strictEqual(call.error, undefined) + assert.strictEqual(call.result, 7) + assert.strictEqual(call.target, undefined) + assert.strictEqual(call.this, undefined) + + call = sum.mock.calls[1] + assert.deepStrictEqual(call.arguments, [9, 1]) + assert.strictEqual(call.error, undefined) + assert.strictEqual(call.result, 10) + assert.strictEqual(call.target, undefined) + assert.strictEqual(call.this, 1000) +}) + +test('spies on a bound function', (t) => { + const bound = function (arg1, arg2) { + return this + arg1 + arg2 + }.bind(50) + const sum = t.mock.fn(bound) + + assert.strictEqual(sum.mock.calls.length, 0) + assert.strictEqual(sum(3, 4), 57) + assert.strictEqual(sum(9, 1), 60) + assert.strictEqual(sum.mock.calls.length, 2) + + let call = sum.mock.calls[0] + assert.deepStrictEqual(call.arguments, [3, 4]) + assert.strictEqual(call.result, 57) + assert.strictEqual(call.target, undefined) + assert.strictEqual(call.this, undefined) + + call = sum.mock.calls[1] + assert.deepStrictEqual(call.arguments, [9, 1]) + assert.strictEqual(call.result, 60) + assert.strictEqual(call.target, undefined) + assert.strictEqual(call.this, undefined) +}) + +test('spies on a constructor', (t) => { + class ParentClazz { + constructor (c) { + this.c = c + } + } + + class Clazz extends ParentClazz { + #privateValue + + constructor (a, b) { + super(a + b) + this.a = a + this.#privateValue = b + } + + getPrivateValue () { + return this.#privateValue + } + } + + const ctor = t.mock.fn(Clazz) + const instance = new ctor(42, 85) // eslint-disable-line new-cap + + assert(instance instanceof Clazz) + assert(instance instanceof ParentClazz) + assert.strictEqual(instance.a, 42) + assert.strictEqual(instance.getPrivateValue(), 85) + assert.strictEqual(instance.c, 127) + assert.strictEqual(ctor.mock.calls.length, 1) + + const call = ctor.mock.calls[0] + + assert.deepStrictEqual(call.arguments, [42, 85]) + assert.strictEqual(call.error, undefined) + assert.strictEqual(call.result, instance) + assert.strictEqual(call.target, Clazz) + assert.strictEqual(call.this, instance) +}) + +test('a no-op spy function is created by default', (t) => { + const fn = t.mock.fn() + + assert.strictEqual(fn.mock.calls.length, 0) + assert.strictEqual(fn(3, 4), undefined) + assert.strictEqual(fn.mock.calls.length, 1) + + const call = fn.mock.calls[0] + assert.deepStrictEqual(call.arguments, [3, 4]) + assert.strictEqual(call.result, undefined) + assert.strictEqual(call.target, undefined) + assert.strictEqual(call.this, undefined) +}) + +test('internal no-op function can be reused', (t) => { + const fn1 = t.mock.fn() + fn1.prop = true + const fn2 = t.mock.fn() + + fn1(1) + fn2(2) + fn1(3) + + assert.notStrictEqual(fn1.mock, fn2.mock) + assert.strictEqual(fn1.mock.calls.length, 2) + assert.strictEqual(fn2.mock.calls.length, 1) + assert.strictEqual(fn1.prop, true) + assert.strictEqual(fn2.prop, undefined) +}) + +test('functions can be mocked multiple times at once', (t) => { + function sum (a, b) { + return a + b + } + + function difference (a, b) { + return a - b + } + + function product (a, b) { + return a * b + } + + const fn1 = t.mock.fn(sum, difference) + const fn2 = t.mock.fn(sum, product) + + assert.strictEqual(fn1(5, 3), 2) + assert.strictEqual(fn2(5, 3), 15) + assert.strictEqual(fn2(4, 2), 8) + assert(!('mock' in sum)) + assert(!('mock' in difference)) + assert(!('mock' in product)) + assert.notStrictEqual(fn1.mock, fn2.mock) + assert.strictEqual(fn1.mock.calls.length, 1) + assert.strictEqual(fn2.mock.calls.length, 2) +}) + +test('internal no-op function can be reused as methods', (t) => { + const obj = { + _foo: 5, + _bar: 9, + foo () { + return this._foo + }, + bar () { + return this._bar + } + } + + t.mock.method(obj, 'foo') + obj.foo.prop = true + t.mock.method(obj, 'bar') + assert.strictEqual(obj.foo(), 5) + assert.strictEqual(obj.bar(), 9) + assert.strictEqual(obj.bar(), 9) + assert.notStrictEqual(obj.foo.mock, obj.bar.mock) + assert.strictEqual(obj.foo.mock.calls.length, 1) + assert.strictEqual(obj.bar.mock.calls.length, 2) + assert.strictEqual(obj.foo.prop, true) + assert.strictEqual(obj.bar.prop, undefined) +}) + +test('methods can be mocked multiple times but not at the same time', (t) => { + const obj = { + offset: 3, + sum (a, b) { + return this.offset + a + b + } + } + + function difference (a, b) { + return this.offset + (a - b) + } + + function product (a, b) { + return this.offset + a * b + } + + const originalSum = obj.sum + const fn1 = t.mock.method(obj, 'sum', difference) + + assert.strictEqual(obj.sum(5, 3), 5) + assert.strictEqual(obj.sum(5, 1), 7) + assert.strictEqual(obj.sum, fn1) + assert.notStrictEqual(fn1.mock, undefined) + assert.strictEqual(originalSum.mock, undefined) + assert.strictEqual(difference.mock, undefined) + assert.strictEqual(product.mock, undefined) + assert.strictEqual(fn1.mock.calls.length, 2) + + const fn2 = t.mock.method(obj, 'sum', product) + + assert.strictEqual(obj.sum(5, 3), 18) + assert.strictEqual(obj.sum, fn2) + assert.notStrictEqual(fn1, fn2) + assert.strictEqual(fn2.mock.calls.length, 1) + + obj.sum.mock.restore() + assert.strictEqual(obj.sum, fn1) + obj.sum.mock.restore() + assert.strictEqual(obj.sum, originalSum) + assert.strictEqual(obj.sum.mock, undefined) +}) + +test('spies on an object method', (t) => { + const obj = { + prop: 5, + method (a, b) { + return a + b + this.prop + } + } + + assert.strictEqual(obj.method(1, 3), 9) + t.mock.method(obj, 'method') + assert.strictEqual(obj.method.mock.calls.length, 0) + assert.strictEqual(obj.method(1, 3), 9) + + const call = obj.method.mock.calls[0] + + assert.deepStrictEqual(call.arguments, [1, 3]) + assert.strictEqual(call.result, 9) + assert.strictEqual(call.target, undefined) + assert.strictEqual(call.this, obj) + + assert.strictEqual(obj.method.mock.restore(), undefined) + assert.strictEqual(obj.method(1, 3), 9) + assert.strictEqual(obj.method.mock, undefined) +}) + +test('spies on a getter', (t) => { + const obj = { + prop: 5, + get method () { + return this.prop + } + } + + assert.strictEqual(obj.method, 5) + + const getter = t.mock.method(obj, 'method', { getter: true }) + + assert.strictEqual(getter.mock.calls.length, 0) + assert.strictEqual(obj.method, 5) + + const call = getter.mock.calls[0] + + assert.deepStrictEqual(call.arguments, []) + assert.strictEqual(call.result, 5) + assert.strictEqual(call.target, undefined) + assert.strictEqual(call.this, obj) + + assert.strictEqual(getter.mock.restore(), undefined) + assert.strictEqual(obj.method, 5) +}) + +test('spies on a setter', (t) => { + const obj = { + prop: 100, + // eslint-disable-next-line accessor-pairs + set method (val) { + this.prop = val + } + } + + assert.strictEqual(obj.prop, 100) + obj.method = 88 + assert.strictEqual(obj.prop, 88) + + const setter = t.mock.method(obj, 'method', { setter: true }) + + assert.strictEqual(setter.mock.calls.length, 0) + obj.method = 77 + assert.strictEqual(obj.prop, 77) + assert.strictEqual(setter.mock.calls.length, 1) + + const call = setter.mock.calls[0] + + assert.deepStrictEqual(call.arguments, [77]) + assert.strictEqual(call.result, undefined) + assert.strictEqual(call.target, undefined) + assert.strictEqual(call.this, obj) + + assert.strictEqual(setter.mock.restore(), undefined) + assert.strictEqual(obj.prop, 77) + obj.method = 65 + assert.strictEqual(obj.prop, 65) +}) + +test('spy functions can be bound', (t) => { + const sum = t.mock.fn(function (arg1, arg2) { + return this + arg1 + arg2 + }) + const bound = sum.bind(1000) + + assert.strictEqual(bound(9, 1), 1010) + assert.strictEqual(sum.mock.calls.length, 1) + + const call = sum.mock.calls[0] + assert.deepStrictEqual(call.arguments, [9, 1]) + assert.strictEqual(call.result, 1010) + assert.strictEqual(call.target, undefined) + assert.strictEqual(call.this, 1000) + + assert.strictEqual(sum.mock.restore(), undefined) + assert.strictEqual(sum.bind(0)(2, 11), 13) +}) + +test('mocks prototype methods on an instance', async (t) => { + class Runner { + async someTask (msg) { + return Promise.resolve(msg) + } + + async method (msg) { + await this.someTask(msg) + return msg + } + } + const msg = 'ok' + const obj = new Runner() + assert.strictEqual(await obj.method(msg), msg) + + t.mock.method(obj, obj.someTask.name) + assert.strictEqual(obj.someTask.mock.calls.length, 0) + + assert.strictEqual(await obj.method(msg), msg) + + const call = obj.someTask.mock.calls[0] + + assert.deepStrictEqual(call.arguments, [msg]) + assert.strictEqual(await call.result, msg) + assert.strictEqual(call.target, undefined) + assert.strictEqual(call.this, obj) + + const obj2 = new Runner() + // Ensure that a brand new instance is not mocked + assert.strictEqual( + obj2.someTask.mock, + undefined + ) + + assert.strictEqual(obj.someTask.mock.restore(), undefined) + assert.strictEqual(await obj.method(msg), msg) + assert.strictEqual(obj.someTask.mock, undefined) + assert.strictEqual(Runner.prototype.someTask.mock, undefined) +}) + +test('spies on async static class methods', async (t) => { + class Runner { + static async someTask (msg) { + return Promise.resolve(msg) + } + + static async method (msg) { + await this.someTask(msg) + return msg + } + } + const msg = 'ok' + assert.strictEqual(await Runner.method(msg), msg) + + t.mock.method(Runner, Runner.someTask.name) + assert.strictEqual(Runner.someTask.mock.calls.length, 0) + + assert.strictEqual(await Runner.method(msg), msg) + + const call = Runner.someTask.mock.calls[0] + + assert.deepStrictEqual(call.arguments, [msg]) + assert.strictEqual(await call.result, msg) + assert.strictEqual(call.target, undefined) + assert.strictEqual(call.this, Runner) + + assert.strictEqual(Runner.someTask.mock.restore(), undefined) + assert.strictEqual(await Runner.method(msg), msg) + assert.strictEqual(Runner.someTask.mock, undefined) + assert.strictEqual(Runner.prototype.someTask, undefined) +}) + +test('given null to a mock.method it throws a invalid argument error', (t) => { + assert.throws(() => t.mock.method(null, {}), { code: 'ERR_INVALID_ARG_TYPE' }) +}) + +test('it should throw given an inexistent property on a object instance', (t) => { + assert.throws(() => t.mock.method({ abc: 0 }, 'non-existent'), { + code: 'ERR_INVALID_ARG_VALUE' + }) +}) + +test('spy functions can be used on classes inheritance', (t) => { + // Makes sure that having a null-prototype doesn't throw our system off + class A extends null { + static someTask (msg) { + return msg + } + + static method (msg) { + return this.someTask(msg) + } + } + class B extends A {} + class C extends B {} + + const msg = 'ok' + assert.strictEqual(C.method(msg), msg) + + t.mock.method(C, C.someTask.name) + assert.strictEqual(C.someTask.mock.calls.length, 0) + + assert.strictEqual(C.method(msg), msg) + + const call = C.someTask.mock.calls[0] + + assert.deepStrictEqual(call.arguments, [msg]) + assert.strictEqual(call.result, msg) + assert.strictEqual(call.target, undefined) + assert.strictEqual(call.this, C) + + assert.strictEqual(C.someTask.mock.restore(), undefined) + assert.strictEqual(C.method(msg), msg) + assert.strictEqual(C.someTask.mock, undefined) +}) + +test('spy functions don\'t affect the prototype chain', (t) => { + class A { + static someTask (msg) { + return msg + } + } + class B extends A {} + class C extends B {} + + const msg = 'ok' + + const ABeforeMockIsUnchanged = Object.getOwnPropertyDescriptor(A, A.someTask.name) + const BBeforeMockIsUnchanged = Object.getOwnPropertyDescriptor(B, B.someTask.name) + const CBeforeMockShouldNotHaveDesc = Object.getOwnPropertyDescriptor(C, C.someTask.name) + + t.mock.method(C, C.someTask.name) + C.someTask(msg) + const BAfterMockIsUnchanged = Object.getOwnPropertyDescriptor(B, B.someTask.name) + + const AAfterMockIsUnchanged = Object.getOwnPropertyDescriptor(A, A.someTask.name) + const CAfterMockHasDescriptor = Object.getOwnPropertyDescriptor(C, C.someTask.name) + + assert.strictEqual(CBeforeMockShouldNotHaveDesc, undefined) + assert.ok(CAfterMockHasDescriptor) + + assert.deepStrictEqual(ABeforeMockIsUnchanged, AAfterMockIsUnchanged) + assert.strictEqual(BBeforeMockIsUnchanged, BAfterMockIsUnchanged) + assert.strictEqual(BBeforeMockIsUnchanged, undefined) + + assert.strictEqual(C.someTask.mock.restore(), undefined) + const CAfterRestoreKeepsDescriptor = Object.getOwnPropertyDescriptor(C, C.someTask.name) + assert.ok(CAfterRestoreKeepsDescriptor) +}) + +test('mocked functions report thrown errors', (t) => { + const testError = new Error('test error') + const fn = t.mock.fn(() => { + throw testError + }) + + assert.throws(fn, /test error/) + assert.strictEqual(fn.mock.calls.length, 1) + + const call = fn.mock.calls[0] + + assert.deepStrictEqual(call.arguments, []) + assert.strictEqual(call.error, testError) + assert.strictEqual(call.result, undefined) + assert.strictEqual(call.target, undefined) + assert.strictEqual(call.this, undefined) +}) + +test('mocked constructors report thrown errors', (t) => { + const testError = new Error('test error') + class Clazz { + constructor () { + throw testError + } + } + + const ctor = t.mock.fn(Clazz) + + assert.throws(() => { + new ctor() // eslint-disable-line new-cap, no-new + }, /test error/) + assert.strictEqual(ctor.mock.calls.length, 1) + + const call = ctor.mock.calls[0] + + assert.deepStrictEqual(call.arguments, []) + assert.strictEqual(call.error, testError) + assert.strictEqual(call.result, undefined) + assert.strictEqual(call.target, Clazz) + assert.strictEqual(call.this, undefined) +}) + +test('mocks a function', (t) => { + const sum = (arg1, arg2) => arg1 + arg2 + const difference = (arg1, arg2) => arg1 - arg2 + const fn = t.mock.fn(sum, difference) + + assert.strictEqual(fn.mock.calls.length, 0) + assert.strictEqual(fn(3, 4), -1) + assert.strictEqual(fn(9, 1), 8) + assert.strictEqual(fn.mock.calls.length, 2) + + let call = fn.mock.calls[0] + assert.deepStrictEqual(call.arguments, [3, 4]) + assert.strictEqual(call.result, -1) + assert.strictEqual(call.target, undefined) + assert.strictEqual(call.this, undefined) + + call = fn.mock.calls[1] + assert.deepStrictEqual(call.arguments, [9, 1]) + assert.strictEqual(call.result, 8) + assert.strictEqual(call.target, undefined) + assert.strictEqual(call.this, undefined) + + assert.strictEqual(fn.mock.restore(), undefined) + assert.strictEqual(fn(2, 11), 13) +}) + +test('mocks a constructor', (t) => { + class ParentClazz { + constructor (c) { + this.c = c + } + } + + class Clazz extends ParentClazz { + #privateValue + + constructor (a, b) { + super(a + b) + this.a = a + this.#privateValue = b + } + + getPrivateValue () { + return this.#privateValue + } + } + + class MockClazz { + #privateValue + + constructor (z) { + this.z = z + } + } + + const ctor = t.mock.fn(Clazz, MockClazz) + const instance = new ctor(42, 85) // eslint-disable-line new-cap + + assert(!(instance instanceof MockClazz)) + assert(instance instanceof Clazz) + assert(instance instanceof ParentClazz) + assert.strictEqual(instance.a, undefined) + assert.strictEqual(instance.c, undefined) + assert.strictEqual(instance.z, 42) + assert.strictEqual(ctor.mock.calls.length, 1) + + const call = ctor.mock.calls[0] + + assert.deepStrictEqual(call.arguments, [42, 85]) + assert.strictEqual(call.result, instance) + assert.strictEqual(call.target, Clazz) + assert.strictEqual(call.this, instance) + assert.throws(() => { + instance.getPrivateValue() + }, /TypeError: Cannot read private member #privateValue /) +}) + +test('mocks an object method', (t) => { + const obj = { + prop: 5, + method (a, b) { + return a + b + this.prop + } + } + + function mockMethod (a) { + return a + this.prop + } + + assert.strictEqual(obj.method(1, 3), 9) + t.mock.method(obj, 'method', mockMethod) + assert.strictEqual(obj.method.mock.calls.length, 0) + assert.strictEqual(obj.method(1, 3), 6) + + const call = obj.method.mock.calls[0] + + assert.deepStrictEqual(call.arguments, [1, 3]) + assert.strictEqual(call.result, 6) + assert.strictEqual(call.target, undefined) + assert.strictEqual(call.this, obj) + + assert.strictEqual(obj.method.mock.restore(), undefined) + assert.strictEqual(obj.method(1, 3), 9) + assert.strictEqual(obj.method.mock, undefined) +}) + +test('mocks a getter', (t) => { + const obj = { + prop: 5, + get method () { + return this.prop + } + } + + function mockMethod () { + return this.prop - 1 + } + + assert.strictEqual(obj.method, 5) + + const getter = t.mock.method(obj, 'method', mockMethod, { getter: true }) + + assert.strictEqual(getter.mock.calls.length, 0) + assert.strictEqual(obj.method, 4) + + const call = getter.mock.calls[0] + + assert.deepStrictEqual(call.arguments, []) + assert.strictEqual(call.result, 4) + assert.strictEqual(call.target, undefined) + assert.strictEqual(call.this, obj) + + assert.strictEqual(getter.mock.restore(), undefined) + assert.strictEqual(obj.method, 5) +}) + +test('mocks a setter', (t) => { + const obj = { + prop: 100, + // eslint-disable-next-line accessor-pairs + set method (val) { + this.prop = val + } + } + + function mockMethod (val) { + this.prop = -val + } + + assert.strictEqual(obj.prop, 100) + obj.method = 88 + assert.strictEqual(obj.prop, 88) + + const setter = t.mock.method(obj, 'method', mockMethod, { setter: true }) + + assert.strictEqual(setter.mock.calls.length, 0) + obj.method = 77 + assert.strictEqual(obj.prop, -77) + assert.strictEqual(setter.mock.calls.length, 1) + + const call = setter.mock.calls[0] + + assert.deepStrictEqual(call.arguments, [77]) + assert.strictEqual(call.result, undefined) + assert.strictEqual(call.target, undefined) + assert.strictEqual(call.this, obj) + + assert.strictEqual(setter.mock.restore(), undefined) + assert.strictEqual(obj.prop, -77) + obj.method = 65 + assert.strictEqual(obj.prop, 65) +}) + +test('mocks a getter with syntax sugar', (t) => { + const obj = { + prop: 5, + get method () { + return this.prop + } + } + + function mockMethod () { + return this.prop - 1 + } + const getter = t.mock.getter(obj, 'method', mockMethod) + assert.strictEqual(getter.mock.calls.length, 0) + assert.strictEqual(obj.method, 4) + + const call = getter.mock.calls[0] + + assert.deepStrictEqual(call.arguments, []) + assert.strictEqual(call.result, 4) + assert.strictEqual(call.target, undefined) + assert.strictEqual(call.this, obj) + + assert.strictEqual(getter.mock.restore(), undefined) + assert.strictEqual(obj.method, 5) +}) + +test('mocks a setter with syntax sugar', (t) => { + const obj = { + prop: 100, + // eslint-disable-next-line accessor-pairs + set method (val) { + this.prop = val + } + } + + function mockMethod (val) { + this.prop = -val + } + + assert.strictEqual(obj.prop, 100) + obj.method = 88 + assert.strictEqual(obj.prop, 88) + + const setter = t.mock.setter(obj, 'method', mockMethod) + + assert.strictEqual(setter.mock.calls.length, 0) + obj.method = 77 + assert.strictEqual(obj.prop, -77) + assert.strictEqual(setter.mock.calls.length, 1) + + const call = setter.mock.calls[0] + + assert.deepStrictEqual(call.arguments, [77]) + assert.strictEqual(call.result, undefined) + assert.strictEqual(call.target, undefined) + assert.strictEqual(call.this, obj) + + assert.strictEqual(setter.mock.restore(), undefined) + assert.strictEqual(obj.prop, -77) + obj.method = 65 + assert.strictEqual(obj.prop, 65) +}) + +test('mocked functions match name and length', (t) => { + function getNameAndLength (fn) { + return { + name: Object.getOwnPropertyDescriptor(fn, 'name'), + length: Object.getOwnPropertyDescriptor(fn, 'length') + } + } + + function func1 () {} + const func2 = function (a) {} // eslint-disable-line func-style + const arrow = (a, b, c) => {} + const obj = { method (a, b) {} } + + assert.deepStrictEqual( + getNameAndLength(func1), + getNameAndLength(t.mock.fn(func1)) + ) + assert.deepStrictEqual( + getNameAndLength(func2), + getNameAndLength(t.mock.fn(func2)) + ) + assert.deepStrictEqual( + getNameAndLength(arrow), + getNameAndLength(t.mock.fn(arrow)) + ) + assert.deepStrictEqual( + getNameAndLength(obj.method), + getNameAndLength(t.mock.method(obj, 'method', func1)) + ) +}) + +test('method() fails if method cannot be redefined', (t) => { + const obj = { + prop: 5 + } + + Object.defineProperty(obj, 'method', { + configurable: false, + value (a, b) { + return a + b + this.prop + } + }) + + function mockMethod (a) { + return a + this.prop + } + + assert.throws(() => { + t.mock.method(obj, 'method', mockMethod) + }, /Cannot redefine property: method/) + assert.strictEqual(obj.method(1, 3), 9) + assert.strictEqual(obj.method.mock, undefined) +}) + +test('method() fails if field is a property instead of a method', (t) => { + const obj = { + prop: 5, + method: 100 + } + + function mockMethod (a) { + return a + this.prop + } + + assert.throws(() => { + t.mock.method(obj, 'method', mockMethod) + }, /The argument 'methodName' must be a method/) + assert.strictEqual(obj.method, 100) + assert.strictEqual(obj.method.mock, undefined) +}) + +test('mocks can be auto-restored', (t) => { + let cnt = 0 + + function addOne () { + cnt++ + return cnt + } + + function addTwo () { + cnt += 2 + return cnt + } + + const fn = t.mock.fn(addOne, addTwo, { times: 2 }) + + assert.strictEqual(fn(), 2) + assert.strictEqual(fn(), 4) + assert.strictEqual(fn(), 5) + assert.strictEqual(fn(), 6) +}) + +test('mock implementation can be changed dynamically', (t) => { + let cnt = 0 + + function addOne () { + cnt++ + return cnt + } + + function addTwo () { + cnt += 2 + return cnt + } + + function addThree () { + cnt += 3 + return cnt + } + + const fn = t.mock.fn(addOne) + + assert.strictEqual(fn.mock.callCount(), 0) + assert.strictEqual(fn(), 1) + assert.strictEqual(fn(), 2) + assert.strictEqual(fn(), 3) + assert.strictEqual(fn.mock.callCount(), 3) + + fn.mock.mockImplementation(addTwo) + assert.strictEqual(fn(), 5) + assert.strictEqual(fn(), 7) + assert.strictEqual(fn.mock.callCount(), 5) + + fn.mock.restore() + assert.strictEqual(fn(), 8) + assert.strictEqual(fn(), 9) + assert.strictEqual(fn.mock.callCount(), 7) + + assert.throws(() => { + fn.mock.mockImplementationOnce(common.mustNotCall(), 6) + }, /Expected onCall to be >= 7/) + + fn.mock.mockImplementationOnce(addThree, 7) + fn.mock.mockImplementationOnce(addTwo, 8) + assert.strictEqual(fn(), 12) + assert.strictEqual(fn(), 14) + assert.strictEqual(fn(), 15) + assert.strictEqual(fn.mock.callCount(), 10) + fn.mock.mockImplementationOnce(addThree) + assert.strictEqual(fn(), 18) + assert.strictEqual(fn(), 19) + assert.strictEqual(fn.mock.callCount(), 12) +}) + +test('local mocks are auto restored after the test finishes', async (t) => { + const obj = { + foo () {}, + bar () {} + } + const originalFoo = obj.foo + const originalBar = obj.bar + + assert.strictEqual(originalFoo, obj.foo) + assert.strictEqual(originalBar, obj.bar) + + const mockFoo = t.mock.method(obj, 'foo') + + assert.strictEqual(mockFoo, obj.foo) + assert.notStrictEqual(originalFoo, obj.foo) + assert.strictEqual(originalBar, obj.bar) + + t.beforeEach(() => { + assert.strictEqual(mockFoo, obj.foo) + assert.strictEqual(originalBar, obj.bar) + }) + + t.afterEach(() => { + assert.strictEqual(mockFoo, obj.foo) + assert.notStrictEqual(originalBar, obj.bar) + }) + + await t.test('creates mocks that are auto restored', (t) => { + const mockBar = t.mock.method(obj, 'bar') + + assert.strictEqual(mockFoo, obj.foo) + assert.strictEqual(mockBar, obj.bar) + assert.notStrictEqual(originalBar, obj.bar) + }) + + assert.strictEqual(mockFoo, obj.foo) + assert.strictEqual(originalBar, obj.bar) +}) + +test('uses top level mock', () => { + function sum (a, b) { + return a + b + } + + function difference (a, b) { + return a - b + } + + const fn = mock.fn(sum, difference) + + assert.strictEqual(fn.mock.calls.length, 0) + assert.strictEqual(fn(3, 4), -1) + assert.strictEqual(fn.mock.calls.length, 1) + mock.reset() + assert.strictEqual(fn(3, 4), 7) + assert.strictEqual(fn.mock.calls.length, 2) +}) + +test('the getter and setter options cannot be used together', (t) => { + assert.throws(() => { + t.mock.method({}, 'method', { getter: true, setter: true }) + }, /The property 'options\.setter' cannot be used with 'options\.getter'/) +}) + +test('method names must be strings or symbols', (t) => { + const symbol = Symbol('') + const obj = { + method () {}, + [symbol] () {} + } + + t.mock.method(obj, 'method') + t.mock.method(obj, symbol) + + assert.throws(() => { + t.mock.method(obj, {}) + }, /Expected methodName to be string,symbol/) +}) + +test('the times option must be an integer >= 1', (t) => { + assert.throws(() => { + t.mock.fn({ times: null }) + }, /Expected options\.times to be number/) + + assert.throws(() => { + t.mock.fn({ times: 0 }) + }, /Expected options.times to be >= 1/) + + assert.throws(() => { + t.mock.fn(() => {}, { times: 3.14159 }) + }, /Expected options.times to be an integer/) +}) + +test('spies on a class prototype method', (t) => { + class Clazz { + constructor (c) { + this.c = c + } + + getC () { + return this.c + } + } + + const instance = new Clazz(85) + + assert.strictEqual(instance.getC(), 85) + t.mock.method(Clazz.prototype, 'getC') + + assert.strictEqual(instance.getC.mock.calls.length, 0) + assert.strictEqual(instance.getC(), 85) + assert.strictEqual(instance.getC.mock.calls.length, 1) + assert.strictEqual(Clazz.prototype.getC.mock.calls.length, 1) + + const call = instance.getC.mock.calls[0] + assert.deepStrictEqual(call.arguments, []) + assert.strictEqual(call.result, 85) + assert.strictEqual(call.error, undefined) + assert.strictEqual(call.target, undefined) + assert.strictEqual(call.this, instance) +}) + +test('getter() fails if getter options set to false', (t) => { + assert.throws(() => { + t.mock.getter({}, 'method', { getter: false }) + }, /The property 'options\.getter' cannot be false/) +}) + +test('setter() fails if setter options set to false', (t) => { + assert.throws(() => { + t.mock.setter({}, 'method', { setter: false }) + }, /The property 'options\.setter' cannot be false/) +}) + +test('getter() fails if setter options is true', (t) => { + assert.throws(() => { + t.mock.getter({}, 'method', { setter: true }) + }, /The property 'options\.setter' cannot be used with 'options\.getter'/) +}) + +test('setter() fails if getter options is true', (t) => { + assert.throws(() => { + t.mock.setter({}, 'method', { getter: true }) + }, /The property 'options\.setter' cannot be used with 'options\.getter'/) +}) diff --git a/test/parallel/test-runner-run.mjs b/test/parallel/test-runner-run.mjs new file mode 100644 index 0000000..b41dd33 --- /dev/null +++ b/test/parallel/test-runner-run.mjs @@ -0,0 +1,75 @@ +// https://github.com/nodejs/node/blob/f8ce9117b19702487eb600493d941f7876e00e01/test/parallel/test-runner-run.mjs + +import common from '../common/index.js' +import fixtures from '../common/fixtures.js' +import { join } from 'node:path' +import pkg from '#node:test' +import assert from 'node:assert' + +const { describe, it, run } = pkg + +const testFixtures = fixtures.path('test-runner') + +describe('require(\'node:test\').run', { concurrency: true }, () => { + it('should run with no tests', async () => { + const stream = run({ files: [] }) + stream.setEncoding('utf8') + stream.on('test:fail', common.mustNotCall()) + stream.on('test:pass', common.mustNotCall()) + // eslint-disable-next-line no-unused-vars + for await (const _ of stream); // TODO(MoLow): assert.snapshot + }) + + it('should fail with non existing file', async () => { + const stream = run({ files: ['a-random-file-that-does-not-exist.js'] }) + stream.on('test:fail', common.mustCall(1)) + stream.on('test:pass', common.mustNotCall()) + // eslint-disable-next-line no-unused-vars + for await (const _ of stream); // TODO(MoLow): assert.snapshot + }) + + it('should succeed with a file', async () => { + const stream = run({ files: [join(testFixtures, 'test/random.cjs')] }) + stream.on('test:fail', common.mustNotCall()) + stream.on('test:pass', common.mustCall(2)) + // eslint-disable-next-line no-unused-vars + for await (const _ of stream); // TODO(MoLow): assert.snapshot + }) + + it('should run same file twice', async () => { + const stream = run({ files: [join(testFixtures, 'test/random.cjs'), join(testFixtures, 'test/random.cjs')] }) + stream.on('test:fail', common.mustNotCall()) + stream.on('test:pass', common.mustCall(4)) + // eslint-disable-next-line no-unused-vars + for await (const _ of stream); // TODO(MoLow): assert.snapshot + }) + + it('should run a failed test', async () => { + const stream = run({ files: [testFixtures] }) + stream.on('test:fail', common.mustCall(1)) + stream.on('test:pass', common.mustNotCall()) + // eslint-disable-next-line no-unused-vars + for await (const _ of stream); // TODO(MoLow): assert.snapshot + }) + + it('should support timeout', async () => { + const stream = run({ + timeout: 50, + files: [ + fixtures.path('test-runner', 'never_ending_sync.js'), + fixtures.path('test-runner', 'never_ending_async.js') + ] + }) + stream.on('test:fail', common.mustCall(2)) + stream.on('test:pass', common.mustNotCall()) + // eslint-disable-next-line no-unused-vars + for await (const _ of stream); // TODO(MoLow): assert.snapshot + }) + + it('should validate files', async () => { + [Symbol(''), {}, () => {}, 0, 1, 0n, 1n, '', '1', Promise.resolve([]), true, false] + .forEach((files) => assert.throws(() => run({ files }), { + code: 'ERR_INVALID_ARG_TYPE' + })) + }) +}) diff --git a/test/parallel/test-runner-string-to-regexp.js b/test/parallel/test-runner-string-to-regexp.js new file mode 100644 index 0000000..7b6fc0e --- /dev/null +++ b/test/parallel/test-runner-string-to-regexp.js @@ -0,0 +1,21 @@ +// https://github.com/nodejs/node/blob/87170c3f9271da947a7b33d0696ec4cf8aab6eb6/test/parallel/test-runner-string-to-regexp.js + +'use strict' +const common = require('../common') +const { deepStrictEqual, throws } = require('node:assert') +const { convertStringToRegExp } = require('#internal/test_runner/utils') + +deepStrictEqual(convertStringToRegExp('foo', 'x'), /foo/) +deepStrictEqual(convertStringToRegExp('/bar/', 'x'), /bar/) +deepStrictEqual(convertStringToRegExp('/baz/gi', 'x'), /baz/gi) +deepStrictEqual(convertStringToRegExp('/foo/9', 'x'), /\/foo\/9/) + +throws( + () => convertStringToRegExp('/foo/abcdefghijk', 'x'), + common.expectsError({ + code: 'ERR_INVALID_ARG_VALUE', + message: "The argument 'x' is an invalid regular expression. " + + "Invalid flags supplied to RegExp constructor 'abcdefghijk'. " + + 'Received /foo/abcdefghijk' + }) +) diff --git a/test/parallel/test-runner-tap-checker.js b/test/parallel/test-runner-tap-checker.js new file mode 100644 index 0000000..8fbc8d3 --- /dev/null +++ b/test/parallel/test-runner-tap-checker.js @@ -0,0 +1,120 @@ +// https://github.com/nodejs/node/blob/f8ce9117b19702487eb600493d941f7876e00e01/test/parallel/test-runner-tap-checker.js +'use strict' +// Flags: --expose-internals + +require('../common') +const assert = require('assert') + +const { TapParser } = require('#internal/test_runner/tap_parser') +const { TapChecker } = require('#internal/test_runner/tap_checker') + +function TAPChecker (input) { + // parse + const parser = new TapParser({ specs: TapChecker.TAP14 }) + parser.parseSync(input) + parser.check() +} + +[ + ['TAP version 14', 'missing TAP plan'], + [` +TAP version 14 +1..1 + `, 'missing Test Points'], + [` +TAP version 14 +1..1 +ok 2 + `, 'test 2 is out of plan range 1..1'], + [` +TAP version 14 +3..1 +ok 2 + `, 'plan start 3 is greater than plan end 1'], + [` +TAP version 14 +2..3 +ok 1 +ok 2 +ok 3 + `, 'test 1 is out of plan range 2..3'] + +].forEach(([str, message]) => { + assert.throws(() => TAPChecker(str), { + code: 'ERR_TAP_VALIDATION_ERROR', + message + }) +}) + +// Valid TAP14 should not throw +TAPChecker(` +TAP version 14 +1..1 +ok +`) + +// Valid comment line shout not throw. +TAPChecker(` +TAP version 14 +1..5 +ok 1 - approved operating system +# $^0 is solaris +ok 2 - # SKIP no /sys directory +ok 3 - # SKIP no /sys directory +ok 4 - # SKIP no /sys directory +ok 5 - # SKIP no /sys directory +`) + +// Valid empty test plan should not throw. +TAPChecker(` +TAP version 14 +1..0 # skip because English-to-French translator isn't installed +`) + +// Valid test plan count should not throw. +TAPChecker(` +TAP version 14 +1..4 +ok 1 - Creating test program +ok 2 - Test program runs, no error +not ok 3 - infinite loop # TODO halting problem unsolved +not ok 4 - infinite loop 2 # TODO halting problem unsolved +`) + +// Valid YAML diagnostic should not throw. +TAPChecker(` +TAP version 14 +ok - created Board +ok +ok +ok +ok +ok +ok +ok + --- + message: "Board layout" + severity: comment + dump: + board: + - ' 16G 05C ' + - ' G N C C C G ' + - ' G C + ' + - '10C 01G 03C ' + - 'R N G G A G C C C ' + - ' R G C + ' + - ' 01G 17C 00C ' + - ' G A G G N R R N R ' + - ' G R G ' + ... +ok - board has 7 tiles + starter tile +1..9 +`) + +// Valid Bail out should not throw. +TAPChecker(` +TAP version 14 +1..573 +not ok 1 - database handle +Bail out! Couldn't connect to database. +`) diff --git a/test/parallel/test-runner-tap-lexer.js b/test/parallel/test-runner-tap-lexer.js new file mode 100644 index 0000000..801a8e6 --- /dev/null +++ b/test/parallel/test-runner-tap-lexer.js @@ -0,0 +1,447 @@ +// https://github.com/nodejs/node/blob/f8ce9117b19702487eb600493d941f7876e00e01/test/parallel/test-runner-tap-lexer.js +'use strict' +// Flags: --expose-internals + +require('../common') +const assert = require('assert') + +const { TapLexer, TokenKind } = require('#internal/test_runner/tap_lexer') + +function TAPLexer (input) { + const lexer = new TapLexer(input) + return lexer.scan().flat() +} + +{ + const tokens = TAPLexer('') + + assert.strictEqual(tokens[0].kind, TokenKind.EOF) + assert.strictEqual(tokens[0].value, '') +} + +{ + const tokens = TAPLexer('TAP version 14'); + + [ + { kind: TokenKind.TAP, value: 'TAP' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.TAP_VERSION, value: 'version' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.NUMERIC, value: '14' }, + { kind: TokenKind.EOL, value: '' }, + { kind: TokenKind.EOF, value: '' } + ].forEach((token, index) => { + assert.strictEqual(tokens[index].kind, token.kind) + assert.strictEqual(tokens[index].value, token.value) + }) +} + +{ + const tokens = TAPLexer('1..5 # reason'); + + [ + { kind: TokenKind.NUMERIC, value: '1' }, + { kind: TokenKind.TAP_PLAN, value: '..' }, + { kind: TokenKind.NUMERIC, value: '5' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.HASH, value: '#' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.LITERAL, value: 'reason' }, + { kind: TokenKind.EOL, value: '' } + ].forEach((token, index) => { + assert.strictEqual(tokens[index].kind, token.kind) + assert.strictEqual(tokens[index].value, token.value) + }) +} + +{ + const tokens = TAPLexer( + '1..5 # reason "\\ !"\\#$%&\'()*+,\\-./:;<=>?@[]^_`{|}~' + ); + + [ + { kind: TokenKind.NUMERIC, value: '1' }, + { kind: TokenKind.TAP_PLAN, value: '..' }, + { kind: TokenKind.NUMERIC, value: '5' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.HASH, value: '#' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.LITERAL, value: 'reason' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.LITERAL, value: '"' }, + { kind: TokenKind.ESCAPE, value: '\\' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.LITERAL, value: '!"' }, + { kind: TokenKind.LITERAL, value: '\\' }, + { kind: TokenKind.LITERAL, value: '#' }, + { kind: TokenKind.LITERAL, value: "$%&'()*" }, + { kind: TokenKind.PLUS, value: '+' }, + { kind: TokenKind.LITERAL, value: ',' }, + { kind: TokenKind.ESCAPE, value: '\\' }, + { kind: TokenKind.DASH, value: '-' }, + { kind: TokenKind.LITERAL, value: './:;<=>?@[]^_`{|}~' }, + { kind: TokenKind.EOL, value: '' } + ].forEach((token, index) => { + assert.strictEqual(tokens[index].kind, token.kind) + assert.strictEqual(tokens[index].value, token.value) + }) +} + +{ + const tokens = TAPLexer('ok'); + + [ + { kind: TokenKind.TAP_TEST_OK, value: 'ok' }, + { kind: TokenKind.EOL, value: '' } + ].forEach((token, index) => { + assert.strictEqual(tokens[index].kind, token.kind) + assert.strictEqual(tokens[index].value, token.value) + }) +} + +{ + const tokens = TAPLexer('not ok'); + + [ + { kind: TokenKind.TAP_TEST_NOTOK, value: 'not' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.TAP_TEST_OK, value: 'ok' }, + { kind: TokenKind.EOL, value: '' } + ].forEach((token, index) => { + assert.strictEqual(tokens[index].kind, token.kind) + assert.strictEqual(tokens[index].value, token.value) + }) +} + +{ + const tokens = TAPLexer('ok 1'); + + [ + { kind: TokenKind.TAP_TEST_OK, value: 'ok' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.NUMERIC, value: '1' }, + { kind: TokenKind.EOL, value: '' } + ].forEach((token, index) => { + assert.strictEqual(tokens[index].kind, token.kind) + assert.strictEqual(tokens[index].value, token.value) + }) +} + +{ + const tokens = TAPLexer(` +ok 1 +not ok 2 +`); + + [ + { kind: TokenKind.NEWLINE, value: '\n' }, + { kind: TokenKind.TAP_TEST_OK, value: 'ok' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.NUMERIC, value: '1' }, + { kind: TokenKind.NEWLINE, value: '\n' }, + { kind: TokenKind.TAP_TEST_NOTOK, value: 'not' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.TAP_TEST_OK, value: 'ok' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.NUMERIC, value: '2' }, + { kind: TokenKind.NEWLINE, value: '\n' }, + { kind: TokenKind.EOF, value: '' } + ].forEach((token, index) => { + assert.strictEqual(tokens[index].kind, token.kind) + assert.strictEqual(tokens[index].value, token.value) + }) +} + +{ + const tokens = TAPLexer(` +ok 1 + ok 1 +`); + + [ + { kind: TokenKind.NEWLINE, value: '\n' }, + { kind: TokenKind.TAP_TEST_OK, value: 'ok' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.NUMERIC, value: '1' }, + { kind: TokenKind.NEWLINE, value: '\n' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.TAP_TEST_OK, value: 'ok' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.NUMERIC, value: '1' }, + { kind: TokenKind.NEWLINE, value: '\n' }, + { kind: TokenKind.EOF, value: '' } + ].forEach((token, index) => { + assert.strictEqual(tokens[index].kind, token.kind) + assert.strictEqual(tokens[index].value, token.value) + }) +} + +{ + const tokens = TAPLexer('ok 1 description'); + + [ + { kind: TokenKind.TAP_TEST_OK, value: 'ok' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.NUMERIC, value: '1' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.LITERAL, value: 'description' }, + { kind: TokenKind.EOL, value: '' } + ].forEach((token, index) => { + assert.strictEqual(tokens[index].kind, token.kind) + assert.strictEqual(tokens[index].value, token.value) + }) +} + +{ + const tokens = TAPLexer('ok 1 - description'); + + [ + { kind: TokenKind.TAP_TEST_OK, value: 'ok' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.NUMERIC, value: '1' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.DASH, value: '-' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.LITERAL, value: 'description' }, + { kind: TokenKind.EOL, value: '' } + ].forEach((token, index) => { + assert.strictEqual(tokens[index].kind, token.kind) + assert.strictEqual(tokens[index].value, token.value) + }) +} + +{ + const tokens = TAPLexer('ok 1 - description # todo'); + + [ + { kind: TokenKind.TAP_TEST_OK, value: 'ok' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.NUMERIC, value: '1' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.DASH, value: '-' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.LITERAL, value: 'description' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.HASH, value: '#' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.LITERAL, value: 'todo' }, + { kind: TokenKind.EOL, value: '' } + ].forEach((token, index) => { + assert.strictEqual(tokens[index].kind, token.kind) + assert.strictEqual(tokens[index].value, token.value) + }) +} + +{ + const tokens = TAPLexer('ok 1 - description \\# todo'); + + [ + { kind: TokenKind.TAP_TEST_OK, value: 'ok' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.NUMERIC, value: '1' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.DASH, value: '-' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.LITERAL, value: 'description' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.ESCAPE, value: '\\' }, + { kind: TokenKind.LITERAL, value: '#' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.LITERAL, value: 'todo' }, + { kind: TokenKind.EOL, value: '' } + ].forEach((token, index) => { + assert.strictEqual(tokens[index].kind, token.kind) + assert.strictEqual(tokens[index].value, token.value) + }) +} + +{ + const tokens = TAPLexer('ok 1 - description \\ # todo'); + + [ + { kind: TokenKind.TAP_TEST_OK, value: 'ok' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.NUMERIC, value: '1' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.DASH, value: '-' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.LITERAL, value: 'description' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.ESCAPE, value: '\\' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.HASH, value: '#' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.LITERAL, value: 'todo' }, + { kind: TokenKind.EOL, value: '' } + ].forEach((token, index) => { + assert.strictEqual(tokens[index].kind, token.kind) + assert.strictEqual(tokens[index].value, token.value) + }) +} + +{ + const tokens = TAPLexer( + 'ok 1 description \\# \\\\ world # TODO escape \\# characters with \\\\' + ); + [ + { kind: TokenKind.TAP_TEST_OK, value: 'ok' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.NUMERIC, value: '1' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.LITERAL, value: 'description' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.ESCAPE, value: '\\' }, + { kind: TokenKind.LITERAL, value: '#' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.ESCAPE, value: '\\' }, + { kind: TokenKind.LITERAL, value: '\\' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.LITERAL, value: 'world' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.HASH, value: '#' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.LITERAL, value: 'TODO' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.LITERAL, value: 'escape' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.ESCAPE, value: '\\' }, + { kind: TokenKind.LITERAL, value: '#' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.LITERAL, value: 'characters' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.LITERAL, value: 'with' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.ESCAPE, value: '\\' }, + { kind: TokenKind.LITERAL, value: '\\' }, + { kind: TokenKind.EOL, value: '' } + ].forEach((token, index) => { + assert.strictEqual(tokens[index].kind, token.kind) + assert.strictEqual(tokens[index].value, token.value) + }) +} + +{ + const tokens = TAPLexer('ok 1 - description # ##'); + + [ + { kind: TokenKind.TAP_TEST_OK, value: 'ok' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.NUMERIC, value: '1' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.DASH, value: '-' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.LITERAL, value: 'description' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.HASH, value: '#' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.LITERAL, value: '#' }, + { kind: TokenKind.LITERAL, value: '#' }, + { kind: TokenKind.EOL, value: '' } + ].forEach((token, index) => { + assert.strictEqual(tokens[index].kind, token.kind) + assert.strictEqual(tokens[index].value, token.value) + }) +} + +{ + const tokens = TAPLexer('# comment'); + [ + { kind: TokenKind.COMMENT, value: '#' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.LITERAL, value: 'comment' }, + { kind: TokenKind.EOL, value: '' } + ].forEach((token, index) => { + assert.strictEqual(tokens[index].kind, token.kind) + assert.strictEqual(tokens[index].value, token.value) + }) +} + +{ + const tokens = TAPLexer('#'); + + [ + { kind: TokenKind.COMMENT, value: '#' }, + { kind: TokenKind.EOL, value: '' } + ].forEach((token, index) => { + assert.strictEqual(tokens[index].kind, token.kind) + assert.strictEqual(tokens[index].value, token.value) + }) +} + +{ + const tokens = TAPLexer(` + --- + message: "description" + severity: fail + ... +`); + + [ + { kind: TokenKind.NEWLINE, value: '\n' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.TAP_YAML_START, value: '---' }, + { kind: TokenKind.NEWLINE, value: '\n' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.LITERAL, value: 'message:' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.LITERAL, value: '"description"' }, + { kind: TokenKind.NEWLINE, value: '\n' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.LITERAL, value: 'severity:' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.LITERAL, value: 'fail' }, + { kind: TokenKind.NEWLINE, value: '\n' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.TAP_YAML_END, value: '...' }, + { kind: TokenKind.NEWLINE, value: '\n' }, + { kind: TokenKind.EOF, value: '' } + ].forEach((token, index) => { + assert.strictEqual(tokens[index].kind, token.kind) + assert.strictEqual(tokens[index].value, token.value) + }) +} + +{ + const tokens = TAPLexer('pragma +strict -warnings'); + + [ + { kind: TokenKind.TAP_PRAGMA, value: 'pragma' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.PLUS, value: '+' }, + { kind: TokenKind.LITERAL, value: 'strict' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.DASH, value: '-' }, + { kind: TokenKind.LITERAL, value: 'warnings' }, + { kind: TokenKind.EOL, value: '' } + ].forEach((token, index) => { + assert.strictEqual(tokens[index].kind, token.kind) + assert.strictEqual(tokens[index].value, token.value) + }) +} + +{ + const tokens = TAPLexer('Bail out! Error'); + + [ + { kind: TokenKind.LITERAL, value: 'Bail' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.LITERAL, value: 'out!' }, + { kind: TokenKind.WHITESPACE, value: ' ' }, + { kind: TokenKind.LITERAL, value: 'Error' }, + { kind: TokenKind.EOL, value: '' } + ].forEach((token, index) => { + assert.strictEqual(tokens[index].kind, token.kind) + assert.strictEqual(tokens[index].value, token.value) + }) +} diff --git a/test/parallel/test-runner-tap-parser-stream.js b/test/parallel/test-runner-tap-parser-stream.js new file mode 100644 index 0000000..63a3ff3 --- /dev/null +++ b/test/parallel/test-runner-tap-parser-stream.js @@ -0,0 +1,631 @@ +// https://github.com/nodejs/node/blob/f8ce9117b19702487eb600493d941f7876e00e01/test/parallel/test-runner-tap-parser-stream.js +// Flags: --expose-internals +'use strict' +const common = require('../common') +const assert = require('node:assert') +const { TapParser } = require('#internal/test_runner/tap_parser') +const { TapChecker } = require('#internal/test_runner/tap_checker') +const { toArray } = require('#internal/streams/operators').promiseReturningOperators + +const cases = [ + { + input: 'TAP version 13', + expected: [ + { + nesting: 0, + kind: 'VersionKeyword', + node: { version: '13' }, + lexeme: 'TAP version 13' + } + ] + }, + { + input: 'invalid tap', + expected: [ + { + nesting: 0, + kind: 'Unknown', + node: { value: 'invalid tap' }, + lexeme: 'invalid tap' + } + ] + }, + { + input: 'TAP version 13\ninvalid tap after harness', + expected: [ + { + nesting: 0, + kind: 'VersionKeyword', + node: { version: '13' }, + lexeme: 'TAP version 13' + }, + { + nesting: 0, + kind: 'Unknown', + node: { value: 'invalid tap after harness' }, + lexeme: 'invalid tap after harness' + } + ] + }, + { + input: `TAP version 13 + # nested diagnostic +# diagnostic comment`, + expected: [ + { + nesting: 0, + kind: 'VersionKeyword', + node: { version: '13' }, + lexeme: 'TAP version 13' + }, + { + nesting: 1, + kind: 'Comment', + node: { comment: 'nested diagnostic' }, + lexeme: ' # nested diagnostic' + }, + { + nesting: 0, + kind: 'Comment', + node: { comment: 'diagnostic comment' }, + lexeme: '# diagnostic comment' + } + ] + }, + { + input: `TAP version 13 + 1..5 +1..3 +2..2`, + expected: [ + { + nesting: 0, + kind: 'VersionKeyword', + node: { version: '13' }, + lexeme: 'TAP version 13' + }, + { + nesting: 1, + kind: 'PlanKeyword', + node: { start: '1', end: '5' }, + lexeme: ' 1..5' + }, + { + nesting: 0, + kind: 'PlanKeyword', + node: { start: '1', end: '3' }, + lexeme: '1..3' + }, + { + nesting: 0, + kind: 'PlanKeyword', + node: { start: '2', end: '2' }, + lexeme: '2..2' + } + ] + }, + { + input: `TAP version 13 +ok 1 - test +ok 2 - test # SKIP +not ok 3 - test # TODO reason`, + expected: [ + { + nesting: 0, + kind: 'VersionKeyword', + node: { version: '13' }, + lexeme: 'TAP version 13' + }, + { + nesting: 0, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '1', + description: 'test', + reason: '', + time: 0, + diagnostics: [] + }, + lexeme: 'ok 1 - test' + }, + { + nesting: 0, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: true }, + id: '2', + description: 'test', + reason: '', + time: 0, + diagnostics: [] + }, + lexeme: 'ok 2 - test # SKIP' + }, + { + nesting: 0, + kind: 'TestPointKeyword', + node: { + status: { fail: true, pass: false, todo: true, skip: false }, + id: '3', + description: 'test', + reason: 'reason', + time: 0, + diagnostics: [] + }, + lexeme: 'not ok 3 - test # TODO reason' + } + ] + }, + { + input: `TAP version 13 +# Subtest: test +ok 1 - test +ok 2 - test`, + expected: [ + { + nesting: 0, + kind: 'VersionKeyword', + node: { version: '13' }, + lexeme: 'TAP version 13' + }, + { + nesting: 0, + kind: 'SubTestPointKeyword', + node: { name: 'test' }, + lexeme: '# Subtest: test' + }, + { + nesting: 0, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '1', + description: 'test', + reason: '', + time: 0, + diagnostics: [] + }, + lexeme: 'ok 1 - test' + }, + { + nesting: 0, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '2', + description: 'test', + reason: '', + time: 0, + diagnostics: [] + }, + lexeme: 'ok 2 - test' + } + ] + }, + { + input: `TAP version 13 +# Subtest: test +ok 1 - test + --- + foo: bar + duration_ms: 0.0001 + prop: |- + multiple + lines + ...`, + expected: [ + { + nesting: 0, + kind: 'VersionKeyword', + node: { version: '13' }, + lexeme: 'TAP version 13' + }, + { + nesting: 0, + kind: 'SubTestPointKeyword', + node: { name: 'test' }, + lexeme: '# Subtest: test' + }, + { + nesting: 0, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '1', + description: 'test', + reason: '', + time: 0.0001, + diagnostics: [ + 'foo: bar', + 'duration_ms: 0.0001', + 'prop: |-', + ' multiple', + ' lines' + ] + }, + lexeme: 'ok 1 - test' + } + ] + }, + { + input: `TAP version 13 +# Subtest: test/fixtures/test-runner/index.test.js + # Subtest: this should pass + ok 1 - this should pass + --- + duration_ms: 0.0001 + ... + 1..1`, + expected: [ + { + nesting: 0, + kind: 'VersionKeyword', + node: { version: '13' }, + lexeme: 'TAP version 13' + }, + { + kind: 'SubTestPointKeyword', + lexeme: '# Subtest: test/fixtures/test-runner/index.test.js', + nesting: 0, + node: { + name: 'test/fixtures/test-runner/index.test.js' + } + }, + { + kind: 'SubTestPointKeyword', + lexeme: ' # Subtest: this should pass', + nesting: 1, + node: { + name: 'this should pass' + } + }, + { + kind: 'TestPointKeyword', + lexeme: ' ok 1 - this should pass', + nesting: 1, + node: { + description: 'this should pass', + diagnostics: ['duration_ms: 0.0001'], + id: '1', + reason: '', + status: { + fail: false, + pass: true, + skip: false, + todo: false + }, + time: 0.0001 + } + }, + { + kind: 'PlanKeyword', + lexeme: ' 1..1', + nesting: 1, + node: { + end: '1', + start: '1' + } + } + ] + }, + { + input: `TAP version 13 +# Subtest: test 1 +ok 1 - test 1 + --- + foo: bar + duration_ms: 1.00 + prop: |- + multiple + lines + ... +# Subtest: test 2 +ok 2 - test 2 + --- + duration_ms: 2.00 + ... +# Subtest: test 3 +ok 3 - test 3 + --- + foo: bar + duration_ms: 3.00 + prop: |- + multiple + lines + ...`, + expected: [ + { + nesting: 0, + kind: 'VersionKeyword', + node: { version: '13' }, + lexeme: 'TAP version 13' + }, + { + nesting: 0, + kind: 'SubTestPointKeyword', + node: { name: 'test 1' }, + lexeme: '# Subtest: test 1' + }, + { + nesting: 0, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '1', + description: 'test 1', + reason: '', + time: 1.0, + diagnostics: [ + 'foo: bar', + 'duration_ms: 1.00', + 'prop: |-', + ' multiple', + ' lines' + ] + }, + lexeme: 'ok 1 - test 1' + }, + { + nesting: 0, + kind: 'SubTestPointKeyword', + node: { name: 'test 2' }, + lexeme: '# Subtest: test 2' + }, + { + nesting: 0, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '2', + description: 'test 2', + reason: '', + time: 2.0, + diagnostics: ['duration_ms: 2.00'] + }, + lexeme: 'ok 2 - test 2' + }, + { + nesting: 0, + kind: 'SubTestPointKeyword', + node: { name: 'test 3' }, + lexeme: '# Subtest: test 3' + }, + { + nesting: 0, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '3', + description: 'test 3', + reason: '', + time: 3.0, + diagnostics: [ + 'foo: bar', + 'duration_ms: 3.00', + 'prop: |-', + ' multiple', + ' lines' + ] + }, + lexeme: 'ok 3 - test 3' + } + ] + }, + { + input: `TAP version 13 +# Subtest: test 1 +ok 1 - test 1 + --- + foo: bar + duration_ms: 1.00 + prop: |- + multiple + lines + ... + # Subtest: test 11 + ok 11 - test 11 + --- + duration_ms: 11.00 + ... + # Subtest: test 111 + ok 111 - test 111 + --- + foo: bar + duration_ms: 111.00 + prop: |- + multiple + lines + ...`, + expected: [ + { + nesting: 0, + kind: 'VersionKeyword', + node: { version: '13' }, + lexeme: 'TAP version 13' + }, + { + nesting: 0, + kind: 'SubTestPointKeyword', + node: { name: 'test 1' }, + lexeme: '# Subtest: test 1' + }, + { + nesting: 0, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '1', + description: 'test 1', + reason: '', + time: 1.0, + diagnostics: [ + 'foo: bar', + 'duration_ms: 1.00', + 'prop: |-', + ' multiple', + ' lines' + ] + }, + lexeme: 'ok 1 - test 1' + }, + { + nesting: 1, + kind: 'SubTestPointKeyword', + node: { name: 'test 11' }, + lexeme: ' # Subtest: test 11' + }, + { + nesting: 1, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '11', + description: 'test 11', + reason: '', + time: 11.0, + diagnostics: ['duration_ms: 11.00'] + }, + lexeme: ' ok 11 - test 11' + }, + { + nesting: 2, + kind: 'SubTestPointKeyword', + node: { name: 'test 111' }, + lexeme: ' # Subtest: test 111' + }, + { + nesting: 2, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '111', + description: 'test 111', + reason: '', + time: 111.0, + diagnostics: [ + 'foo: bar', + 'duration_ms: 111.00', + 'prop: |-', + ' multiple', + ' lines' + ] + }, + lexeme: ' ok 111 - test 111' + } + ] + } +]; + +(async () => { + for (const { input, expected } of cases) { + const parser = new TapParser() + parser.write(input) + parser.end() + const actual = await toArray.call(parser) + assert.deepStrictEqual( + actual, + expected.map((item) => ({ __proto__: null, ...item })) + ) + } +})().then(common.mustCall()); + +(async () => { + const expected = [ + { + kind: 'PlanKeyword', + node: { start: '1', end: '3' }, + nesting: 0, + lexeme: '1..3' + }, + { + nesting: 0, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '1', + description: 'Input file opened', + reason: '', + time: 0, + diagnostics: [] + }, + lexeme: 'ok 1 - Input file opened' + }, + { + kind: 'TestPointKeyword', + node: { + status: { fail: true, pass: false, todo: false, skip: false }, + id: '2', + description: '', + reason: '', + time: 0, + diagnostics: [] + }, + nesting: 0, + lexeme: 'not ok 2 ' + }, + { + kind: 'SubTestPointKeyword', + node: { name: 'foobar' }, + nesting: 1, + lexeme: ' # Subtest: foobar' + }, + { + __proto__: null, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: true, skip: false }, + id: '3', + description: '', + reason: '', + time: 0.0001, + diagnostics: [ + 'foo: bar', + 'duration_ms: 0.0001', + 'prop: |-', + ' foo', + ' bar' + ] + }, + nesting: 0, + lexeme: 'ok 3 # TODO' + } + ] + + const parser = new TapParser({ specs: TapChecker.TAP14 }) + parser.write('\n') + parser.write('1') + parser.write('.') + parser.write('.') + parser.write('3') + parser.write('\n') + parser.write('ok 1 ') + parser.write('- Input file opened\n') + parser.write('not') + parser.write(' ok') + parser.write(' 2 \n') + parser.write('\n') + parser.write(' # ') + parser.write('Subtest: foo') + parser.write('bar') + parser.write('\n') + parser.write('') + parser.write('ok') + parser.write(' 3 #') + parser.write(' TODO') + parser.write('\n') + parser.write(' ---\n') + parser.write(' foo: bar\n') + parser.write(' duration_ms: ') + parser.write(' 0.0001\n') + parser.write(' prop: |-\n') + parser.write(' foo\n') + parser.write(' bar\n') + parser.write(' ...\n') + parser.end() + const actual = await toArray.call(parser) + assert.deepStrictEqual( + actual, + expected.map((item) => ({ __proto__: null, ...item })) + ) +})().then(common.mustCall()) diff --git a/test/parallel/test-runner-tap-parser.js b/test/parallel/test-runner-tap-parser.js new file mode 100644 index 0000000..aa6fbb4 --- /dev/null +++ b/test/parallel/test-runner-tap-parser.js @@ -0,0 +1,1315 @@ +// https://github.com/nodejs/node/blob/f8ce9117b19702487eb600493d941f7876e00e01/test/parallel/test-runner-tap-parser.js +'use strict' +// Flags: --expose-internals + +/* eslint no-lone-blocks: 0 */ + +require('../common') +const assert = require('assert') + +const { TapParser } = require('#internal/test_runner/tap_parser') + +function TAPParser (input) { + const parser = new TapParser() + const ast = parser.parseSync(input) + return ast +} + +// Comment + +{ + const ast = TAPParser('# comment') + assert.deepStrictEqual(ast, [ + { + nesting: 0, + kind: 'Comment', + node: { comment: 'comment' }, + lexeme: '# comment' + } + ]) +} + +{ + const ast = TAPParser('#') + assert.deepStrictEqual(ast, [ + { + kind: 'Comment', + nesting: 0, + node: { + comment: '' + }, + lexeme: '#' + } + ]) +} + +{ + const ast = TAPParser('####') + assert.deepStrictEqual(ast, [ + { + nesting: 0, + kind: 'Comment', + node: { comment: '###' }, + lexeme: '####' + } + ]) +} + +// Empty input + +{ + const ast = TAPParser('') + assert.deepStrictEqual(ast, []) +} + +// TAP version + +{ + const ast = TAPParser('TAP version 14') + assert.deepStrictEqual(ast, [ + { + nesting: 0, + kind: 'VersionKeyword', + node: { version: '14' }, + lexeme: 'TAP version 14' + } + ]) +} + +{ + assert.throws(() => TAPParser('TAP version'), { + name: 'SyntaxError', + code: 'ERR_TAP_PARSER_ERROR', + message: + 'Expected a version number, received "version" (VersionKeyword) at line 1, column 5 (start 4, end 10)' + }) +} + +{ + assert.throws(() => TAPParser('TAP'), { + name: 'SyntaxError', + code: 'ERR_TAP_PARSER_ERROR', + message: + 'Expected "version" keyword, received "TAP" (TAPKeyword) at line 1, column 1 (start 0, end 2)' + }) +} + +// Test plan + +{ + const ast = TAPParser('1..5 # reason') + assert.deepStrictEqual(ast, [ + { + nesting: 0, + kind: 'PlanKeyword', + node: { start: '1', end: '5', reason: 'reason' }, + lexeme: '1..5 # reason' + } + ]) +} + +{ + const ast = TAPParser( + '1..5 # reason "\\ !"\\#$%&\'()*+,\\-./:;<=>?@[]^_`{|}~' + ) + assert.deepStrictEqual(ast, [ + { + nesting: 0, + kind: 'PlanKeyword', + node: { + start: '1', + end: '5', + reason: 'reason " !"\\#$%&\'()*+,-./:;<=>?@[]^_`{|}~' + }, + lexeme: '1..5 # reason "\\ !"\\#$%&\'()*+,\\-./:;<=>?@[]^_`{|}~' + } + ]) +} + +{ + assert.throws(() => TAPParser('1..'), { + name: 'SyntaxError', + code: 'ERR_TAP_PARSER_ERROR', + message: + 'Expected a plan end count, received "" (EOL) at line 1, column 4 (start 3, end 3)' + }) +} + +{ + assert.throws(() => TAPParser('1..abc'), { + name: 'SyntaxError', + code: 'ERR_TAP_PARSER_ERROR', + message: + 'Expected ".." symbol, received "..abc" (Literal) at line 1, column 2 (start 1, end 5)' + }) +} + +{ + assert.throws(() => TAPParser('1..-1'), { + name: 'SyntaxError', + code: 'ERR_TAP_PARSER_ERROR', + message: + 'Expected a plan end count, received "-" (Dash) at line 1, column 4 (start 3, end 3)' + }) +} + +{ + assert.throws(() => TAPParser('1.1'), { + name: 'SyntaxError', + code: 'ERR_TAP_PARSER_ERROR', + message: + 'Expected ".." symbol, received "." (Literal) at line 1, column 2 (start 1, end 1)' + }) +} + +// Test point + +{ + const ast = TAPParser('ok') + assert.deepStrictEqual(ast, [ + { + nesting: 0, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '', + description: '', + reason: '', + time: 0, + diagnostics: [] + }, + lexeme: 'ok' + } + ]) +} + +{ + const ast = TAPParser('not ok') + assert.deepStrictEqual(ast, [ + { + nesting: 0, + kind: 'TestPointKeyword', + node: { + status: { fail: true, pass: false, todo: false, skip: false }, + id: '', + description: '', + reason: '', + time: 0, + diagnostics: [] + }, + lexeme: 'not ok' + } + ]) +} + +{ + const ast = TAPParser('ok 1') + assert.deepStrictEqual(ast, [ + { + nesting: 0, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '1', + description: '', + reason: '', + time: 0, + diagnostics: [] + }, + lexeme: 'ok 1' + } + ]) +} + +{ + const ast = TAPParser(` +ok 111 +not ok 222 +`) + assert.deepStrictEqual(ast, [ + { + nesting: 0, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '111', + description: '', + reason: '', + time: 0, + diagnostics: [] + }, + lexeme: 'ok 111' + }, + { + nesting: 0, + kind: 'TestPointKeyword', + node: { + status: { fail: true, pass: false, todo: false, skip: false }, + id: '222', + description: '', + reason: '', + time: 0, + diagnostics: [] + }, + lexeme: 'not ok 222' + } + ]) +} + +{ + // Nested tests + const ast = TAPParser(` +ok 1 - parent + ok 2 - child +`) + assert.deepStrictEqual(ast, [ + { + nesting: 0, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '1', + description: 'parent', + reason: '', + time: 0, + diagnostics: [] + }, + lexeme: 'ok 1 - parent' + }, + { + nesting: 1, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '2', + description: 'child', + reason: '', + time: 0, + diagnostics: [] + }, + lexeme: ' ok 2 - child' + } + ]) +} + +{ + const ast = TAPParser(` +# Subtest: nested1 + ok 1 + + # Subtest: nested2 + ok 2 - nested2 + + # Subtest: nested3 + ok 3 - nested3 + + # Subtest: nested4 + ok 4 - nested4 + +ok 1 - nested1 +`) + assert.deepStrictEqual(ast, [ + { + nesting: 0, + kind: 'SubTestPointKeyword', + node: { name: 'nested1' }, + lexeme: '# Subtest: nested1' + }, + { + nesting: 1, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '1', + description: '', + reason: '', + time: 0, + diagnostics: [] + }, + lexeme: ' ok 1' + }, + { + nesting: 1, + kind: 'SubTestPointKeyword', + node: { name: 'nested2' }, + lexeme: ' # Subtest: nested2' + }, + { + nesting: 1, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '2', + description: 'nested2', + reason: '', + time: 0, + diagnostics: [] + }, + lexeme: ' ok 2 - nested2' + }, + { + nesting: 1, + kind: 'SubTestPointKeyword', + node: { name: 'nested3' }, + lexeme: ' # Subtest: nested3' + }, + { + nesting: 1, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '3', + description: 'nested3', + reason: '', + time: 0, + diagnostics: [] + }, + lexeme: ' ok 3 - nested3' + }, + { + nesting: 1, + kind: 'SubTestPointKeyword', + node: { name: 'nested4' }, + lexeme: ' # Subtest: nested4' + }, + { + nesting: 1, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '4', + description: 'nested4', + reason: '', + time: 0, + diagnostics: [] + }, + lexeme: ' ok 4 - nested4' + }, + { + nesting: 0, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '1', + description: 'nested1', + reason: '', + time: 0, + diagnostics: [] + }, + lexeme: 'ok 1 - nested1' + } + ]) +} + +// Nested tests as comment + +{ + const ast = TAPParser(` +# Subtest: nested1 + ok 1 - test nested1 + + # Subtest: nested2 + ok 2 - test nested2 + + ok 3 - nested2 + +ok 4 - nested1 +`) + assert.deepStrictEqual(ast, [ + { + nesting: 0, + kind: 'SubTestPointKeyword', + node: { name: 'nested1' }, + lexeme: '# Subtest: nested1' + }, + { + nesting: 1, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '1', + description: 'test nested1', + reason: '', + time: 0, + diagnostics: [] + }, + lexeme: ' ok 1 - test nested1' + }, + { + nesting: 1, + kind: 'SubTestPointKeyword', + node: { name: 'nested2' }, + lexeme: ' # Subtest: nested2' + }, + { + nesting: 2, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '2', + description: 'test nested2', + reason: '', + time: 0, + diagnostics: [] + }, + lexeme: ' ok 2 - test nested2' + }, + { + nesting: 1, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '3', + description: 'nested2', + reason: '', + time: 0, + diagnostics: [] + }, + lexeme: ' ok 3 - nested2' + }, + { + nesting: 0, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '4', + description: 'nested1', + reason: '', + time: 0, + diagnostics: [] + }, + lexeme: 'ok 4 - nested1' + } + ]) +} + +// Multiple nested tests as comment + +{ + const ast = TAPParser(` +# Subtest: nested1 + ok 1 - test nested1 + + # Subtest: nested2a + ok 2 - test nested2a + + ok 3 - nested2a + + # Subtest: nested2b + ok 4 - test nested2b + + ok 5 - nested2b + +ok 6 - nested1 +`) + assert.deepStrictEqual(ast, [ + { + nesting: 0, + kind: 'SubTestPointKeyword', + node: { name: 'nested1' }, + lexeme: '# Subtest: nested1' + }, + { + nesting: 1, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '1', + description: 'test nested1', + reason: '', + time: 0, + diagnostics: [] + }, + lexeme: ' ok 1 - test nested1' + }, + { + nesting: 1, + kind: 'SubTestPointKeyword', + node: { name: 'nested2a' }, + lexeme: ' # Subtest: nested2a' + }, + { + nesting: 2, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '2', + description: 'test nested2a', + reason: '', + time: 0, + diagnostics: [] + }, + lexeme: ' ok 2 - test nested2a' + }, + { + nesting: 1, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '3', + description: 'nested2a', + reason: '', + time: 0, + diagnostics: [] + }, + lexeme: ' ok 3 - nested2a' + }, + { + nesting: 1, + kind: 'SubTestPointKeyword', + node: { name: 'nested2b' }, + lexeme: ' # Subtest: nested2b' + }, + { + nesting: 2, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '4', + description: 'test nested2b', + reason: '', + time: 0, + diagnostics: [] + }, + lexeme: ' ok 4 - test nested2b' + }, + { + nesting: 1, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '5', + description: 'nested2b', + reason: '', + time: 0, + diagnostics: [] + }, + lexeme: ' ok 5 - nested2b' + }, + { + nesting: 0, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '6', + description: 'nested1', + reason: '', + time: 0, + diagnostics: [] + }, + lexeme: 'ok 6 - nested1' + } + ]) +} + +{ + const ast = TAPParser('ok 1 description') + assert.deepStrictEqual(ast, [ + { + nesting: 0, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '1', + description: 'description', + reason: '', + time: 0, + diagnostics: [] + }, + lexeme: 'ok 1 description' + } + ]) +} + +{ + const ast = TAPParser('ok 1 - description') + assert.deepStrictEqual(ast, [ + { + nesting: 0, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '1', + description: 'description', + reason: '', + time: 0, + diagnostics: [] + }, + lexeme: 'ok 1 - description' + } + ]) +} + +{ + const ast = TAPParser('ok 1 - description # todo') + assert.deepStrictEqual(ast, [ + { + nesting: 0, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: true, skip: false }, + id: '1', + description: 'description', + reason: '', + time: 0, + diagnostics: [] + }, + lexeme: 'ok 1 - description # todo' + } + ]) +} + +{ + const ast = TAPParser('ok 1 - description \\# todo') + assert.deepStrictEqual(ast, [ + { + nesting: 0, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '1', + description: 'description # todo', + reason: '', + time: 0, + diagnostics: [] + }, + lexeme: 'ok 1 - description \\# todo' + } + ]) +} + +{ + const ast = TAPParser('ok 1 - description \\ # todo') + assert.deepStrictEqual(ast, [ + { + nesting: 0, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: true, skip: false }, + id: '1', + description: 'description', + reason: '', + time: 0, + diagnostics: [] + }, + lexeme: 'ok 1 - description \\ # todo' + } + ]) +} + +{ + const ast = TAPParser( + 'ok 1 description \\# \\\\ world # TODO escape \\# characters with \\\\' + ) + assert.deepStrictEqual(ast, [ + { + nesting: 0, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: true, skip: false }, + id: '1', + description: 'description # \\ world', + reason: 'escape # characters with \\', + time: 0, + diagnostics: [] + }, + lexeme: + 'ok 1 description \\# \\\\ world # TODO escape \\# characters with \\\\' + } + ]) +} + +{ + const ast = TAPParser('ok 1 - description # ##') + assert.deepStrictEqual(ast, [ + { + nesting: 0, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '1', + description: 'description', + reason: '##', + time: 0, + diagnostics: [] + }, + lexeme: 'ok 1 - description # ##' + } + ]) +} + +{ + const ast = TAPParser( + 'ok 2 not skipped: https://example.com/page.html#skip is a url' + ) + assert.deepStrictEqual(ast, [ + { + nesting: 0, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '2', + description: 'not skipped: https://example.com/page.html#skip is a url', + reason: '', + time: 0, + diagnostics: [] + }, + lexeme: 'ok 2 not skipped: https://example.com/page.html#skip is a url' + } + ]) +} + +{ + const ast = TAPParser('ok 3 - #SkIp case insensitive, so this is skipped') + assert.deepStrictEqual(ast, [ + { + nesting: 0, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: true }, + id: '3', + description: '', + reason: 'case insensitive, so this is skipped', + time: 0, + diagnostics: [] + }, + lexeme: 'ok 3 - #SkIp case insensitive, so this is skipped' + } + ]) +} + +{ + const ast = TAPParser('ok ok ok') + assert.deepStrictEqual(ast, [ + { + nesting: 0, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '', + description: 'ok ok', + reason: '', + time: 0, + diagnostics: [] + }, + lexeme: 'ok ok ok' + } + ]) +} + +{ + const ast = TAPParser('ok not ok') + assert.deepStrictEqual(ast, [ + { + nesting: 0, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '', + description: 'not ok', + reason: '', + time: 0, + diagnostics: [] + }, + lexeme: 'ok not ok' + } + ]) +} + +{ + const ast = TAPParser('ok 1..1') + assert.deepStrictEqual(ast, [ + { + nesting: 0, + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '1', + description: '', + reason: '', + time: 0, + diagnostics: [] + }, + lexeme: 'ok 1..1' + } + ]) +} + +// Diagnostic + +{ + // Note the leading 2 valid spaces + const ast = TAPParser(` + --- + message: 'description' + property: 'value' + ... +`) + assert.deepStrictEqual(ast, [ + { + nesting: 0, + kind: 'YamlEndKeyword', + node: { + diagnostics: ["message: 'description'", "property: 'value'"] + }, + lexeme: ' ...' + } + ]) +} + +{ + // Note the leading 2 valid spaces + const ast = TAPParser(` + --- + message: "Board layout" + severity: comment + dump: + board: + - ' 16G 05C ' + - ' G N C C C G ' + - ' G C + ' + - '10C 01G 03C ' + - 'R N G G A G C C C ' + - ' R G C + ' + - ' 01G 17C 00C ' + - ' G A G G N R R N R ' + - ' G R G ' + ... +`) + assert.deepStrictEqual(ast, [ + { + nesting: 0, + kind: 'YamlEndKeyword', + node: { + diagnostics: [ + 'message: "Board layout"', + 'severity: comment', + 'dump:', + ' board:', + " - ' 16G 05C '", + " - ' G N C C C G '", + " - ' G C + '", + " - '10C 01G 03C '", + " - 'R N G G A G C C C '", + " - ' R G C + '", + " - ' 01G 17C 00C '", + " - ' G A G G N R R N R '", + " - ' G R G '" + ] + }, + lexeme: ' ...' + } + ]) +} + +{ + const ast = TAPParser(` + --- + ... +`) + assert.deepStrictEqual(ast, [ + { + nesting: 0, + kind: 'YamlEndKeyword', + node: { diagnostics: [] }, + lexeme: ' ...' + } + ]) +} + +{ + assert.throws( + () => + TAPParser( + ` + message: 'description' + property: 'value' + ...` + ), + { + name: 'SyntaxError', + code: 'ERR_TAP_PARSER_ERROR', + message: + 'Unexpected YAML end marker, received "..." (YamlEndKeyword) at line 4, column 3 (start 48, end 50)' + } + ) +} + +{ + assert.throws( + () => + TAPParser( + ` + --- + message: 'description' + property: 'value'` + ), + { + name: 'SyntaxError', + code: 'ERR_TAP_PARSER_ERROR', + message: + 'Expected end of YAML block, received "\'value\'" (Literal) at line 4, column 13 (start 44, end 50)' + } + ) +} + +{ + assert.throws( + () => + // Note the leading 3 spaces before --- + TAPParser( + ` + --- + message: 'description' + property: 'value' + ...` + ), + { + name: 'SyntaxError', + code: 'ERR_TAP_PARSER_ERROR', + message: + 'Expected valid YAML indentation (2 spaces), received " " (Whitespace) at line 2, column 3 (start 3, end 3)' + } + ) +} + +{ + assert.throws( + () => + // Note the leading 5 spaces before --- + // This is a special case because the YAML block is indented by 1 space + // the extra 4 spaces are those of the subtest nesting level. + // However, the remaining content of the YAML block is indented by 2 spaces + // making it belong to the parent level. + TAPParser( + ` + --- + message: 'description' + property: 'value' + ... + ` + ), + { + name: 'SyntaxError', + code: 'ERR_TAP_PARSER_ERROR', + message: + 'Expected end of YAML block, received "\'value\'" (Literal) at line 4, column 13 (start 47, end 53)' + } + ) +} + +{ + assert.throws( + () => + // Note the leading 4 spaces before --- + TAPParser( + ` + --- + message: 'description' + property: 'value' + ... + ` + ), + { + name: 'SyntaxError', + code: 'ERR_TAP_PARSER_ERROR', + message: + 'Expected a valid token, received "---" (YamlStartKeyword) at line 2, column 5 (start 5, end 7)' + } + ) +} + +{ + assert.throws( + () => + // Note the leading 4 spaces before ... + TAPParser( + ` + --- + message: 'description' + property: 'value' + ... + ` + ), + { + name: 'SyntaxError', + code: 'ERR_TAP_PARSER_ERROR', + message: + 'Expected end of YAML block, received " " (Whitespace) at line 6, column 2 (start 61, end 61)' + } + ) +} + +// Pragma + +{ + const ast = TAPParser('pragma +strict, -warnings') + assert.deepStrictEqual(ast, [ + { + nesting: 0, + kind: 'PragmaKeyword', + node: { + pragmas: { strict: true, warnings: false } + }, + lexeme: 'pragma +strict, -warnings' + } + ]) +} + +// Bail out + +{ + const ast = TAPParser('Bail out! Error') + assert.deepStrictEqual(ast, [ + { + nesting: 0, + kind: 'BailOutKeyword', + node: { bailout: true, reason: 'Error' }, + lexeme: 'Bail out! Error' + } + ]) +} + +// Non-recognized + +{ + assert.throws(() => TAPParser('abc'), { + name: 'SyntaxError', + code: 'ERR_TAP_PARSER_ERROR', + message: + 'Expected a valid token, received "abc" (Literal) at line 1, column 1 (start 0, end 2)' + }) +} + +{ + assert.throws(() => TAPParser(' abc'), { + name: 'SyntaxError', + code: 'ERR_TAP_PARSER_ERROR', + message: + 'Expected a valid token, received "abc" (Literal) at line 1, column 5 (start 4, end 6)' + }) +} + +// TAP document (with diagnostics) + +{ + const ast = TAPParser(` +# Comment on version 13 +# Another comment on version 13 + +TAP version 13 + +# Subtest: /test.js + # Subtest: level 0a + # Subtest: level 1a + # Comment test point 1a + # Comment test point 1aa + ok 1 - level 1a + --- + duration_ms: 1.676996 + ... + # Comment plan 1a + # Comment plan 1aa + 1..1 + # Comment closing test point 1a + # Comment closing test point 1aa + not ok 1 - level 1a + --- + duration_ms: 0.122839 + failureType: 'testCodeFailure' + error: 'level 0b error' + code: 'ERR_TEST_FAILURE' + stack: |- + TestContext. (/test.js:23:9) + ... + 1..1 + not ok 1 - level 0a + --- + duration_ms: 84.920487 + failureType: 'subtestsFailed' + exitCode: 1 + error: '3 subtests failed' + code: 'ERR_TEST_FAILURE' + ... + # Comment plan 0a + # Comment plan 0aa + 1..1 + +# Comment closing test point 0a + +# Comment closing test point 0aa + +not ok 1 - /test.js +# tests 1 +# pass 0 +# fail 1 +# cancelled 0 +# skipped 0 +# todo 0 +# duration_ms 87.077507 +`) + + assert.deepStrictEqual(ast, [ + { + kind: 'VersionKeyword', + node: { version: '13' }, + nesting: 0, + comments: ['Comment on version 13', 'Another comment on version 13'], + lexeme: 'TAP version 13' + }, + { + kind: 'SubTestPointKeyword', + node: { name: '/test.js' }, + nesting: 0, + lexeme: '# Subtest: /test.js' + }, + { + kind: 'SubTestPointKeyword', + node: { name: 'level 0a' }, + nesting: 1, + lexeme: ' # Subtest: level 0a' + }, + { + kind: 'SubTestPointKeyword', + node: { name: 'level 1a' }, + nesting: 2, + lexeme: ' # Subtest: level 1a' + }, + { + kind: 'TestPointKeyword', + node: { + status: { fail: false, pass: true, todo: false, skip: false }, + id: '1', + description: 'level 1a', + reason: '', + time: 1.676996, + diagnostics: ['duration_ms: 1.676996'] + }, + nesting: 3, + comments: ['Comment test point 1a', 'Comment test point 1aa'], + lexeme: ' ok 1 - level 1a' + }, + { + kind: 'PlanKeyword', + node: { start: '1', end: '1' }, + nesting: 3, + comments: ['Comment plan 1a', 'Comment plan 1aa'], + lexeme: ' 1..1' + }, + { + kind: 'TestPointKeyword', + node: { + status: { fail: true, pass: false, todo: false, skip: false }, + id: '1', + description: 'level 1a', + reason: '', + time: 0.122839, + diagnostics: [ + 'duration_ms: 0.122839', + "failureType: 'testCodeFailure'", + "error: 'level 0b error'", + "code: 'ERR_TEST_FAILURE'", + 'stack: |-', + ' TestContext. (/test.js:23:9)' + ] + }, + nesting: 2, + comments: [ + 'Comment closing test point 1a', + 'Comment closing test point 1aa' + ], + lexeme: ' not ok 1 - level 1a' + }, + { + kind: 'PlanKeyword', + node: { start: '1', end: '1' }, + nesting: 2, + lexeme: ' 1..1' + }, + { + kind: 'TestPointKeyword', + node: { + status: { fail: true, pass: false, todo: false, skip: false }, + id: '1', + description: 'level 0a', + reason: '', + time: 84.920487, + diagnostics: [ + 'duration_ms: 84.920487', + "failureType: 'subtestsFailed'", + 'exitCode: 1', + "error: '3 subtests failed'", + "code: 'ERR_TEST_FAILURE'" + ] + }, + nesting: 1, + lexeme: ' not ok 1 - level 0a' + }, + { + kind: 'PlanKeyword', + node: { start: '1', end: '1' }, + nesting: 1, + comments: ['Comment plan 0a', 'Comment plan 0aa'], + lexeme: ' 1..1' + }, + { + kind: 'TestPointKeyword', + node: { + status: { fail: true, pass: false, todo: false, skip: false }, + id: '1', + description: '/test.js', + reason: '', + time: 0, + diagnostics: [] + }, + nesting: 0, + comments: [ + 'Comment closing test point 0a', + 'Comment closing test point 0aa' + ], + lexeme: 'not ok 1 - /test.js' + }, + { + kind: 'Comment', + node: { comment: 'tests 1' }, + nesting: 0, + lexeme: '# tests 1' + }, + { + kind: 'Comment', + node: { comment: 'pass 0' }, + nesting: 0, + lexeme: '# pass 0' + }, + { + kind: 'Comment', + node: { comment: 'fail 1' }, + nesting: 0, + lexeme: '# fail 1' + }, + { + kind: 'Comment', + node: { comment: 'cancelled 0' }, + nesting: 0, + lexeme: '# cancelled 0' + }, + { + kind: 'Comment', + node: { comment: 'skipped 0' }, + nesting: 0, + lexeme: '# skipped 0' + }, + { + kind: 'Comment', + node: { comment: 'todo 0' }, + nesting: 0, + lexeme: '# todo 0' + }, + { + kind: 'Comment', + node: { comment: 'duration_ms 87.077507' }, + nesting: 0, + lexeme: '# duration_ms 87.077507' + } + ]) +}