lib: restructure assert to become a class

PR-URL: https://github.com/nodejs/node/pull/58253
Reviewed-By: Ruben Bridgewater <ruben@bridgewater.de>
Reviewed-By: Michaël Zasso <targos@protonmail.com>
Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com>
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
This commit is contained in:
Miguel Marcondes Filho 2025-08-05 11:15:02 -03:00 committed by GitHub
parent 3090def635
commit 4f5d11e6fb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 823 additions and 49 deletions

View file

@ -21,6 +21,7 @@
'use strict';
const {
ArrayPrototypeForEach,
ArrayPrototypeIndexOf,
ArrayPrototypeJoin,
ArrayPrototypePush,
@ -28,6 +29,7 @@ const {
Error,
NumberIsNaN,
ObjectAssign,
ObjectDefineProperty,
ObjectIs,
ObjectKeys,
ObjectPrototypeIsPrototypeOf,
@ -37,11 +39,13 @@ const {
StringPrototypeIndexOf,
StringPrototypeSlice,
StringPrototypeSplit,
Symbol,
} = primordials;
const {
codes: {
ERR_AMBIGUOUS_ARGUMENT,
ERR_CONSTRUCT_CALL_REQUIRED,
ERR_INVALID_ARG_TYPE,
ERR_INVALID_ARG_VALUE,
ERR_INVALID_RETURN_VALUE,
@ -54,13 +58,16 @@ const {
isPromise,
isRegExp,
} = require('internal/util/types');
const { isError } = require('internal/util');
const { isError, setOwnProperty } = require('internal/util');
const { innerOk } = require('internal/assert/utils');
const {
validateFunction,
validateOneOf,
} = require('internal/validators');
const kOptions = Symbol('options');
let isDeepEqual;
let isDeepStrictEqual;
let isPartialStrictEqual;
@ -80,12 +87,60 @@ const assert = module.exports = ok;
const NO_EXCEPTION_SENTINEL = {};
/**
* Assert options.
* @typedef {object} AssertOptions
* @property {'full'|'simple'} [diff='simple'] - If set to 'full', shows the full diff in assertion errors.
* @property {boolean} [strict=true] - If set to true, non-strict methods behave like their corresponding
* strict methods.
*/
/**
* @class Assert
* @param {AssertOptions} [options] - Optional configuration for assertions.
* @throws {ERR_CONSTRUCT_CALL_REQUIRED} If not called with `new`.
*/
function Assert(options) {
if (!new.target) {
throw new ERR_CONSTRUCT_CALL_REQUIRED('Assert');
}
options = ObjectAssign({ __proto__: null, strict: true }, options);
const allowedDiffs = ['simple', 'full'];
if (options.diff !== undefined) {
validateOneOf(options.diff, 'options.diff', allowedDiffs);
}
this.AssertionError = AssertionError;
ObjectDefineProperty(this, kOptions, {
__proto__: null,
value: options,
enumerable: false,
configurable: false,
writable: false,
});
if (options.strict) {
this.equal = this.strictEqual;
this.deepEqual = this.deepStrictEqual;
this.notEqual = this.notStrictEqual;
this.notDeepEqual = this.notDeepStrictEqual;
}
}
// All of the following functions must throw an AssertionError
// when a corresponding condition is not met, with a message that
// may be undefined if not provided. All assertion methods provide
// both the actual and expected values to the assertion error for
// display purposes.
// DESTRUCTURING WARNING: All Assert.prototype methods use optional chaining
// (this?.[kOptions]) to safely access instance configuration. When methods are
// destructured from an Assert instance (e.g., const {strictEqual} = myAssert),
// they lose their `this` context and will use default behavior instead of the
// instance's custom options.
function innerFail(obj) {
if (obj.message instanceof Error) throw obj.message;
@ -96,7 +151,7 @@ function innerFail(obj) {
* Throws an AssertionError with the given message.
* @param {any | Error} [message]
*/
function fail(message) {
Assert.prototype.fail = function fail(message) {
if (isError(message)) throw message;
let internalMessage = false;
@ -105,19 +160,22 @@ function fail(message) {
internalMessage = true;
}
// IMPORTANT: When adding new references to `this`, ensure they use optional chaining
// (this?.[kOptions]?.diff) to handle cases where the method is destructured from an
// Assert instance and loses its context. Destructured methods will fall back
// to default behavior when `this` is undefined.
const errArgs = {
operator: 'fail',
stackStartFn: fail,
message,
diff: this?.[kOptions]?.diff,
};
const err = new AssertionError(errArgs);
if (internalMessage) {
err.generatedMessage = true;
}
throw err;
}
assert.fail = fail;
};
// The AssertionError is defined in internal/error.
assert.AssertionError = AssertionError;
@ -131,7 +189,17 @@ assert.AssertionError = AssertionError;
function ok(...args) {
innerOk(ok, args.length, ...args);
}
assert.ok = ok;
/**
* Pure assertion tests whether a value is truthy, as determined
* by !!value.
* Duplicated as the other `ok` function is supercharged and exposed as default export.
* @param {...any} args
* @returns {void}
*/
Assert.prototype.ok = function ok(...args) {
innerOk(ok, args.length, ...args);
};
/**
* The equality assertion tests shallow, coercive equality with ==.
@ -140,8 +208,7 @@ assert.ok = ok;
* @param {string | Error} [message]
* @returns {void}
*/
/* eslint-disable no-restricted-properties */
assert.equal = function equal(actual, expected, message) {
Assert.prototype.equal = function equal(actual, expected, message) {
if (arguments.length < 2) {
throw new ERR_MISSING_ARGS('actual', 'expected');
}
@ -153,6 +220,7 @@ assert.equal = function equal(actual, expected, message) {
message,
operator: '==',
stackStartFn: equal,
diff: this?.[kOptions]?.diff,
});
}
};
@ -165,7 +233,7 @@ assert.equal = function equal(actual, expected, message) {
* @param {string | Error} [message]
* @returns {void}
*/
assert.notEqual = function notEqual(actual, expected, message) {
Assert.prototype.notEqual = function notEqual(actual, expected, message) {
if (arguments.length < 2) {
throw new ERR_MISSING_ARGS('actual', 'expected');
}
@ -177,6 +245,7 @@ assert.notEqual = function notEqual(actual, expected, message) {
message,
operator: '!=',
stackStartFn: notEqual,
diff: this?.[kOptions]?.diff,
});
}
};
@ -188,7 +257,7 @@ assert.notEqual = function notEqual(actual, expected, message) {
* @param {string | Error} [message]
* @returns {void}
*/
assert.deepEqual = function deepEqual(actual, expected, message) {
Assert.prototype.deepEqual = function deepEqual(actual, expected, message) {
if (arguments.length < 2) {
throw new ERR_MISSING_ARGS('actual', 'expected');
}
@ -200,6 +269,7 @@ assert.deepEqual = function deepEqual(actual, expected, message) {
message,
operator: 'deepEqual',
stackStartFn: deepEqual,
diff: this?.[kOptions]?.diff,
});
}
};
@ -211,7 +281,7 @@ assert.deepEqual = function deepEqual(actual, expected, message) {
* @param {string | Error} [message]
* @returns {void}
*/
assert.notDeepEqual = function notDeepEqual(actual, expected, message) {
Assert.prototype.notDeepEqual = function notDeepEqual(actual, expected, message) {
if (arguments.length < 2) {
throw new ERR_MISSING_ARGS('actual', 'expected');
}
@ -223,10 +293,10 @@ assert.notDeepEqual = function notDeepEqual(actual, expected, message) {
message,
operator: 'notDeepEqual',
stackStartFn: notDeepEqual,
diff: this?.[kOptions]?.diff,
});
}
};
/* eslint-enable */
/**
* The deep strict equivalence assertion tests a deep strict equality
@ -236,7 +306,7 @@ assert.notDeepEqual = function notDeepEqual(actual, expected, message) {
* @param {string | Error} [message]
* @returns {void}
*/
assert.deepStrictEqual = function deepStrictEqual(actual, expected, message) {
Assert.prototype.deepStrictEqual = function deepStrictEqual(actual, expected, message) {
if (arguments.length < 2) {
throw new ERR_MISSING_ARGS('actual', 'expected');
}
@ -248,6 +318,7 @@ assert.deepStrictEqual = function deepStrictEqual(actual, expected, message) {
message,
operator: 'deepStrictEqual',
stackStartFn: deepStrictEqual,
diff: this?.[kOptions]?.diff,
});
}
};
@ -260,7 +331,7 @@ assert.deepStrictEqual = function deepStrictEqual(actual, expected, message) {
* @param {string | Error} [message]
* @returns {void}
*/
assert.notDeepStrictEqual = notDeepStrictEqual;
Assert.prototype.notDeepStrictEqual = notDeepStrictEqual;
function notDeepStrictEqual(actual, expected, message) {
if (arguments.length < 2) {
throw new ERR_MISSING_ARGS('actual', 'expected');
@ -273,6 +344,7 @@ function notDeepStrictEqual(actual, expected, message) {
message,
operator: 'notDeepStrictEqual',
stackStartFn: notDeepStrictEqual,
diff: this?.[kOptions]?.diff,
});
}
}
@ -284,7 +356,7 @@ function notDeepStrictEqual(actual, expected, message) {
* @param {string | Error} [message]
* @returns {void}
*/
assert.strictEqual = function strictEqual(actual, expected, message) {
Assert.prototype.strictEqual = function strictEqual(actual, expected, message) {
if (arguments.length < 2) {
throw new ERR_MISSING_ARGS('actual', 'expected');
}
@ -295,6 +367,7 @@ assert.strictEqual = function strictEqual(actual, expected, message) {
message,
operator: 'strictEqual',
stackStartFn: strictEqual,
diff: this?.[kOptions]?.diff,
});
}
};
@ -306,7 +379,7 @@ assert.strictEqual = function strictEqual(actual, expected, message) {
* @param {string | Error} [message]
* @returns {void}
*/
assert.notStrictEqual = function notStrictEqual(actual, expected, message) {
Assert.prototype.notStrictEqual = function notStrictEqual(actual, expected, message) {
if (arguments.length < 2) {
throw new ERR_MISSING_ARGS('actual', 'expected');
}
@ -317,6 +390,7 @@ assert.notStrictEqual = function notStrictEqual(actual, expected, message) {
message,
operator: 'notStrictEqual',
stackStartFn: notStrictEqual,
diff: this?.[kOptions]?.diff,
});
}
};
@ -328,7 +402,7 @@ assert.notStrictEqual = function notStrictEqual(actual, expected, message) {
* @param {string | Error} [message]
* @returns {void}
*/
assert.partialDeepStrictEqual = function partialDeepStrictEqual(
Assert.prototype.partialDeepStrictEqual = function partialDeepStrictEqual(
actual,
expected,
message,
@ -344,6 +418,7 @@ assert.partialDeepStrictEqual = function partialDeepStrictEqual(
message,
operator: 'partialDeepStrictEqual',
stackStartFn: partialDeepStrictEqual,
diff: this?.[kOptions]?.diff,
});
}
};
@ -377,6 +452,7 @@ function compareExceptionKey(actual, expected, key, message, keys, fn) {
expected: b,
operator: 'deepStrictEqual',
stackStartFn: fn,
diff: this?.[kOptions]?.diff,
});
err.actual = actual;
err.expected = expected;
@ -389,6 +465,7 @@ function compareExceptionKey(actual, expected, key, message, keys, fn) {
message,
operator: fn.name,
stackStartFn: fn,
diff: this?.[kOptions]?.diff,
});
}
}
@ -418,6 +495,7 @@ function expectedException(actual, expected, message, fn) {
message,
operator: 'deepStrictEqual',
stackStartFn: fn,
diff: this?.[kOptions]?.diff,
});
err.operator = fn.name;
throw err;
@ -493,6 +571,7 @@ function expectedException(actual, expected, message, fn) {
message,
operator: fn.name,
stackStartFn: fn,
diff: this?.[kOptions]?.diff,
});
err.generatedMessage = generatedMessage;
throw err;
@ -580,20 +659,21 @@ function expectsError(stackStartFn, actual, error, message) {
details += ` (${error.name})`;
}
details += message ? `: ${message}` : '.';
const fnType = stackStartFn === assert.rejects ? 'rejection' : 'exception';
const fnType = stackStartFn === Assert.prototype.rejects ? 'rejection' : 'exception';
innerFail({
actual: undefined,
expected: error,
operator: stackStartFn.name,
message: `Missing expected ${fnType}${details}`,
stackStartFn,
diff: this?.[kOptions]?.diff,
});
}
if (!error)
return;
expectedException(actual, error, message, stackStartFn);
expectedException.call(this, actual, error, message, stackStartFn);
}
function hasMatchingError(actual, expected) {
@ -627,7 +707,7 @@ function expectsNoError(stackStartFn, actual, error, message) {
if (!error || hasMatchingError(actual, error)) {
const details = message ? `: ${message}` : '.';
const fnType = stackStartFn === assert.doesNotReject ?
const fnType = stackStartFn === Assert.prototype.doesNotReject ?
'rejection' : 'exception';
innerFail({
actual,
@ -636,6 +716,7 @@ function expectsNoError(stackStartFn, actual, error, message) {
message: `Got unwanted ${fnType}${details}\n` +
`Actual message: "${actual?.message}"`,
stackStartFn,
diff: this?.[kOptions]?.diff,
});
}
throw actual;
@ -647,7 +728,7 @@ function expectsNoError(stackStartFn, actual, error, message) {
* @param {...any} [args]
* @returns {void}
*/
assert.throws = function throws(promiseFn, ...args) {
Assert.prototype.throws = function throws(promiseFn, ...args) {
expectsError(throws, getActual(promiseFn), ...args);
};
@ -657,7 +738,7 @@ assert.throws = function throws(promiseFn, ...args) {
* @param {...any} [args]
* @returns {Promise<void>}
*/
assert.rejects = async function rejects(promiseFn, ...args) {
Assert.prototype.rejects = async function rejects(promiseFn, ...args) {
expectsError(rejects, await waitForActual(promiseFn), ...args);
};
@ -667,7 +748,7 @@ assert.rejects = async function rejects(promiseFn, ...args) {
* @param {...any} [args]
* @returns {void}
*/
assert.doesNotThrow = function doesNotThrow(fn, ...args) {
Assert.prototype.doesNotThrow = function doesNotThrow(fn, ...args) {
expectsNoError(doesNotThrow, getActual(fn), ...args);
};
@ -677,7 +758,7 @@ assert.doesNotThrow = function doesNotThrow(fn, ...args) {
* @param {...any} [args]
* @returns {Promise<void>}
*/
assert.doesNotReject = async function doesNotReject(fn, ...args) {
Assert.prototype.doesNotReject = async function doesNotReject(fn, ...args) {
expectsNoError(doesNotReject, await waitForActual(fn), ...args);
};
@ -686,7 +767,7 @@ assert.doesNotReject = async function doesNotReject(fn, ...args) {
* @param {any} err
* @returns {void}
*/
assert.ifError = function ifError(err) {
Assert.prototype.ifError = function ifError(err) {
if (err !== null && err !== undefined) {
let message = 'ifError got unwanted exception: ';
if (typeof err === 'object' && typeof err.message === 'string') {
@ -705,6 +786,7 @@ assert.ifError = function ifError(err) {
operator: 'ifError',
message,
stackStartFn: ifError,
diff: this?.[kOptions]?.diff,
});
// Make sure we actually have a stack trace!
@ -747,7 +829,7 @@ function internalMatch(string, regexp, message, fn) {
'regexp', 'RegExp', regexp,
);
}
const match = fn === assert.match;
const match = fn === Assert.prototype.match;
if (typeof string !== 'string' ||
RegExpPrototypeExec(regexp, string) !== null !== match) {
if (message instanceof Error) {
@ -770,6 +852,7 @@ function internalMatch(string, regexp, message, fn) {
message,
operator: fn.name,
stackStartFn: fn,
diff: this?.[kOptions]?.diff,
});
err.generatedMessage = generatedMessage;
throw err;
@ -783,7 +866,7 @@ function internalMatch(string, regexp, message, fn) {
* @param {string | Error} [message]
* @returns {void}
*/
assert.match = function match(string, regexp, message) {
Assert.prototype.match = function match(string, regexp, message) {
internalMatch(string, regexp, message, match);
};
@ -794,7 +877,7 @@ assert.match = function match(string, regexp, message) {
* @param {string | Error} [message]
* @returns {void}
*/
assert.doesNotMatch = function doesNotMatch(string, regexp, message) {
Assert.prototype.doesNotMatch = function doesNotMatch(string, regexp, message) {
internalMatch(string, regexp, message, doesNotMatch);
};
@ -807,6 +890,17 @@ function strict(...args) {
innerOk(strict, args.length, ...args);
}
// TODO(aduh95): take `ok` from `Assert.prototype` instead of a self-ref in a next major.
assert.ok = assert;
ArrayPrototypeForEach([
'fail', 'equal', 'notEqual', 'deepEqual', 'notDeepEqual',
'deepStrictEqual', 'notDeepStrictEqual', 'strictEqual',
'notStrictEqual', 'partialDeepStrictEqual', 'match', 'doesNotMatch',
'throws', 'rejects', 'doesNotThrow', 'doesNotReject', 'ifError',
], (name) => {
setOwnProperty(assert, name, Assert.prototype[name]);
});
assert.strict = ObjectAssign(strict, assert, {
equal: assert.strictEqual,
deepEqual: assert.deepStrictEqual,
@ -814,4 +908,7 @@ assert.strict = ObjectAssign(strict, assert, {
notDeepEqual: assert.notDeepStrictEqual,
});
assert.strict.Assert = Assert;
assert.strict.strict = assert.strict;
assert.Assert = Assert;