ava
Version:
653 lines (546 loc) • 16.6 kB
JavaScript
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('Can’t 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('Can’t 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 attempt’s 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 attempt’s 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 attempt’s 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,
};
}
}