mirror of
https://github.com/nodejs/node.git
synced 2025-05-01 08:42:45 +00:00

This commit refactors the internals of snapshot tests to get the name of the test file from the test context instead of passing it to the SnapshotManager constructor. This is prep work for supporting running test files in the test runner process. PR-URL: https://github.com/nodejs/node/pull/53853 Reviewed-By: Moshe Atlow <moshe@atlow.co.il> Reviewed-By: Chemi Atlow <chemi@atlow.co.il> Reviewed-By: Benjamin Gruenbaum <benjamingr@gmail.com>
1266 lines
34 KiB
JavaScript
1266 lines
34 KiB
JavaScript
'use strict';
|
|
const {
|
|
ArrayPrototypePush,
|
|
ArrayPrototypePushApply,
|
|
ArrayPrototypeReduce,
|
|
ArrayPrototypeShift,
|
|
ArrayPrototypeSlice,
|
|
ArrayPrototypeSome,
|
|
ArrayPrototypeSplice,
|
|
ArrayPrototypeUnshift,
|
|
FunctionPrototype,
|
|
MathMax,
|
|
Number,
|
|
ObjectDefineProperty,
|
|
ObjectSeal,
|
|
PromisePrototypeThen,
|
|
PromiseResolve,
|
|
ReflectApply,
|
|
RegExpPrototypeExec,
|
|
SafeMap,
|
|
SafePromiseAll,
|
|
SafePromisePrototypeFinally,
|
|
SafePromiseRace,
|
|
SafeSet,
|
|
StringPrototypeStartsWith,
|
|
StringPrototypeTrim,
|
|
Symbol,
|
|
SymbolDispose,
|
|
} = primordials;
|
|
const { getCallerLocation } = internalBinding('util');
|
|
const { addAbortListener } = require('internal/events/abort_listener');
|
|
const { queueMicrotask } = require('internal/process/task_queues');
|
|
const { AsyncResource } = require('async_hooks');
|
|
const { AbortController } = require('internal/abort_controller');
|
|
const {
|
|
AbortError,
|
|
codes: {
|
|
ERR_INVALID_ARG_TYPE,
|
|
ERR_TEST_FAILURE,
|
|
},
|
|
} = require('internal/errors');
|
|
const { MockTracker } = require('internal/test_runner/mock/mock');
|
|
const { TestsStream } = require('internal/test_runner/tests_stream');
|
|
const {
|
|
createDeferredCallback,
|
|
countCompletedTest,
|
|
isTestFailureError,
|
|
parseCommandLine,
|
|
} = require('internal/test_runner/utils');
|
|
const {
|
|
createDeferredPromise,
|
|
kEmptyObject,
|
|
once: runOnce,
|
|
} = require('internal/util');
|
|
const { isPromise } = require('internal/util/types');
|
|
const {
|
|
validateAbortSignal,
|
|
validateNumber,
|
|
validateOneOf,
|
|
validateUint32,
|
|
} = require('internal/validators');
|
|
const { setTimeout } = require('timers');
|
|
const { TIMEOUT_MAX } = require('internal/timers');
|
|
const { fileURLToPath } = require('internal/url');
|
|
const { availableParallelism } = require('os');
|
|
const { bigint: hrtime } = process.hrtime;
|
|
const kCallbackAndPromisePresent = 'callbackAndPromisePresent';
|
|
const kCancelledByParent = 'cancelledByParent';
|
|
const kAborted = 'testAborted';
|
|
const kParentAlreadyFinished = 'parentAlreadyFinished';
|
|
const kSubtestsFailed = 'subtestsFailed';
|
|
const kTestCodeFailure = 'testCodeFailure';
|
|
const kTestTimeoutFailure = 'testTimeoutFailure';
|
|
const kHookFailure = 'hookFailed';
|
|
const kDefaultTimeout = null;
|
|
const noop = FunctionPrototype;
|
|
const kShouldAbort = Symbol('kShouldAbort');
|
|
const kFilename = process.argv?.[1];
|
|
const kHookNames = ObjectSeal(['before', 'after', 'beforeEach', 'afterEach']);
|
|
const kUnwrapErrors = new SafeSet()
|
|
.add(kTestCodeFailure).add(kHookFailure)
|
|
.add('uncaughtException').add('unhandledRejection');
|
|
const {
|
|
forceExit,
|
|
sourceMaps,
|
|
testNamePatterns,
|
|
testSkipPatterns,
|
|
testOnlyFlag,
|
|
updateSnapshots,
|
|
} = parseCommandLine();
|
|
let kResistStopPropagation;
|
|
let assertObj;
|
|
let findSourceMap;
|
|
let noopTestStream;
|
|
|
|
const kRunOnceOptions = { __proto__: null, preserveReturnValue: true };
|
|
|
|
function lazyFindSourceMap(file) {
|
|
if (findSourceMap === undefined) {
|
|
({ findSourceMap } = require('internal/source_map/source_map_cache'));
|
|
}
|
|
|
|
return findSourceMap(file);
|
|
}
|
|
|
|
function lazyAssertObject(harness) {
|
|
if (assertObj === undefined) {
|
|
assertObj = new SafeMap();
|
|
const assert = require('assert');
|
|
const methodsToCopy = [
|
|
'deepEqual',
|
|
'deepStrictEqual',
|
|
'doesNotMatch',
|
|
'doesNotReject',
|
|
'doesNotThrow',
|
|
'equal',
|
|
'fail',
|
|
'ifError',
|
|
'match',
|
|
'notDeepEqual',
|
|
'notDeepStrictEqual',
|
|
'notEqual',
|
|
'notStrictEqual',
|
|
'ok',
|
|
'rejects',
|
|
'strictEqual',
|
|
'throws',
|
|
];
|
|
for (let i = 0; i < methodsToCopy.length; i++) {
|
|
assertObj.set(methodsToCopy[i], assert[methodsToCopy[i]]);
|
|
}
|
|
|
|
const { getOptionValue } = require('internal/options');
|
|
if (getOptionValue('--experimental-test-snapshots')) {
|
|
const { SnapshotManager } = require('internal/test_runner/snapshot');
|
|
harness.snapshotManager = new SnapshotManager(updateSnapshots);
|
|
assertObj.set('snapshot', harness.snapshotManager.createAssert());
|
|
}
|
|
}
|
|
return assertObj;
|
|
}
|
|
|
|
function stopTest(timeout, signal) {
|
|
const deferred = createDeferredPromise();
|
|
const abortListener = addAbortListener(signal, deferred.resolve);
|
|
let timer;
|
|
let disposeFunction;
|
|
|
|
if (timeout === kDefaultTimeout) {
|
|
disposeFunction = abortListener[SymbolDispose];
|
|
} else {
|
|
timer = setTimeout(() => deferred.resolve(), timeout);
|
|
timer.unref();
|
|
|
|
ObjectDefineProperty(deferred, 'promise', {
|
|
__proto__: null,
|
|
configurable: true,
|
|
writable: true,
|
|
value: PromisePrototypeThen(deferred.promise, () => {
|
|
throw new ERR_TEST_FAILURE(
|
|
`test timed out after ${timeout}ms`,
|
|
kTestTimeoutFailure,
|
|
);
|
|
}),
|
|
});
|
|
|
|
disposeFunction = () => {
|
|
abortListener[SymbolDispose]();
|
|
timer[SymbolDispose]();
|
|
};
|
|
}
|
|
|
|
ObjectDefineProperty(deferred.promise, SymbolDispose, {
|
|
__proto__: null,
|
|
configurable: true,
|
|
writable: true,
|
|
value: disposeFunction,
|
|
});
|
|
return deferred.promise;
|
|
}
|
|
|
|
function testMatchesPattern(test, patterns) {
|
|
const matchesByNameOrParent = ArrayPrototypeSome(patterns, (re) =>
|
|
RegExpPrototypeExec(re, test.name) !== null,
|
|
) || (test.parent && testMatchesPattern(test.parent, patterns));
|
|
if (matchesByNameOrParent) return true;
|
|
|
|
const testNameWithAncestors = StringPrototypeTrim(test.getTestNameWithAncestors());
|
|
|
|
return ArrayPrototypeSome(patterns, (re) =>
|
|
RegExpPrototypeExec(re, testNameWithAncestors) !== null,
|
|
);
|
|
}
|
|
|
|
class TestPlan {
|
|
constructor(count) {
|
|
validateUint32(count, 'count');
|
|
this.expected = count;
|
|
this.actual = 0;
|
|
}
|
|
|
|
check() {
|
|
if (this.actual !== this.expected) {
|
|
throw new ERR_TEST_FAILURE(
|
|
`plan expected ${this.expected} assertions but received ${this.actual}`,
|
|
kTestCodeFailure,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
class TestContext {
|
|
#assert;
|
|
#test;
|
|
|
|
constructor(test) {
|
|
this.#test = test;
|
|
}
|
|
|
|
get signal() {
|
|
return this.#test.signal;
|
|
}
|
|
|
|
get name() {
|
|
return this.#test.name;
|
|
}
|
|
|
|
get filePath() {
|
|
return this.#test.entryFile;
|
|
}
|
|
|
|
get fullName() {
|
|
return getFullName(this.#test);
|
|
}
|
|
|
|
get error() {
|
|
return this.#test.error;
|
|
}
|
|
|
|
get passed() {
|
|
return this.#test.passed;
|
|
}
|
|
|
|
diagnostic(message) {
|
|
this.#test.diagnostic(message);
|
|
}
|
|
|
|
plan(count) {
|
|
if (this.#test.plan !== null) {
|
|
throw new ERR_TEST_FAILURE(
|
|
'cannot set plan more than once',
|
|
kTestCodeFailure,
|
|
);
|
|
}
|
|
|
|
this.#test.plan = new TestPlan(count);
|
|
}
|
|
|
|
get assert() {
|
|
if (this.#assert === undefined) {
|
|
const { plan } = this.#test;
|
|
const map = lazyAssertObject(this.#test.root.harness);
|
|
const assert = { __proto__: null };
|
|
|
|
this.#assert = assert;
|
|
map.forEach((method, name) => {
|
|
assert[name] = (...args) => {
|
|
if (plan !== null) {
|
|
plan.actual++;
|
|
}
|
|
return ReflectApply(method, this, args);
|
|
};
|
|
});
|
|
}
|
|
return this.#assert;
|
|
}
|
|
|
|
get mock() {
|
|
this.#test.mock ??= new MockTracker();
|
|
return this.#test.mock;
|
|
}
|
|
|
|
runOnly(value) {
|
|
this.#test.runOnlySubtests = !!value;
|
|
}
|
|
|
|
skip(message) {
|
|
this.#test.skip(message);
|
|
}
|
|
|
|
todo(message) {
|
|
this.#test.todo(message);
|
|
}
|
|
|
|
test(name, options, fn) {
|
|
const overrides = {
|
|
__proto__: null,
|
|
loc: getCallerLocation(),
|
|
};
|
|
|
|
const { plan } = this.#test;
|
|
if (plan !== null) {
|
|
plan.actual++;
|
|
}
|
|
|
|
const subtest = this.#test.createSubtest(
|
|
// eslint-disable-next-line no-use-before-define
|
|
Test, name, options, fn, overrides,
|
|
);
|
|
|
|
return subtest.start();
|
|
}
|
|
|
|
before(fn, options) {
|
|
this.#test
|
|
.createHook('before', fn, { __proto__: null, ...options, hookType: 'before', loc: getCallerLocation() });
|
|
}
|
|
|
|
after(fn, options) {
|
|
this.#test
|
|
.createHook('after', fn, { __proto__: null, ...options, hookType: 'after', loc: getCallerLocation() });
|
|
}
|
|
|
|
beforeEach(fn, options) {
|
|
this.#test
|
|
.createHook('beforeEach', fn, { __proto__: null, ...options, hookType: 'beforeEach', loc: getCallerLocation() });
|
|
}
|
|
|
|
afterEach(fn, options) {
|
|
this.#test
|
|
.createHook('afterEach', fn, { __proto__: null, ...options, hookType: 'afterEach', loc: getCallerLocation() });
|
|
}
|
|
}
|
|
|
|
class SuiteContext {
|
|
#suite;
|
|
|
|
constructor(suite) {
|
|
this.#suite = suite;
|
|
}
|
|
|
|
get signal() {
|
|
return this.#suite.signal;
|
|
}
|
|
|
|
get name() {
|
|
return this.#suite.name;
|
|
}
|
|
|
|
get filePath() {
|
|
return this.#suite.entryFile;
|
|
}
|
|
|
|
get fullName() {
|
|
return getFullName(this.#suite);
|
|
}
|
|
}
|
|
|
|
class Test extends AsyncResource {
|
|
abortController;
|
|
outerSignal;
|
|
#reportedSubtest;
|
|
|
|
constructor(options) {
|
|
super('Test');
|
|
|
|
let { fn, name, parent } = options;
|
|
const { concurrency, entryFile, loc, only, timeout, todo, skip, signal, plan } = options;
|
|
|
|
if (typeof fn !== 'function') {
|
|
fn = noop;
|
|
}
|
|
|
|
if (typeof name !== 'string' || name === '') {
|
|
name = fn.name || '<anonymous>';
|
|
}
|
|
|
|
if (!(parent instanceof Test)) {
|
|
parent = null;
|
|
}
|
|
|
|
this.name = name;
|
|
this.parent = parent;
|
|
this.testNumber = 0;
|
|
this.outputSubtestCount = 0;
|
|
this.filteredSubtestCount = 0;
|
|
this.filtered = false;
|
|
|
|
if (parent === null) {
|
|
this.concurrency = 1;
|
|
this.nesting = 0;
|
|
this.only = testOnlyFlag;
|
|
this.reporter = new TestsStream();
|
|
this.runOnlySubtests = this.only;
|
|
this.childNumber = 0;
|
|
this.timeout = kDefaultTimeout;
|
|
this.entryFile = entryFile;
|
|
this.root = this;
|
|
this.hooks = {
|
|
__proto__: null,
|
|
before: [],
|
|
after: [],
|
|
beforeEach: [],
|
|
afterEach: [],
|
|
ownAfterEachCount: 0,
|
|
};
|
|
} else {
|
|
const nesting = parent.parent === null ? parent.nesting :
|
|
parent.nesting + 1;
|
|
|
|
this.concurrency = parent.concurrency;
|
|
this.nesting = nesting;
|
|
this.only = only ?? !parent.runOnlySubtests;
|
|
this.reporter = parent.reporter;
|
|
this.runOnlySubtests = !this.only;
|
|
this.childNumber = parent.subtests.length + 1;
|
|
this.timeout = parent.timeout;
|
|
this.entryFile = parent.entryFile;
|
|
this.root = parent.root;
|
|
this.hooks = {
|
|
__proto__: null,
|
|
before: [],
|
|
after: [],
|
|
beforeEach: ArrayPrototypeSlice(parent.hooks.beforeEach),
|
|
afterEach: ArrayPrototypeSlice(parent.hooks.afterEach),
|
|
ownAfterEachCount: 0,
|
|
};
|
|
|
|
if (this.willBeFiltered()) {
|
|
this.filtered = true;
|
|
this.parent.filteredSubtestCount++;
|
|
}
|
|
|
|
if (testOnlyFlag && only === false) {
|
|
fn = noop;
|
|
}
|
|
}
|
|
|
|
switch (typeof concurrency) {
|
|
case 'number':
|
|
validateUint32(concurrency, 'options.concurrency', true);
|
|
this.concurrency = concurrency;
|
|
break;
|
|
|
|
case 'boolean':
|
|
if (concurrency) {
|
|
this.concurrency = parent === null ?
|
|
MathMax(availableParallelism() - 1, 1) : Infinity;
|
|
} else {
|
|
this.concurrency = 1;
|
|
}
|
|
break;
|
|
|
|
default:
|
|
if (concurrency != null)
|
|
throw new ERR_INVALID_ARG_TYPE('options.concurrency', ['boolean', 'number'], concurrency);
|
|
}
|
|
|
|
if (timeout != null && timeout !== Infinity) {
|
|
validateNumber(timeout, 'options.timeout', 0, TIMEOUT_MAX);
|
|
this.timeout = timeout;
|
|
}
|
|
|
|
if (skip) {
|
|
fn = noop;
|
|
}
|
|
|
|
this.abortController = new AbortController();
|
|
this.outerSignal = signal;
|
|
this.signal = this.abortController.signal;
|
|
|
|
validateAbortSignal(signal, 'options.signal');
|
|
if (signal) {
|
|
kResistStopPropagation ??= require('internal/event_target').kResistStopPropagation;
|
|
}
|
|
|
|
this.outerSignal?.addEventListener(
|
|
'abort',
|
|
this.#abortHandler,
|
|
{ __proto__: null, [kResistStopPropagation]: true },
|
|
);
|
|
|
|
this.fn = fn;
|
|
this.harness = null; // Configured on the root test by the test harness.
|
|
this.mock = null;
|
|
this.plan = null;
|
|
this.expectedAssertions = plan;
|
|
this.cancelled = false;
|
|
this.skipped = skip !== undefined && skip !== false;
|
|
this.isTodo = todo !== undefined && todo !== false;
|
|
this.startTime = null;
|
|
this.endTime = null;
|
|
this.passed = false;
|
|
this.error = null;
|
|
this.diagnostics = [];
|
|
this.message = typeof skip === 'string' ? skip :
|
|
typeof todo === 'string' ? todo : null;
|
|
this.activeSubtests = 0;
|
|
this.pendingSubtests = [];
|
|
this.readySubtests = new SafeMap();
|
|
this.subtests = [];
|
|
this.waitingOn = 0;
|
|
this.finished = false;
|
|
|
|
if (!testOnlyFlag && (only || this.runOnlySubtests)) {
|
|
const warning =
|
|
"'only' and 'runOnly' require the --test-only command-line option.";
|
|
this.diagnostic(warning);
|
|
}
|
|
|
|
if (loc === undefined || kFilename === undefined) {
|
|
this.loc = undefined;
|
|
} else {
|
|
this.loc = {
|
|
__proto__: null,
|
|
line: loc[0],
|
|
column: loc[1],
|
|
file: loc[2],
|
|
};
|
|
|
|
if (sourceMaps === true) {
|
|
const map = lazyFindSourceMap(this.loc.file);
|
|
const entry = map?.findEntry(this.loc.line - 1, this.loc.column - 1);
|
|
|
|
if (entry !== undefined) {
|
|
this.loc.line = entry.originalLine + 1;
|
|
this.loc.column = entry.originalColumn + 1;
|
|
this.loc.file = entry.originalSource;
|
|
}
|
|
}
|
|
|
|
if (StringPrototypeStartsWith(this.loc.file, 'file://')) {
|
|
this.loc.file = fileURLToPath(this.loc.file);
|
|
}
|
|
}
|
|
}
|
|
|
|
willBeFiltered() {
|
|
if (testOnlyFlag && !this.only) return true;
|
|
|
|
if (testNamePatterns && !testMatchesPattern(this, testNamePatterns)) {
|
|
return true;
|
|
}
|
|
if (testSkipPatterns && testMatchesPattern(this, testSkipPatterns)) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Returns a name of the test prefixed by name of all its ancestors in ascending order, separated by a space
|
|
* Ex."grandparent parent test"
|
|
*
|
|
* It's needed to match a single test with non-unique name by pattern
|
|
*/
|
|
getTestNameWithAncestors() {
|
|
if (!this.parent) return '';
|
|
|
|
return `${this.parent.getTestNameWithAncestors()} ${this.name}`;
|
|
}
|
|
|
|
hasConcurrency() {
|
|
return this.concurrency > this.activeSubtests;
|
|
}
|
|
|
|
addPendingSubtest(deferred) {
|
|
ArrayPrototypePush(this.pendingSubtests, deferred);
|
|
}
|
|
|
|
async processPendingSubtests() {
|
|
while (this.pendingSubtests.length > 0 && this.hasConcurrency()) {
|
|
const deferred = ArrayPrototypeShift(this.pendingSubtests);
|
|
const test = deferred.test;
|
|
test.reporter.dequeue(test.nesting, test.loc, test.name);
|
|
await test.run();
|
|
deferred.resolve();
|
|
}
|
|
}
|
|
|
|
addReadySubtest(subtest) {
|
|
this.readySubtests.set(subtest.childNumber, subtest);
|
|
}
|
|
|
|
processReadySubtestRange(canSend) {
|
|
const start = this.waitingOn;
|
|
const end = start + this.readySubtests.size;
|
|
|
|
for (let i = start; i < end; i++) {
|
|
const subtest = this.readySubtests.get(i);
|
|
|
|
// Check if the specified subtest is in the map. If it is not, return
|
|
// early to avoid trying to process any more tests since they would be
|
|
// out of order.
|
|
if (subtest === undefined) {
|
|
return;
|
|
}
|
|
|
|
// Call isClearToSend() in the loop so that it is:
|
|
// - Only called if there are results to report in the correct order.
|
|
// - Guaranteed to only be called a maximum of once per call to
|
|
// processReadySubtestRange().
|
|
canSend = canSend || this.isClearToSend();
|
|
|
|
if (!canSend) {
|
|
return;
|
|
}
|
|
|
|
// Report the subtest's results and remove it from the ready map.
|
|
subtest.finalize();
|
|
this.readySubtests.delete(i);
|
|
}
|
|
}
|
|
|
|
createSubtest(Factory, name, options, fn, overrides) {
|
|
if (typeof name === 'function') {
|
|
fn = name;
|
|
} else if (name !== null && typeof name === 'object') {
|
|
fn = options;
|
|
options = name;
|
|
} else if (typeof options === 'function') {
|
|
fn = options;
|
|
}
|
|
|
|
if (options === null || typeof options !== 'object') {
|
|
options = kEmptyObject;
|
|
}
|
|
|
|
let parent = this;
|
|
|
|
// If this test has already ended, attach this test to the root test so
|
|
// that the error can be properly reported.
|
|
const preventAddingSubtests = this.finished || this.buildPhaseFinished;
|
|
if (preventAddingSubtests) {
|
|
while (parent.parent !== null) {
|
|
parent = parent.parent;
|
|
}
|
|
}
|
|
|
|
const test = new Factory({ __proto__: null, fn, name, parent, ...options, ...overrides });
|
|
|
|
if (parent.waitingOn === 0) {
|
|
parent.waitingOn = test.childNumber;
|
|
}
|
|
|
|
if (preventAddingSubtests) {
|
|
test.fail(
|
|
new ERR_TEST_FAILURE(
|
|
'test could not be started because its parent finished',
|
|
kParentAlreadyFinished,
|
|
),
|
|
);
|
|
}
|
|
|
|
ArrayPrototypePush(parent.subtests, test);
|
|
return test;
|
|
}
|
|
|
|
#abortHandler = () => {
|
|
const error = this.outerSignal?.reason || new AbortError('The test was aborted');
|
|
error.failureType = kAborted;
|
|
this.#cancel(error);
|
|
};
|
|
|
|
#cancel(error) {
|
|
if (this.endTime !== null || this.error !== null) {
|
|
return;
|
|
}
|
|
|
|
this.fail(error ||
|
|
new ERR_TEST_FAILURE(
|
|
'test did not finish before its parent and was cancelled',
|
|
kCancelledByParent,
|
|
),
|
|
);
|
|
this.cancelled = true;
|
|
this.abortController.abort();
|
|
}
|
|
|
|
createHook(name, fn, options) {
|
|
validateOneOf(name, 'hook name', kHookNames);
|
|
// eslint-disable-next-line no-use-before-define
|
|
const hook = new TestHook(fn, options);
|
|
if (name === 'before' || name === 'after') {
|
|
hook.run = runOnce(hook.run, kRunOnceOptions);
|
|
}
|
|
if (name === 'before' && this.startTime !== null) {
|
|
// Test has already started, run the hook immediately
|
|
PromisePrototypeThen(hook.run(this.getRunArgs()), () => {
|
|
if (hook.error != null) {
|
|
this.fail(hook.error);
|
|
}
|
|
});
|
|
}
|
|
if (name === 'afterEach') {
|
|
// afterEach hooks for the current test should run in the order that they
|
|
// are created. However, the current test's afterEach hooks should run
|
|
// prior to any ancestor afterEach hooks.
|
|
ArrayPrototypeSplice(this.hooks[name], this.hooks.ownAfterEachCount, 0, hook);
|
|
this.hooks.ownAfterEachCount++;
|
|
} else {
|
|
ArrayPrototypePush(this.hooks[name], hook);
|
|
}
|
|
return hook;
|
|
}
|
|
|
|
fail(err) {
|
|
if (this.error !== null) {
|
|
return;
|
|
}
|
|
|
|
this.passed = false;
|
|
this.error = err;
|
|
}
|
|
|
|
pass() {
|
|
if (this.error !== null) {
|
|
return;
|
|
}
|
|
|
|
this.passed = true;
|
|
}
|
|
|
|
skip(message) {
|
|
this.skipped = true;
|
|
this.message = message;
|
|
}
|
|
|
|
todo(message) {
|
|
this.isTodo = true;
|
|
this.message = message;
|
|
}
|
|
|
|
diagnostic(message) {
|
|
ArrayPrototypePush(this.diagnostics, message);
|
|
}
|
|
|
|
get shouldFilter() {
|
|
return this.filtered && this.parent?.filteredSubtestCount > 0;
|
|
}
|
|
|
|
start() {
|
|
if (this.shouldFilter) {
|
|
noopTestStream ??= new TestsStream();
|
|
this.reporter = noopTestStream;
|
|
this.run = this.filteredRun;
|
|
} else {
|
|
this.testNumber = ++this.parent.outputSubtestCount;
|
|
}
|
|
|
|
// If there is enough available concurrency to run the test now, then do
|
|
// it. Otherwise, return a Promise to the caller and mark the test as
|
|
// pending for later execution.
|
|
this.reporter.enqueue(this.nesting, this.loc, this.name);
|
|
if (!this.root.harness.allowTestsToRun || !this.parent.hasConcurrency()) {
|
|
const deferred = createDeferredPromise();
|
|
|
|
deferred.test = this;
|
|
this.parent.addPendingSubtest(deferred);
|
|
return deferred.promise;
|
|
}
|
|
|
|
this.reporter.dequeue(this.nesting, this.loc, this.name);
|
|
return this.run();
|
|
}
|
|
|
|
[kShouldAbort]() {
|
|
if (this.signal.aborted) {
|
|
return true;
|
|
}
|
|
if (this.outerSignal?.aborted) {
|
|
this.#abortHandler();
|
|
return true;
|
|
}
|
|
}
|
|
|
|
getRunArgs() {
|
|
const ctx = new TestContext(this);
|
|
return { __proto__: null, ctx, args: [ctx] };
|
|
}
|
|
|
|
async runHook(hook, args) {
|
|
validateOneOf(hook, 'hook name', kHookNames);
|
|
try {
|
|
await ArrayPrototypeReduce(this.hooks[hook], async (prev, hook) => {
|
|
await prev;
|
|
await hook.run(args);
|
|
if (hook.error) {
|
|
throw hook.error;
|
|
}
|
|
}, PromiseResolve());
|
|
} catch (err) {
|
|
const error = new ERR_TEST_FAILURE(`failed running ${hook} hook`, kHookFailure);
|
|
error.cause = isTestFailureError(err) ? err.cause : err;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async filteredRun() {
|
|
this.pass();
|
|
this.subtests = [];
|
|
this.report = noop;
|
|
queueMicrotask(() => this.postRun());
|
|
}
|
|
|
|
async run() {
|
|
if (this.parent !== null) {
|
|
this.parent.activeSubtests++;
|
|
}
|
|
this.startTime ??= hrtime();
|
|
|
|
if (this[kShouldAbort]()) {
|
|
this.postRun();
|
|
return;
|
|
}
|
|
|
|
const hookArgs = this.getRunArgs();
|
|
const { args, ctx } = hookArgs;
|
|
|
|
if (this.plan === null && this.expectedAssertions) {
|
|
ctx.plan(this.expectedAssertions);
|
|
}
|
|
|
|
const after = async () => {
|
|
if (this.hooks.after.length > 0) {
|
|
await this.runHook('after', hookArgs);
|
|
}
|
|
};
|
|
const afterEach = runOnce(async () => {
|
|
if (this.parent?.hooks.afterEach.length > 0 && !this.skipped) {
|
|
await this.parent.runHook('afterEach', hookArgs);
|
|
}
|
|
}, kRunOnceOptions);
|
|
|
|
let stopPromise;
|
|
|
|
try {
|
|
if (this.parent?.hooks.before.length > 0) {
|
|
// This hook usually runs immediately, we need to wait for it to finish
|
|
await this.parent.runHook('before', this.parent.getRunArgs());
|
|
}
|
|
if (this.parent?.hooks.beforeEach.length > 0 && !this.skipped) {
|
|
await this.parent.runHook('beforeEach', hookArgs);
|
|
}
|
|
stopPromise = stopTest(this.timeout, this.signal);
|
|
const runArgs = ArrayPrototypeSlice(args);
|
|
ArrayPrototypeUnshift(runArgs, this.fn, ctx);
|
|
|
|
if (this.fn.length === runArgs.length - 1) {
|
|
// This test is using legacy Node.js error first callbacks.
|
|
const { promise, cb } = createDeferredCallback();
|
|
|
|
ArrayPrototypePush(runArgs, cb);
|
|
const ret = ReflectApply(this.runInAsyncScope, this, runArgs);
|
|
|
|
if (isPromise(ret)) {
|
|
this.fail(new ERR_TEST_FAILURE(
|
|
'passed a callback but also returned a Promise',
|
|
kCallbackAndPromisePresent,
|
|
));
|
|
await SafePromiseRace([ret, stopPromise]);
|
|
} else {
|
|
await SafePromiseRace([PromiseResolve(promise), stopPromise]);
|
|
}
|
|
} else {
|
|
// This test is synchronous or using Promises.
|
|
const promise = ReflectApply(this.runInAsyncScope, this, runArgs);
|
|
await SafePromiseRace([PromiseResolve(promise), stopPromise]);
|
|
}
|
|
|
|
if (this[kShouldAbort]()) {
|
|
this.postRun();
|
|
return;
|
|
}
|
|
this.plan?.check();
|
|
this.pass();
|
|
await afterEach();
|
|
await after();
|
|
} catch (err) {
|
|
if (isTestFailureError(err)) {
|
|
if (err.failureType === kTestTimeoutFailure) {
|
|
this.#cancel(err);
|
|
} else {
|
|
this.fail(err);
|
|
}
|
|
} else {
|
|
this.fail(new ERR_TEST_FAILURE(err, kTestCodeFailure));
|
|
}
|
|
try { await afterEach(); } catch { /* test is already failing, let's ignore the error */ }
|
|
try { await after(); } catch { /* Ignore error. */ }
|
|
} finally {
|
|
stopPromise?.[SymbolDispose]();
|
|
|
|
// Do not abort hooks and the root test as hooks instance are shared between tests suite so aborting them will
|
|
// cause them to not run for further tests.
|
|
if (this.parent !== null) {
|
|
this.abortController.abort();
|
|
}
|
|
}
|
|
|
|
if (this.parent !== null || typeof this.hookType === 'string') {
|
|
// Clean up the test. Then, try to report the results and execute any
|
|
// tests that were pending due to available concurrency.
|
|
//
|
|
// The root test is skipped here because it is a special case. Its
|
|
// postRun() method is called when the process is getting ready to exit.
|
|
// This helps catch any asynchronous activity that occurs after the tests
|
|
// have finished executing.
|
|
this.postRun();
|
|
} else if (forceExit) {
|
|
// This is the root test, and all known tests and hooks have finished
|
|
// executing. If the user wants to force exit the process regardless of
|
|
// any remaining ref'ed handles, then do that now. It is theoretically
|
|
// possible that a ref'ed handle could asynchronously create more tests,
|
|
// but the user opted into this behavior.
|
|
this.reporter.once('close', () => {
|
|
process.exit();
|
|
});
|
|
this.harness.teardown();
|
|
}
|
|
}
|
|
|
|
postRun(pendingSubtestsError) {
|
|
// If the test was cancelled before it started, then the start and end
|
|
// times need to be corrected.
|
|
this.endTime ??= hrtime();
|
|
this.startTime ??= this.endTime;
|
|
|
|
// The test has run, so recursively cancel any outstanding subtests and
|
|
// mark this test as failed if any subtests failed.
|
|
this.pendingSubtests = [];
|
|
let failed = 0;
|
|
for (let i = 0; i < this.subtests.length; i++) {
|
|
const subtest = this.subtests[i];
|
|
|
|
if (!subtest.finished) {
|
|
subtest.#cancel(pendingSubtestsError);
|
|
subtest.postRun(pendingSubtestsError);
|
|
}
|
|
if (!subtest.passed && !subtest.isTodo) {
|
|
failed++;
|
|
}
|
|
}
|
|
|
|
if ((this.passed || this.parent === null) && failed > 0) {
|
|
const subtestString = `subtest${failed > 1 ? 's' : ''}`;
|
|
const msg = `${failed} ${subtestString} failed`;
|
|
|
|
this.fail(new ERR_TEST_FAILURE(msg, kSubtestsFailed));
|
|
}
|
|
|
|
this.outerSignal?.removeEventListener('abort', this.#abortHandler);
|
|
this.mock?.reset();
|
|
|
|
if (this.parent !== null) {
|
|
if (!this.shouldFilter) {
|
|
const report = this.getReportDetails();
|
|
report.details.passed = this.passed;
|
|
this.testNumber ||= ++this.parent.outputSubtestCount;
|
|
this.reporter.complete(this.nesting, this.loc, this.testNumber, this.name, report.details, report.directive);
|
|
this.parent.activeSubtests--;
|
|
}
|
|
|
|
this.parent.addReadySubtest(this);
|
|
this.parent.processReadySubtestRange(false);
|
|
this.parent.processPendingSubtests();
|
|
} else if (!this.reported) {
|
|
const {
|
|
diagnostics,
|
|
harness,
|
|
loc,
|
|
nesting,
|
|
reporter,
|
|
} = this;
|
|
|
|
this.reported = true;
|
|
reporter.plan(nesting, loc, harness.counters.topLevel);
|
|
|
|
// Call this harness.coverage() before collecting diagnostics, since failure to collect coverage is a diagnostic.
|
|
const coverage = harness.coverage();
|
|
harness.snapshotManager?.writeSnapshotFiles();
|
|
for (let i = 0; i < diagnostics.length; i++) {
|
|
reporter.diagnostic(nesting, loc, diagnostics[i]);
|
|
}
|
|
|
|
reporter.diagnostic(nesting, loc, `tests ${harness.counters.all}`);
|
|
reporter.diagnostic(nesting, loc, `suites ${harness.counters.suites}`);
|
|
reporter.diagnostic(nesting, loc, `pass ${harness.counters.passed}`);
|
|
reporter.diagnostic(nesting, loc, `fail ${harness.counters.failed}`);
|
|
reporter.diagnostic(nesting, loc, `cancelled ${harness.counters.cancelled}`);
|
|
reporter.diagnostic(nesting, loc, `skipped ${harness.counters.skipped}`);
|
|
reporter.diagnostic(nesting, loc, `todo ${harness.counters.todo}`);
|
|
reporter.diagnostic(nesting, loc, `duration_ms ${this.duration()}`);
|
|
|
|
if (coverage) {
|
|
reporter.coverage(nesting, loc, coverage);
|
|
}
|
|
|
|
if (harness.watching) {
|
|
this.reported = false;
|
|
harness.resetCounters();
|
|
assertObj = undefined;
|
|
} else {
|
|
reporter.end();
|
|
}
|
|
}
|
|
}
|
|
|
|
isClearToSend() {
|
|
return this.parent === null ||
|
|
(
|
|
this.parent.waitingOn === this.childNumber && this.parent.isClearToSend()
|
|
);
|
|
}
|
|
|
|
finalize() {
|
|
// By the time this function is called, the following can be relied on:
|
|
// - The current test has completed or been cancelled.
|
|
// - All of this test's subtests have completed or been cancelled.
|
|
// - It is the current test's turn to report its results.
|
|
|
|
// Report any subtests that have not been reported yet. Since all of the
|
|
// subtests have finished, it's safe to pass true to
|
|
// processReadySubtestRange(), which will finalize all remaining subtests.
|
|
this.processReadySubtestRange(true);
|
|
|
|
// Output this test's results and update the parent's waiting counter.
|
|
this.report();
|
|
this.parent.waitingOn++;
|
|
this.finished = true;
|
|
|
|
if (this.parent === this.root &&
|
|
this.root.waitingOn > this.root.subtests.length) {
|
|
// At this point all of the tests have finished running. However, there
|
|
// might be ref'ed handles keeping the event loop alive. This gives the
|
|
// global after() hook a chance to clean them up. The user may also
|
|
// want to force the test runner to exit despite ref'ed handles.
|
|
this.root.run();
|
|
}
|
|
}
|
|
|
|
duration() {
|
|
// Duration is recorded in BigInt nanoseconds. Convert to milliseconds.
|
|
return Number(this.endTime - this.startTime) / 1_000_000;
|
|
}
|
|
|
|
getReportDetails() {
|
|
let directive;
|
|
const details = { __proto__: null, duration_ms: this.duration() };
|
|
|
|
if (this.skipped) {
|
|
directive = this.reporter.getSkip(this.message);
|
|
} else if (this.isTodo) {
|
|
directive = this.reporter.getTodo(this.message);
|
|
}
|
|
|
|
if (this.reportedType) {
|
|
details.type = this.reportedType;
|
|
}
|
|
if (!this.passed) {
|
|
details.error = this.error;
|
|
}
|
|
return { __proto__: null, details, directive };
|
|
}
|
|
|
|
report() {
|
|
countCompletedTest(this);
|
|
if (this.outputSubtestCount > 0) {
|
|
this.reporter.plan(this.subtests[0].nesting, this.loc, this.outputSubtestCount);
|
|
} else {
|
|
this.reportStarted();
|
|
}
|
|
const report = this.getReportDetails();
|
|
|
|
if (this.passed) {
|
|
this.reporter.ok(this.nesting, this.loc, this.testNumber, this.name, report.details, report.directive);
|
|
} else {
|
|
this.reporter.fail(this.nesting, this.loc, this.testNumber, this.name, report.details, report.directive);
|
|
}
|
|
|
|
for (let i = 0; i < this.diagnostics.length; i++) {
|
|
this.reporter.diagnostic(this.nesting, this.loc, this.diagnostics[i]);
|
|
}
|
|
}
|
|
|
|
reportStarted() {
|
|
if (this.#reportedSubtest || this.parent === null) {
|
|
return;
|
|
}
|
|
this.#reportedSubtest = true;
|
|
this.parent.reportStarted();
|
|
this.reporter.start(this.nesting, this.loc, this.name);
|
|
}
|
|
}
|
|
|
|
class TestHook extends Test {
|
|
#args;
|
|
constructor(fn, options) {
|
|
if (options === null || typeof options !== 'object') {
|
|
options = kEmptyObject;
|
|
}
|
|
const { loc, timeout, signal } = options;
|
|
super({ __proto__: null, fn, loc, timeout, signal });
|
|
|
|
this.parentTest = options.parent ?? null;
|
|
this.hookType = options.hookType;
|
|
}
|
|
run(args) {
|
|
if (this.error && !this.outerSignal?.aborted) {
|
|
this.passed = false;
|
|
this.error = null;
|
|
this.abortController.abort();
|
|
this.abortController = new AbortController();
|
|
this.signal = this.abortController.signal;
|
|
}
|
|
|
|
this.#args = args;
|
|
return super.run();
|
|
}
|
|
getRunArgs() {
|
|
return this.#args;
|
|
}
|
|
willBeFiltered() {
|
|
return false;
|
|
}
|
|
postRun() {
|
|
const { error, loc, parentTest: parent } = this;
|
|
|
|
// Report failures in the root test's after() hook.
|
|
if (error && parent !== null &&
|
|
parent === parent.root && this.hookType === 'after') {
|
|
|
|
if (isTestFailureError(error)) {
|
|
error.failureType = kHookFailure;
|
|
}
|
|
|
|
this.endTime ??= hrtime();
|
|
parent.reporter.fail(0, loc, parent.subtests.length + 1, loc.file, {
|
|
__proto__: null,
|
|
duration_ms: this.duration(),
|
|
error,
|
|
}, undefined);
|
|
}
|
|
}
|
|
}
|
|
|
|
class Suite extends Test {
|
|
reportedType = 'suite';
|
|
constructor(options) {
|
|
super(options);
|
|
|
|
if (testNamePatterns !== null && testSkipPatterns !== null && !options.skip) {
|
|
this.fn = options.fn || this.fn;
|
|
this.skipped = false;
|
|
}
|
|
this.runOnlySubtests = testOnlyFlag;
|
|
|
|
try {
|
|
const { ctx, args } = this.getRunArgs();
|
|
const runArgs = [this.fn, ctx];
|
|
ArrayPrototypePushApply(runArgs, args);
|
|
this.buildSuite = SafePromisePrototypeFinally(
|
|
PromisePrototypeThen(
|
|
PromiseResolve(ReflectApply(this.runInAsyncScope, this, runArgs)),
|
|
undefined,
|
|
(err) => {
|
|
this.fail(new ERR_TEST_FAILURE(err, kTestCodeFailure));
|
|
}),
|
|
() => this.postBuild(),
|
|
);
|
|
} catch (err) {
|
|
this.fail(new ERR_TEST_FAILURE(err, kTestCodeFailure));
|
|
|
|
this.buildPhaseFinished = true;
|
|
}
|
|
this.fn = noop;
|
|
}
|
|
|
|
postBuild() {
|
|
this.buildPhaseFinished = true;
|
|
if (this.filtered && this.filteredSubtestCount !== this.subtests.length) {
|
|
// A suite can transition from filtered to unfiltered based on the
|
|
// tests that it contains - in case of children matching patterns.
|
|
this.filtered = false;
|
|
this.parent.filteredSubtestCount--;
|
|
} else if (
|
|
testOnlyFlag &&
|
|
testNamePatterns == null &&
|
|
testSkipPatterns == null &&
|
|
this.filteredSubtestCount === this.subtests.length
|
|
) {
|
|
// If no subtests are marked as "only", run them all
|
|
this.filteredSubtestCount = 0;
|
|
}
|
|
}
|
|
|
|
getRunArgs() {
|
|
const ctx = new SuiteContext(this);
|
|
return { __proto__: null, ctx, args: [ctx] };
|
|
}
|
|
|
|
async run() {
|
|
const hookArgs = this.getRunArgs();
|
|
|
|
let stopPromise;
|
|
const after = runOnce(() => this.runHook('after', hookArgs), kRunOnceOptions);
|
|
try {
|
|
this.parent.activeSubtests++;
|
|
await this.buildSuite;
|
|
this.startTime = hrtime();
|
|
|
|
if (this[kShouldAbort]()) {
|
|
this.subtests = [];
|
|
this.postRun();
|
|
return;
|
|
}
|
|
|
|
if (this.parent.hooks.before.length > 0) {
|
|
await this.parent.runHook('before', this.parent.getRunArgs());
|
|
}
|
|
|
|
await this.runHook('before', hookArgs);
|
|
|
|
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 after();
|
|
|
|
this.pass();
|
|
} catch (err) {
|
|
try { await after(); } catch { /* suite is already failing */ }
|
|
if (isTestFailureError(err)) {
|
|
this.fail(err);
|
|
} else {
|
|
this.fail(new ERR_TEST_FAILURE(err, kTestCodeFailure));
|
|
}
|
|
} finally {
|
|
stopPromise?.[SymbolDispose]();
|
|
}
|
|
|
|
this.postRun();
|
|
}
|
|
}
|
|
|
|
function getFullName(test) {
|
|
let fullName = test.name;
|
|
|
|
for (let t = test.parent; t !== t.root; t = t.parent) {
|
|
fullName = `${t.name} > ${fullName}`;
|
|
}
|
|
|
|
return fullName;
|
|
}
|
|
|
|
module.exports = {
|
|
kCancelledByParent,
|
|
kSubtestsFailed,
|
|
kTestCodeFailure,
|
|
kTestTimeoutFailure,
|
|
kAborted,
|
|
kUnwrapErrors,
|
|
Suite,
|
|
Test,
|
|
};
|