ava
Version:
812 lines (693 loc) • 24 kB
JavaScript
import {isNativeError} from 'node:util/types';
import concordance from 'concordance';
import isPromise from 'is-promise';
import concordanceOptions from './concordance-options.js';
import {CIRCULAR_SELECTOR, isLikeSelector, selectComparable} from './like-selector.js';
import {SnapshotError, VersionMismatchError} from './snapshot-manager.js';
function formatDescriptorDiff(actualDescriptor, expectedDescriptor, options) {
options = {...options, ...concordanceOptions};
const {diffGutters} = options.theme;
const {insertLine, deleteLine} = options.theme.string.diff;
return {
label: `Difference (${diffGutters.actual}${deleteLine.open}actual${deleteLine.close}, ${diffGutters.expected}${insertLine.open}expected${insertLine.close}):`,
formatted: concordance.diffDescriptors(actualDescriptor, expectedDescriptor, options),
};
}
function formatDescriptorWithLabel(label, descriptor) {
return {
label,
formatted: concordance.formatDescriptor(descriptor, concordanceOptions),
};
}
function formatWithLabel(label, value) {
return formatDescriptorWithLabel(label, concordance.describe(value, concordanceOptions));
}
const noop = () => {};
const notImplemented = () => {
throw new Error('not implemented');
};
export class AssertionError extends Error {
constructor(message = '', {
assertion,
assertionStack = getAssertionStack(AssertionError),
formattedDetails = [],
improperUsage = null,
cause,
} = {}) {
super(message, {cause});
this.name = 'AssertionError';
this.assertion = assertion;
this.assertionStack = assertionStack;
this.improperUsage = improperUsage;
this.formattedDetails = formattedDetails;
}
}
export function checkAssertionMessage(message, assertion) {
if (message === undefined || typeof message === 'string') {
return true;
}
return new AssertionError('The assertion message must be a string', {
assertion,
formattedDetails: [formatWithLabel('Called with:', message)],
});
}
export function getAssertionStack(constructorOpt = getAssertionStack) {
const {stackTraceLimit: limitBefore} = Error;
Error.stackTraceLimit = Number.POSITIVE_INFINITY;
const temporary = {};
Error.captureStackTrace(temporary, constructorOpt);
Error.stackTraceLimit = limitBefore;
return temporary.stack;
}
function validateExpectations(assertion, expectations, numberArgs) { // eslint-disable-line complexity
if (numberArgs === 1 || expectations === null || expectations === undefined) {
if (expectations === null) {
throw new AssertionError(`The second argument to \`${assertion}\` must be an expectation object or \`undefined\``, {
assertion,
formattedDetails: [formatWithLabel('Called with:', expectations)],
});
}
expectations = {};
} else if (
typeof expectations === 'function'
|| typeof expectations === 'string'
|| expectations instanceof RegExp
|| typeof expectations !== 'object'
|| Array.isArray(expectations)
|| Object.keys(expectations).length === 0
) {
throw new AssertionError(`The second argument to \`${assertion}\` must be an expectation object, \`null\` or \`undefined\``, {
assertion,
formattedDetails: [formatWithLabel('Called with:', expectations)],
});
} else {
if (Object.hasOwn(expectations, 'instanceOf') && typeof expectations.instanceOf !== 'function') {
throw new AssertionError(`The \`instanceOf\` property of the second argument to \`${assertion}\` must be a function`, {
assertion,
formattedDetails: [formatWithLabel('Called with:', expectations)],
});
}
if (
Object.hasOwn(expectations, 'message')
&& typeof expectations.message !== 'string'
&& !(expectations.message instanceof RegExp)
&& !(typeof expectations.message === 'function')
) {
throw new AssertionError(`The \`message\` property of the second argument to \`${assertion}\` must be a string, regular expression or a function`, {
assertion,
formattedDetails: [formatWithLabel('Called with:', expectations)],
});
}
if (Object.hasOwn(expectations, 'name') && typeof expectations.name !== 'string') {
throw new AssertionError(`The \`name\` property of the second argument to \`${assertion}\` must be a string`, {
assertion,
formattedDetails: [formatWithLabel('Called with:', expectations)],
});
}
if (Object.hasOwn(expectations, 'code') && typeof expectations.code !== 'string' && typeof expectations.code !== 'number') {
throw new AssertionError(`The \`code\` property of the second argument to \`${assertion}\` must be a string or number`, {
assertion,
formattedDetails: [formatWithLabel('Called with:', expectations)],
});
}
if (Object.hasOwn(expectations, 'any') && typeof expectations.any !== 'boolean') {
throw new AssertionError(`The \`any\` property of the second argument to \`${assertion}\` must be a boolean`, {
assertion,
formattedDetails: [formatWithLabel('Called with:', expectations)],
});
}
for (const key of Object.keys(expectations)) {
switch (key) {
case 'instanceOf':
case 'is':
case 'message':
case 'name':
case 'code':
case 'any': {
continue;
}
default: {
throw new AssertionError(`The second argument to \`${assertion}\` contains unexpected properties`, {
assertion,
formattedDetails: [formatWithLabel('Called with:', expectations)],
});
}
}
}
}
return expectations;
}
// Note: this function *must* throw exceptions, since it can be used
// as part of a pending assertion for promises.
function assertExpectations({actual, expectations, message, prefix, assertion, assertionStack}) {
const allowThrowAnything = Object.hasOwn(expectations, 'any') && expectations.any;
if (!isNativeError(actual) && !allowThrowAnything) {
throw new AssertionError(message, {
assertion,
assertionStack,
cause: actual,
formattedDetails: [formatWithLabel(`${prefix} exception that is not an error:`, actual)],
});
}
if (Object.hasOwn(expectations, 'is') && actual !== expectations.is) {
throw new AssertionError(message, {
assertion,
assertionStack,
cause: actual,
formattedDetails: [
formatWithLabel(`${prefix} unexpected exception:`, actual),
formatWithLabel('Expected to be strictly equal to:', expectations.is),
],
});
}
if (expectations.instanceOf && !(actual instanceof expectations.instanceOf)) {
throw new AssertionError(message, {
assertion,
assertionStack,
cause: actual,
formattedDetails: [
formatWithLabel(`${prefix} unexpected exception:`, actual),
formatWithLabel('Expected instance of:', expectations.instanceOf),
],
});
}
if (typeof expectations.name === 'string' && actual.name !== expectations.name) {
throw new AssertionError(message, {
assertion,
assertionStack,
cause: actual,
formattedDetails: [
formatWithLabel(`${prefix} unexpected exception:`, actual),
formatWithLabel('Expected name to equal:', expectations.name),
],
});
}
if (typeof expectations.message === 'string' && actual.message !== expectations.message) {
throw new AssertionError(message, {
assertion,
assertionStack,
cause: actual,
formattedDetails: [
formatWithLabel(`${prefix} unexpected exception:`, actual),
formatWithLabel('Expected message to equal:', expectations.message),
],
});
}
if (expectations.message instanceof RegExp && !expectations.message.test(actual.message)) {
throw new AssertionError(message, {
assertion,
assertionStack,
cause: actual,
formattedDetails: [
formatWithLabel(`${prefix} unexpected exception:`, actual),
formatWithLabel('Expected message to match:', expectations.message),
],
});
}
if (typeof expectations.message === 'function' && expectations.message(actual.message) === false) {
throw new AssertionError(message, {
assertion,
assertionStack,
cause: actual,
formattedDetails: [
formatWithLabel(`${prefix} unexpected exception:`, actual),
formatWithLabel('Expected message to return true:', expectations.message),
],
});
}
if (expectations.code !== undefined && actual.code !== expectations.code) {
throw new AssertionError(message, {
assertion,
assertionStack,
cause: actual,
formattedDetails: [
formatWithLabel(`${prefix} unexpected exception:`, actual),
formatWithLabel('Expected code to equal:', expectations.code),
],
});
}
}
export class Assertions {
constructor({
pass = notImplemented,
pending = notImplemented,
fail = notImplemented,
failPending = notImplemented,
skip = notImplemented,
compareWithSnapshot = notImplemented,
experiments = {},
disableSnapshots = false,
} = {}) {
const withSkip = assertionFn => {
assertionFn.skip = skip;
return assertionFn;
};
const assertMessage = (message, assertion) => {
const result = checkAssertionMessage(message, assertion);
if (result !== true) {
throw fail(result);
}
};
this.pass = withSkip(() => pass());
this.fail = withSkip(message => {
assertMessage(message, 't.fail()');
throw fail(new AssertionError(message ?? 'Test failed via `t.fail()`', {
assertion: 't.fail()',
}));
});
this.is = withSkip((actual, expected, message) => {
assertMessage(message, 't.is()');
if (Object.is(actual, expected)) {
return pass();
}
const result = concordance.compare(actual, expected, concordanceOptions);
const actualDescriptor = result.actual ?? concordance.describe(actual, concordanceOptions);
const expectedDescriptor = result.expected ?? concordance.describe(expected, concordanceOptions);
if (result.pass) {
throw fail(new AssertionError(message, {
assertion: 't.is()',
formattedDetails: [formatDescriptorWithLabel('Values are deeply equal to each other, but they are not the same:', actualDescriptor)],
}));
} else {
throw fail(new AssertionError(message, {
assertion: 't.is()',
formattedDetails: [formatDescriptorDiff(actualDescriptor, expectedDescriptor)],
}));
}
});
this.not = withSkip((actual, expected, message) => {
assertMessage(message, 't.not()');
if (Object.is(actual, expected)) {
throw fail(new AssertionError(message, {
assertion: 't.not()',
formattedDetails: [formatWithLabel('Value is the same as:', actual)],
}));
}
return pass();
});
this.deepEqual = withSkip((actual, expected, message) => {
assertMessage(message, 't.deepEqual()');
const result = concordance.compare(actual, expected, concordanceOptions);
if (result.pass) {
return pass();
}
const actualDescriptor = result.actual ?? concordance.describe(actual, concordanceOptions);
const expectedDescriptor = result.expected ?? concordance.describe(expected, concordanceOptions);
throw fail(new AssertionError(message, {
assertion: 't.deepEqual()',
formattedDetails: [formatDescriptorDiff(actualDescriptor, expectedDescriptor)],
}));
});
this.notDeepEqual = withSkip((actual, expected, message) => {
assertMessage(message, 't.notDeepEqual()');
const result = concordance.compare(actual, expected, concordanceOptions);
if (result.pass) {
const actualDescriptor = result.actual ?? concordance.describe(actual, concordanceOptions);
throw fail(new AssertionError(message, {
assertion: 't.notDeepEqual()',
formattedDetails: [formatDescriptorWithLabel('Value is deeply equal:', actualDescriptor)],
}));
}
return pass();
});
this.like = withSkip((actual, selector, message) => {
assertMessage(message, 't.like()');
if (!isLikeSelector(selector)) {
throw fail(new AssertionError('`t.like()` selector must be a non-empty object', {
assertion: 't.like()',
formattedDetails: [formatWithLabel('Called with:', selector)],
}));
}
let comparable;
try {
comparable = selectComparable(actual, selector);
} catch (error) {
if (error === CIRCULAR_SELECTOR) {
throw fail(new AssertionError('`t.like()` selector must not contain circular references', {
assertion: 't.like()',
formattedDetails: [formatWithLabel('Called with:', selector)],
}));
}
throw error;
}
const result = concordance.compare(comparable, selector, concordanceOptions);
if (result.pass) {
return pass();
}
const actualDescriptor = result.actual ?? concordance.describe(comparable, concordanceOptions);
const expectedDescriptor = result.expected ?? concordance.describe(selector, concordanceOptions);
throw fail(new AssertionError(message, {
assertion: 't.like()',
formattedDetails: [formatDescriptorDiff(actualDescriptor, expectedDescriptor)],
}));
});
this.throws = withSkip((...args) => {
// Since arrow functions do not support 'arguments', we are using rest
// operator, so we can determine the total number of arguments passed
// to the function.
let [fn, expectations, message] = args;
assertMessage(message, 't.throws()');
if (typeof fn !== 'function') {
throw fail(new AssertionError('`t.throws()` must be called with a function', {
assertion: 't.throws()',
improperUsage: {assertion: 'throws'},
formattedDetails: [formatWithLabel('Called with:', fn)],
}));
}
try {
expectations = validateExpectations('t.throws()', expectations, args.length, experiments);
} catch (error) {
throw fail(error);
}
let retval;
let threw = false;
let actual = null;
try {
retval = fn();
if (isPromise(retval)) {
// Here isPromise() checks if something is "promise like". Cast to an actual promise.
Promise.resolve(retval).catch(noop); // eslint-disable-line promise/prefer-await-to-then
throw fail(new AssertionError(message, {
assertion: 't.throws()',
formattedDetails: [formatWithLabel('Function returned a promise. Use `t.throwsAsync()` instead:', retval)],
}));
}
} catch (error) {
threw = true;
actual = error;
}
if (!threw) {
throw fail(new AssertionError(message, {
assertion: 't.throws()',
formattedDetails: [formatWithLabel('Function returned:', retval)],
}));
}
try {
assertExpectations({
assertion: 't.throws()',
actual,
expectations,
message,
prefix: 'Function threw',
});
pass();
return actual;
} catch (error) {
throw fail(error);
}
});
this.throwsAsync = withSkip(async (...args) => {
let [thrower, expectations, message] = args;
try {
assertMessage(message, 't.throwsAsync()');
} catch (error) {
try {
await thrower;
} catch {}
throw error;
}
if (typeof thrower !== 'function' && !isPromise(thrower)) {
throw fail(new AssertionError('`t.throwsAsync()` must be called with a function or promise', {
assertion: 't.throwsAsync()',
formattedDetails: [formatWithLabel('Called with:', thrower)],
}));
}
try {
expectations = validateExpectations('t.throwsAsync()', expectations, args.length, experiments);
} catch (error) {
try {
await thrower;
} catch {}
throw fail(error);
}
const handlePromise = async (promise, wasReturned) => {
// Record the stack before it gets lost in the promise chain.
const assertionStack = getAssertionStack();
// Handle "promise like" objects by casting to a real Promise.
const intermediate = Promise.resolve(promise).then(value => { // eslint-disable-line promise/prefer-await-to-then
throw failPending(new AssertionError(message, {
assertion: 't.throwsAsync()',
assertionStack,
formattedDetails: [formatWithLabel(`${wasReturned ? 'Returned promise' : 'Promise'} resolved with:`, value)],
}));
}, error => {
try {
assertExpectations({
assertion: 't.throwsAsync()',
actual: error,
expectations,
message,
prefix: `${wasReturned ? 'Returned promise' : 'Promise'} rejected with`,
assertionStack,
});
return error;
} catch (error_) {
throw failPending(error_);
}
});
pending(intermediate);
return intermediate;
};
if (isPromise(thrower)) {
return handlePromise(thrower, false);
}
let retval;
let actual = null;
try {
retval = thrower();
} catch (error) {
actual = error;
}
if (actual) {
throw fail(new AssertionError(message, {
assertion: 't.throwsAsync()',
cause: actual,
formattedDetails: [formatWithLabel('Function threw synchronously. Use `t.throws()` instead:', actual)],
}));
}
if (isPromise(retval)) {
return handlePromise(retval, true);
}
throw fail(new AssertionError(message, {
assertion: 't.throwsAsync()',
formattedDetails: [formatWithLabel('Function returned:', retval)],
}));
});
this.notThrows = withSkip((fn, message) => {
assertMessage(message, 't.notThrows()');
if (typeof fn !== 'function') {
throw fail(new AssertionError('`t.notThrows()` must be called with a function', {
assertion: 't.notThrows()',
improperUsage: {assertion: 'notThrows'},
formattedDetails: [formatWithLabel('Called with:', fn)],
}));
}
try {
fn();
} catch (error) {
throw fail(new AssertionError(message, {
assertion: 't.notThrows()',
cause: error,
formattedDetails: [formatWithLabel('Function threw:', error)],
}));
}
return pass();
});
this.notThrowsAsync = withSkip(async (nonThrower, message) => {
try {
assertMessage(message, 't.notThrowsAsync()');
} catch (error) {
try {
await nonThrower;
} catch {}
throw error;
}
if (typeof nonThrower !== 'function' && !isPromise(nonThrower)) {
throw fail(new AssertionError('`t.notThrowsAsync()` must be called with a function or promise', {
assertion: 't.notThrowsAsync()',
formattedDetails: [formatWithLabel('Called with:', nonThrower)],
}));
}
const handlePromise = async (promise, wasReturned) => {
// Create an error object to record the stack before it gets lost in the promise chain.
const assertionStack = getAssertionStack();
// Handle "promise like" objects by casting to a real Promise.
const intermediate = Promise.resolve(promise).then(noop, error => { // eslint-disable-line promise/prefer-await-to-then
throw failPending(new AssertionError(message, {
assertion: 't.notThrowsAsync()',
assertionStack,
formattedDetails: [formatWithLabel(`${wasReturned ? 'Returned promise' : 'Promise'} rejected with:`, error)],
}));
});
pending(intermediate);
await intermediate;
return true;
};
if (isPromise(nonThrower)) {
return handlePromise(nonThrower, false);
}
let retval;
try {
retval = nonThrower();
} catch (error) {
throw fail(new AssertionError(message, {
assertion: 't.notThrowsAsync()',
cause: error,
formattedDetails: [formatWithLabel('Function threw:', error)],
}));
}
if (!isPromise(retval)) {
throw fail(new AssertionError(message, {
assertion: 't.notThrowsAsync()',
formattedDetails: [formatWithLabel('Function did not return a promise. Use `t.notThrows()` instead:', retval)],
}));
}
return handlePromise(retval, true);
});
this.snapshot = withSkip((expected, message) => {
if (disableSnapshots) {
throw fail(new AssertionError('`t.snapshot()` can only be used in tests', {
assertion: 't.snapshot()',
}));
}
assertMessage(message, 't.snapshot()');
if (message === '') {
throw fail(new AssertionError('The snapshot assertion message must be a non-empty string', {
assertion: 't.snapshot()',
formattedDetails: [formatWithLabel('Called with:', message)],
}));
}
let result;
try {
result = compareWithSnapshot({expected, message});
} catch (error) {
if (!(error instanceof SnapshotError)) {
throw error;
}
const improperUsage = {assertion: 'snapshot', name: error.name, snapPath: error.snapPath};
if (error instanceof VersionMismatchError) {
improperUsage.snapVersion = error.snapVersion;
improperUsage.expectedVersion = error.expectedVersion;
}
throw fail(new AssertionError(message ?? 'Could not compare snapshot', {
asssertion: 't.snapshot()',
improperUsage,
}));
}
if (result.pass) {
return pass();
}
if (result.actual) {
throw fail(new AssertionError(message ?? 'Did not match snapshot', {
assertion: 't.snapshot()',
formattedDetails: [formatDescriptorDiff(result.actual, result.expected, {invert: true})],
}));
} else {
// This can only occur in CI environments.
throw fail(new AssertionError(message ?? 'No snapshot available — new snapshots are not created in CI environments', {
assertion: 't.snapshot()',
}));
}
});
this.truthy = withSkip((actual, message) => {
assertMessage(message, 't.truthy()');
if (actual) {
return pass();
}
throw fail(new AssertionError(message, {
assertion: 't.truthy()',
formattedDetails: [formatWithLabel('Value is not truthy:', actual)],
}));
});
this.falsy = withSkip((actual, message) => {
assertMessage(message, 't.falsy()');
if (actual) {
throw fail(new AssertionError(message, {
assertion: 't.falsy()',
formattedDetails: [formatWithLabel('Value is not falsy:', actual)],
}));
}
return pass();
});
this.true = withSkip((actual, message) => {
assertMessage(message, 't.true()');
if (actual === true) {
return pass();
}
throw fail(new AssertionError(message, {
assertion: 't.true()',
formattedDetails: [formatWithLabel('Value is not `true`:', actual)],
}));
});
this.false = withSkip((actual, message) => {
assertMessage(message, 't.false()');
if (actual === false) {
return pass();
}
throw fail(new AssertionError(message, {
assertion: 't.false()',
formattedDetails: [formatWithLabel('Value is not `false`:', actual)],
}));
});
this.regex = withSkip((string, regex, message) => {
assertMessage(message, 't.regex()');
if (typeof string !== 'string') {
throw fail(new AssertionError('`t.regex()` must be called with a string', {
assertion: 't.regex()',
formattedDetails: [formatWithLabel('Called with:', string)],
}));
}
if (!(regex instanceof RegExp)) {
throw fail(new AssertionError('`t.regex()` must be called with a regular expression', {
assertion: 't.regex()',
formattedDetails: [formatWithLabel('Called with:', regex)],
}));
}
if (!regex.test(string)) {
throw fail(new AssertionError(message, {
assertion: 't.regex()',
formattedDetails: [
formatWithLabel('Value must match expression:', string),
formatWithLabel('Regular expression:', regex),
],
}));
}
return pass();
});
this.notRegex = withSkip((string, regex, message) => {
assertMessage(message, 't.notRegex()');
if (typeof string !== 'string') {
throw fail(new AssertionError('`t.notRegex()` must be called with a string', {
assertion: 't.notRegex()',
formattedDetails: [formatWithLabel('Called with:', string)],
}));
}
if (!(regex instanceof RegExp)) {
throw fail(new AssertionError('`t.notRegex()` must be called with a regular expression', {
assertion: 't.notRegex()',
formattedDetails: [formatWithLabel('Called with:', regex)],
}));
}
if (regex.test(string)) {
throw fail(new AssertionError(message, {
assertion: 't.notRegex()',
formattedDetails: [
formatWithLabel('Value must not match expression:', string),
formatWithLabel('Regular expression:', regex),
],
}));
}
return pass();
});
this.assert = withSkip((actual, message) => {
assertMessage(message, 't.assert()');
if (!actual) {
throw fail(new AssertionError(message, {
assertion: 't.assert()',
formattedDetails: [formatWithLabel('Value is not truthy:', actual)],
}));
}
return pass();
});
}
}