Files
codeql-action/node_modules/ava/lib/test.js
2025-07-15 07:33:26 +00:00

655 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import concordance from 'concordance';
import isPromise from 'is-promise';
import plur from 'plur';
import {
AssertionError, Assertions, checkAssertionMessage, getAssertionStack,
} from './assert.js';
import concordanceOptions from './concordance-options.js';
import nowAndTimers from './now-and-timers.cjs';
import parseTestArgs from './parse-test-args.js';
function isExternalAssertError(error) {
if (typeof error !== 'object' || error === null) {
return false;
}
// Match errors thrown by <https://www.npmjs.com/package/expect>.
if (Object.hasOwn(error, 'matcherResult')) {
return true;
}
// Match errors thrown by <https://www.npmjs.com/package/chai> and <https://nodejs.org/api/assert.html>.
return Object.hasOwn(error, 'actual') && Object.hasOwn(error, 'expected');
}
function formatErrorValue(label, error) {
const formatted = concordance.format(error, concordanceOptions);
return {label, formatted};
}
class TestFailure extends Error {
constructor() {
super('The test has failed');
this.name = 'TestFailure';
}
}
const testMap = new WeakMap();
class ExecutionContext extends Assertions {
constructor(test) {
super({
pass() {
test.countPassedAssertion();
return true;
},
pending(promise) {
test.addPendingAssertion(promise);
},
fail(error) {
return test.addFailedAssertion(error);
},
failPending(error) {
return test.failPendingAssertion(error);
},
skip() {
test.countPassedAssertion();
},
compareWithSnapshot: options => test.compareWithSnapshot(options),
experiments: test.experiments,
disableSnapshots: test.isHook === true,
});
testMap.set(this, test);
this.snapshot.skip = () => {
test.skipSnapshot();
};
this.log = (...inputArgs) => {
const args = inputArgs.map(value => typeof value === 'string'
? value
: concordance.format(value, concordanceOptions));
if (args.length > 0) {
test.addLog(args.join(' '));
}
};
this.plan = count => {
test.plan(count, getAssertionStack());
};
this.plan.skip = () => {};
this.timeout = (ms, message) => {
test.timeout(ms, message);
};
this.timeout.clear = () => {
test.clearTimeout();
};
this.teardown = callback => {
test.addTeardown(callback);
};
this.try = async (...attemptArgs) => {
if (test.isHook) {
const error = new Error('`t.try()` can only be used in tests');
test.saveFirstError(error);
throw error;
}
const {args, implementation, title} = parseTestArgs(attemptArgs);
if (typeof implementation !== 'function') {
throw new TypeError('Expected an implementation.');
}
let attemptTitle;
if (!title.isSet || title.isEmpty) {
attemptTitle = `${test.title} ─ attempt ${test.attemptCount + 1}`;
} else if (title.isValid) {
attemptTitle = `${test.title}${title.value}`;
} else {
throw new TypeError('`t.try()` titles must be strings');
}
if (!test.registerUniqueTitle(attemptTitle)) {
throw new Error(`Duplicate test title: ${attemptTitle}`);
}
let committed = false;
let discarded = false;
const {assertCount, deferredSnapshotRecordings, errors, logs, passed, snapshotCount, startingSnapshotCount} = await test.runAttempt(attemptTitle, t => implementation(t, ...args));
return {
errors,
logs: [...logs], // Don't allow modification of logs.
passed,
title: attemptTitle,
commit({retainLogs = true} = {}) {
if (committed) {
return;
}
if (discarded) {
test.saveFirstError(new Error('Cant commit a result that was previously discarded'));
throw this.testFailure;
}
committed = true;
test.finishAttempt({
assertCount,
commit: true,
deferredSnapshotRecordings,
errors,
logs,
passed,
retainLogs,
snapshotCount,
startingSnapshotCount,
});
},
discard({retainLogs = false} = {}) {
if (committed) {
test.saveFirstError(new Error('Cant discard a result that was previously committed'));
throw this.testFailure;
}
if (discarded) {
return;
}
discarded = true;
test.finishAttempt({
assertCount: 0,
commit: false,
deferredSnapshotRecordings,
errors,
logs,
passed,
retainLogs,
snapshotCount,
startingSnapshotCount,
});
},
};
};
}
get title() {
return testMap.get(this).title;
}
get context() {
return testMap.get(this).contextRef.get();
}
set context(context) {
testMap.get(this).contextRef.set(context);
}
get passed() {
const test = testMap.get(this);
return test.isHook ? test.testPassed : !test.assertError;
}
}
export default class Test {
constructor(options) {
this.contextRef = options.contextRef;
this.experiments = options.experiments ?? {};
this.failWithoutAssertions = options.failWithoutAssertions;
this.fn = options.fn;
this.isHook = options.isHook === true;
this.metadata = options.metadata;
this.title = options.title;
this.testPassed = options.testPassed;
this.registerUniqueTitle = options.registerUniqueTitle;
this.logs = [];
this.teardowns = [];
this.notifyTimeoutUpdate = options.notifyTimeoutUpdate;
const {snapshotBelongsTo = this.title, nextSnapshotIndex = 0} = options;
this.snapshotBelongsTo = snapshotBelongsTo;
this.nextSnapshotIndex = nextSnapshotIndex;
this.snapshotCount = 0;
const deferRecording = this.metadata.inline;
this.deferredSnapshotRecordings = [];
this.compareWithSnapshot = ({expected, message}) => {
this.snapshotCount++;
const belongsTo = snapshotBelongsTo;
const index = this.nextSnapshotIndex++;
const label = message;
const {record, ...result} = options.compareTestSnapshot({
belongsTo,
deferRecording,
expected,
index,
label,
taskIndex: this.metadata.taskIndex,
});
if (record) {
this.deferredSnapshotRecordings.push(record);
}
return result;
};
this.skipSnapshot = () => {
if (typeof options.skipSnapshot === 'function') {
const record = options.skipSnapshot({
belongsTo: snapshotBelongsTo,
index: this.nextSnapshotIndex,
deferRecording,
taskIndex: this.metadata.taskIndex,
});
if (record) {
this.deferredSnapshotRecordings.push(record);
}
}
this.nextSnapshotIndex++;
this.snapshotCount++;
this.countPassedAssertion();
};
this.runAttempt = async (title, fn) => {
if (this.finishing) {
this.saveFirstError(new Error('Running a `t.try()`, but the test has already finished'));
}
this.attemptCount++;
this.pendingAttemptCount++;
const {contextRef, snapshotBelongsTo, nextSnapshotIndex, snapshotCount: startingSnapshotCount} = this;
const attempt = new Test({
...options,
fn,
metadata: {...options.metadata, failing: false, inline: true},
contextRef: contextRef.copy(),
snapshotBelongsTo,
nextSnapshotIndex,
title,
});
const {deferredSnapshotRecordings, error, logs, passed, assertCount, snapshotCount} = await attempt.run();
const errors = error ? [error] : [];
return {
assertCount, deferredSnapshotRecordings, errors, logs, passed, snapshotCount, startingSnapshotCount,
};
};
this.assertCount = 0;
this.assertError = null;
this.attemptCount = 0;
this.calledEnd = false;
this.duration = null;
this.finishDueToInactivity = null;
this.finishDueToTimeout = null;
this.finishing = false;
this.pendingAssertionCount = 0;
this.pendingAttemptCount = 0;
this.planCount = null;
this.startedAt = 0;
this.testFailure = null;
this.timeoutTimer = null;
}
createExecutionContext() {
return new ExecutionContext(this);
}
countPassedAssertion() {
if (this.finishing) {
this.saveFirstError(new Error('Assertion passed, but test has already finished'));
}
if (this.pendingAttemptCount > 0) {
this.saveFirstError(new Error('Assertion passed, but an attempt is pending. Use the attempts assertions instead'));
}
this.assertCount++;
this.refreshTimeout();
}
addLog(text) {
this.logs.push(text);
}
async addPendingAssertion(promise) {
if (this.finishing) {
this.saveFirstError(new Error('Assertion started, but test has already finished'));
}
if (this.pendingAttemptCount > 0) {
this.saveFirstError(new Error('Assertion started, but an attempt is pending. Use the attempts assertions instead'));
}
this.assertCount++;
this.pendingAssertionCount++;
this.refreshTimeout();
try {
await promise;
} catch {
// Ignore errors.
} finally {
this.pendingAssertionCount--;
this.refreshTimeout();
}
}
addFailedAssertion(error) {
if (this.finishing) {
this.saveFirstError(new Error('Assertion failed, but test has already finished'));
}
if (this.pendingAttemptCount > 0) {
this.saveFirstError(new Error('Assertion failed, but an attempt is pending. Use the attempts assertions instead'));
}
this.assertCount++;
this.refreshTimeout();
this.saveFirstError(error);
return this.testFailure;
}
failPendingAssertion(error) {
this.saveFirstError(error);
return this.testFailure;
}
finishAttempt({commit, deferredSnapshotRecordings, errors, logs, passed, retainLogs, snapshotCount, startingSnapshotCount}) {
if (this.finishing) {
if (commit) {
this.saveFirstError(new Error('`t.try()` result was committed, but the test has already finished'));
} else {
this.saveFirstError(new Error('`t.try()` result was discarded, but the test has already finished'));
}
}
if (commit) {
this.assertCount++;
if (startingSnapshotCount === this.snapshotCount) {
this.snapshotCount += snapshotCount;
this.nextSnapshotIndex += snapshotCount;
for (const record of deferredSnapshotRecordings) {
record();
}
} else {
this.saveFirstError(new Error('Cannot commit `t.try()` result. Do not run concurrent snapshot assertions when using `t.try()`'));
}
}
this.pendingAttemptCount--;
if (commit && !passed) {
this.saveFirstError(errors[0]);
}
if (retainLogs) {
for (const log of logs) {
this.addLog(log);
}
}
this.refreshTimeout();
if (this.testFailure) {
throw this.testFailure;
}
}
saveFirstError(error) {
this.assertError ??= error;
this.testFailure = new TestFailure();
}
plan(count, planAssertionStack) {
if (typeof count !== 'number') {
throw new TypeError('Expected a number');
}
this.planCount = count;
// In case the `planCount` doesn't match `assertCount, we need the stack of
// this function to throw with a useful stack.
this.planAssertionStack = planAssertionStack;
}
timeout(ms, message) {
const result = checkAssertionMessage(message, 't.timeout()');
if (result !== true) {
this.saveFirstError(result);
// Allow the timeout to be set even when the message is invalid.
message = '';
}
if (this.finishing) {
return;
}
this.clearTimeout();
this.timeoutTimer = nowAndTimers.setCappedTimeout(() => {
this.saveFirstError(new Error(message ?? 'Test timeout exceeded'));
if (this.finishDueToTimeout) {
this.finishDueToTimeout();
}
}, ms);
this.notifyTimeoutUpdate(ms);
}
refreshTimeout() {
this.timeoutTimer?.refresh();
}
clearTimeout() {
nowAndTimers.clearTimeout(this.timeoutTimer);
this.timeoutTimer = null;
}
addTeardown(callback) {
if (this.isHook) {
this.saveFirstError(new Error('`t.teardown()` is not allowed in hooks'));
return;
}
if (this.finishing) {
this.saveFirstError(new Error('`t.teardown()` cannot be used during teardown'));
return;
}
if (typeof callback !== 'function') {
throw new TypeError('Expected a function');
}
this.teardowns.push(callback);
}
async runTeardowns() {
const teardowns = [...this.teardowns].reverse();
for (const teardown of teardowns) {
try {
await teardown(); // eslint-disable-line no-await-in-loop
} catch (error) {
this.saveFirstError(error);
}
}
}
verifyPlan() {
if (!this.assertError && this.planCount !== null && this.planCount !== this.assertCount) {
this.saveFirstError(new AssertionError(`Planned for ${this.planCount} ${plur('assertion', this.planCount)}, but got ${this.assertCount}.`, {
assertion: 't.plan()',
assertionStack: this.planAssertionStack,
}));
}
}
verifyAssertions() {
if (this.assertError) {
return;
}
if (this.pendingAttemptCount > 0) {
this.saveFirstError(new Error('Test finished, but not all attempts were committed or discarded'));
return;
}
if (this.pendingAssertionCount > 0) {
this.saveFirstError(new Error('Test finished, but an assertion is still pending'));
return;
}
if (this.failWithoutAssertions) {
if (this.planCount !== null) {
return; // `verifyPlan()` will report an error already.
}
if (this.assertCount === 0 && !this.calledEnd) {
this.saveFirstError(new Error('Test finished without running any assertions'));
}
}
}
callFn() {
try {
return [true, this.fn.call(null, this.createExecutionContext())];
} catch (error) {
return [false, error];
}
}
run() {
this.startedAt = nowAndTimers.now();
const [syncOk, retval] = this.callFn();
if (!syncOk) {
if (this.testFailure !== null && retval === this.testFailure) {
return this.finish();
}
if (isExternalAssertError(retval)) {
this.saveFirstError(new AssertionError('Assertion failed', {
cause: retval,
formattedDetails: [{label: 'Assertion failed: ', formatted: retval.message}],
}));
} else {
this.saveFirstError(new AssertionError('Error thrown in test', {
// TODO: Provide an assertion stack that traces to the test declaration,
// rather than AVA internals.
assertionStack: '',
cause: retval,
formattedDetails: [formatErrorValue('Error thrown in test:', retval)],
}));
}
return this.finish();
}
const returnedObservable = retval !== null && typeof retval === 'object' && typeof retval.subscribe === 'function';
const returnedPromise = isPromise(retval);
let promise;
if (returnedObservable) {
promise = new Promise((resolve, reject) => {
retval.subscribe({
error: reject,
complete: () => resolve(),
});
});
} else if (returnedPromise) {
// `retval` can be any thenable, so convert to a proper promise.
promise = Promise.resolve(retval);
}
if (promise) {
return new Promise(resolve => {
this.finishDueToAttributedError = () => {
resolve(this.finish());
};
this.finishDueToTimeout = () => {
resolve(this.finish());
};
this.finishDueToInactivity = () => {
const error = returnedObservable
? new Error('Observable returned by test never completed')
: new Error('Promise returned by test never resolved');
this.saveFirstError(error);
resolve(this.finish());
};
promise
.catch(error => { // eslint-disable-line promise/prefer-await-to-then
if (this.testFailure !== null && error === this.testFailure) {
return;
}
if (isExternalAssertError(error)) {
this.saveFirstError(new AssertionError('Assertion failed', {
cause: error,
formattedDetails: [{label: 'Assertion failed: ', formatted: error.message}],
}));
} else {
this.saveFirstError(new AssertionError('Rejected promise returned by test', {
cause: error,
formattedDetails: [formatErrorValue('Rejected promise returned by test. Reason:', error)],
}));
}
})
.then(() => resolve(this.finish())); // eslint-disable-line promise/prefer-await-to-then
});
}
return this.finish();
}
async finish() {
this.finishing = true;
this.clearTimeout();
this.verifyPlan();
this.verifyAssertions();
await this.runTeardowns();
this.duration = nowAndTimers.now() - this.startedAt;
let error = this.assertError;
let passed = !error;
if (this.metadata.failing) {
passed = !passed;
error = passed
? null
: new AssertionError('Test was expected to fail, but succeeded, you should stop marking the test as failing', {
// TODO: Provide an assertion stack that traces to the test declaration,
// rather than AVA internals.
assertionStack: '',
});
}
return {
deferredSnapshotRecordings: this.deferredSnapshotRecordings,
duration: this.duration,
error,
logs: this.logs,
metadata: this.metadata,
passed,
snapshotCount: this.snapshotCount,
assertCount: this.assertCount,
title: this.title,
};
}
}