diff --git a/lib/assert.js b/lib/assert.js index 657ac6225d9833..c8c5b5292ffc6a 100644 --- a/lib/assert.js +++ b/lib/assert.js @@ -21,6 +21,7 @@ 'use strict'; const { + ArrayIsArray, ArrayPrototypeIndexOf, ArrayPrototypeJoin, ArrayPrototypePush, @@ -28,11 +29,14 @@ const { Error, NumberIsNaN, ObjectAssign, + ObjectDefineProperty, + ObjectGetPrototypeOf, ObjectIs, ObjectKeys, ObjectPrototypeIsPrototypeOf, ReflectApply, RegExpPrototypeExec, + SafeSet, String, StringPrototypeIndexOf, StringPrototypeSlice, @@ -73,6 +77,54 @@ function lazyLoadComparison() { isPartialStrictEqual = comparison.isPartialStrictEqual; } +let lazyAsymmetricMatchers; +function loadAsymmetricMatchers() { + if (lazyAsymmetricMatchers === undefined) { + lazyAsymmetricMatchers = require('internal/assert/asymmetric_matchers'); + } + return lazyAsymmetricMatchers; +} + +function isAsymmetricMatcher(val) { + return loadAsymmetricMatchers().isAsymmetricMatcher(val); +} + +// Scan objects for nested matchers so `assert.equal(obj, objWithMatchers)` can +// route into deep-equal instead of falling through to `==` reference compare. +// Arrays and plain objects are traversed; other object kinds are not (nested +// matchers inside Map/Set/class instances still compare correctly via +// `isDeepEqual`, but we don't trigger the deep-walk fallback on them). +const kPlainObjectProto = ObjectGetPrototypeOf({}); +function containsMatcher(value, seen) { + if (isAsymmetricMatcher(value)) return true; + if (value === null || typeof value !== 'object') return false; + if (seen.has(value)) return false; + if (ArrayIsArray(value)) { + seen.add(value); + for (let i = 0; i < value.length; i++) { + if (containsMatcher(value[i], seen)) return true; + } + return false; + } + const proto = ObjectGetPrototypeOf(value); + if (proto !== null && proto !== kPlainObjectProto) return false; + seen.add(value); + const keys = ObjectKeys(value); + for (let i = 0; i < keys.length; i++) { + let child; + try { + child = value[keys[i]]; + } catch { + // Getter threw (e.g. a vm context's cross-realm `crypto` guard). If we + // can't read the property we can't match it against an asymmetric + // matcher either, so conservatively treat the subtree as matcher-free. + continue; + } + if (containsMatcher(child, seen)) return true; + } + return false; +} + let warned = false; // The assert module provides functions that throw @@ -170,6 +222,28 @@ assert.equal = function equal(actual, expected, message) { if (arguments.length < 2) { throw new ERR_MISSING_ARGS('actual', 'expected'); } + if (isAsymmetricMatcher(expected)) { + if (!expected.asymmetricMatch(actual)) { + innerFail({ actual, expected, message, operator: '==', stackStartFn: equal }); + } + return; + } + if (isAsymmetricMatcher(actual)) { + if (!actual.asymmetricMatch(expected)) { + innerFail({ actual, expected, message, operator: '==', stackStartFn: equal }); + } + return; + } + // Delegate to deep-equal when expected holds matchers, so that + // `assert.equal({ name: 'foo' }, { name: asymmetric.any(String) })` matches. + if (typeof expected === 'object' && expected !== null && + containsMatcher(expected, new SafeSet())) { + if (isDeepEqual === undefined) lazyLoadComparison(); + if (!isDeepEqual(actual, expected)) { + innerFail({ actual, expected, message, operator: '==', stackStartFn: equal }); + } + return; + } // eslint-disable-next-line eqeqeq if (actual != expected && (!NumberIsNaN(actual) || !NumberIsNaN(expected))) { innerFail({ @@ -313,6 +387,28 @@ assert.strictEqual = function strictEqual(actual, expected, message) { if (arguments.length < 2) { throw new ERR_MISSING_ARGS('actual', 'expected'); } + if (isAsymmetricMatcher(expected)) { + if (!expected.asymmetricMatch(actual)) { + innerFail({ actual, expected, message, operator: 'strictEqual', stackStartFn: strictEqual }); + } + return; + } + if (isAsymmetricMatcher(actual)) { + if (!actual.asymmetricMatch(expected)) { + innerFail({ actual, expected, message, operator: 'strictEqual', stackStartFn: strictEqual }); + } + return; + } + // Route through deep-strict-equal when expected holds matchers so nested + // matcher usage works the same way as with deepStrictEqual. + if (typeof expected === 'object' && expected !== null && + containsMatcher(expected, new SafeSet())) { + if (isDeepEqual === undefined) lazyLoadComparison(); + if (!isDeepStrictEqual(actual, expected)) { + innerFail({ actual, expected, message, operator: 'strictEqual', stackStartFn: strictEqual }); + } + return; + } if (!ObjectIs(actual, expected)) { innerFail({ actual, @@ -324,6 +420,23 @@ assert.strictEqual = function strictEqual(actual, expected, message) { } }; +let lazyAsymmetricFactory; +function getAsymmetric() { + if (lazyAsymmetricFactory === undefined) { + lazyAsymmetricFactory = loadAsymmetricMatchers().asymmetric; + } + return lazyAsymmetricFactory; +} + +// Lazy-loaded on first access so the asymmetric matchers module is only +// required when actually used. +ObjectDefineProperty(assert, 'asymmetric', { + __proto__: null, + configurable: true, + enumerable: true, + get() { return getAsymmetric(); }, +}); + /** * The strict non-equivalence assertion tests for any strict inequality. * @param {any} actual diff --git a/lib/internal/assert/assertion_error.js b/lib/internal/assert/assertion_error.js index d654ca5038bbab..da134059cfb3fc 100644 --- a/lib/internal/assert/assertion_error.js +++ b/lib/internal/assert/assertion_error.js @@ -1,17 +1,23 @@ 'use strict'; const { + ArrayIsArray, ArrayPrototypeJoin, + ArrayPrototypeMap, ArrayPrototypePop, ArrayPrototypeSlice, Error, ErrorCaptureStackTrace, ObjectAssign, + ObjectCreate, ObjectDefineProperty, ObjectGetPrototypeOf, + ObjectKeys, ObjectPrototypeHasOwnProperty, + SafeSet, String, StringPrototypeRepeat, + StringPrototypeReplaceAll, StringPrototypeSlice, StringPrototypeSplit, } = primordials; @@ -44,6 +50,105 @@ const kMaxLongStringLength = 512; const kMethodsWithCustomMessageDiff = ['deepStrictEqual', 'strictEqual', 'partialDeepStrictEqual']; +// Sentinel delimiters used to pre-walk asymmetric matchers into `inspect`-able +// string tokens, then post-process to strip quotes so matchers render inline. +// The delimiters are intentionally unlikely to appear in user data and contain +// no characters that `inspect` would escape. +const kAsymMark = '__~ASYM_MATCHER~__'; +// Handles '...' and "..." quoting that inspect may choose. +const kAsymRegex = /(['"`])__~ASYM_MATCHER~__([\s\S]*?)__~ASYM_MATCHER~__\1/g; + +let lazyIsAsymmetricMatcher; +function isAsymmetricMatcher(val) { + if (lazyIsAsymmetricMatcher === undefined) { + lazyIsAsymmetricMatcher = + require('internal/assert/asymmetric_matchers').isAsymmetricMatcher; + } + return lazyIsAsymmetricMatcher(val); +} + +const kObjectProto = ObjectGetPrototypeOf({}); + +function containsAsymmetricMatcher(value, seen) { + if (isAsymmetricMatcher(value)) return true; + if (value === null || typeof value !== 'object') return false; + if (seen.has(value)) return false; + if (ArrayIsArray(value)) { + seen.add(value); + for (let i = 0; i < value.length; i++) { + if (containsAsymmetricMatcher(value[i], seen)) return true; + } + return false; + } + // Only descend into plain objects. Walking arbitrary class instances (e.g. + // the test/common Proxy) can trigger getters that throw or have observable + // side effects. Matchers nested inside non-plain containers still compare + // correctly — they just won't get the custom diff rendering. + const proto = ObjectGetPrototypeOf(value); + if (proto !== null && proto !== kObjectProto) return false; + seen.add(value); + const keys = ObjectKeys(value); + for (let i = 0; i < keys.length; i++) { + let child; + try { + child = value[keys[i]]; + } catch { + continue; + } + if (containsAsymmetricMatcher(child, seen)) return true; + } + return false; +} + +// Deep-clones `value`, substituting every asymmetric matcher with a sentinel +// string. `inspect` renders those strings quoted; we post-process the final +// output to drop the quotes so matchers appear as e.g. `Any`. Only +// plain objects and arrays are traversed; other object kinds are returned +// unchanged so their `inspect` output (class tag, special formatting) is +// preserved. +function substituteAsymmetric(value, seen) { + if (isAsymmetricMatcher(value)) { + return `${kAsymMark}${value.toAsymmetricMatcher()}${kAsymMark}`; + } + if (value === null || typeof value !== 'object') { + return value; + } + if (seen.has(value)) { + return value; + } + // Only clone (and mutate) if the subtree actually contains matchers. + // Cloning otherwise would drop class identity / non-index array props / + // toStringTag from the inspect output. + if (!containsAsymmetricMatcher(value, new SafeSet())) { + return value; + } + if (ArrayIsArray(value)) { + seen.add(value); + const out = []; + for (let i = 0; i < value.length; i++) { + out[i] = substituteAsymmetric(value[i], seen); + } + return out; + } + seen.add(value); + const proto = ObjectGetPrototypeOf(value); + const result = proto === null ? ObjectCreate(null) : {}; + const keys = ObjectKeys(value); + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + result[key] = substituteAsymmetric(value[key], seen); + } + return result; +} + +function stripAsymmetricQuotes(inspected) { + return StringPrototypeReplaceAll( + inspected, + kAsymRegex, + (_match, _quote, content) => content, + ); +} + function copyError(source) { const target = ObjectAssign( { __proto__: ObjectGetPrototypeOf(source) }, @@ -68,7 +173,8 @@ function copyError(source) { function inspectValue(val) { // The util.inspect default values could be changed. This makes sure the // error messages contain the necessary information nevertheless. - return inspect(val, { + const sanitized = substituteAsymmetric(val, new SafeSet()); + const out = inspect(sanitized, { compact: false, customInspect: false, depth: 1000, @@ -81,6 +187,7 @@ function inspectValue(val) { // Inspect getters as we also check them when comparing entries. getters: true, }); + return stripAsymmetricQuotes(out); } function getErrorMessage(operator, message) { @@ -89,9 +196,12 @@ function getErrorMessage(operator, message) { function checkOperator(actual, expected, operator) { // In case both values are objects or functions explicitly mark them as not - // reference equal for the `strictEqual` operator. + // reference equal for the `strictEqual` operator. Asymmetric matchers aren't + // reference-equality checks, so keep the plain `strictEqual` message. if ( operator === 'strictEqual' && + !isAsymmetricMatcher(actual) && + !isAsymmetricMatcher(expected) && ((typeof actual === 'object' && actual !== null && typeof expected === 'object' && diff --git a/lib/internal/assert/asymmetric_matchers.js b/lib/internal/assert/asymmetric_matchers.js new file mode 100644 index 00000000000000..debf90d6dc8108 --- /dev/null +++ b/lib/internal/assert/asymmetric_matchers.js @@ -0,0 +1,384 @@ +'use strict'; + +const { + ArrayIsArray, + ArrayPrototypeEvery, + ArrayPrototypeSome, + BigInt, + Boolean, + MathAbs, + MathPow, + Number, + NumberIsNaN, + ObjectGetPrototypeOf, + ObjectKeys, + ObjectPrototypeHasOwnProperty: hasOwn, + RegExp, + RegExpPrototypeTest, + String, + StringPrototypeIncludes, + Symbol, + SymbolFor, +} = primordials; + +const { + codes: { + ERR_INVALID_ARG_TYPE, + ERR_INVALID_ARG_VALUE, + }, +} = require('internal/errors'); + +let lazyInspectCustom; +function inspectCustom() { + if (lazyInspectCustom === undefined) { + lazyInspectCustom = require('internal/util/inspect').customInspectSymbol; + } + return lazyInspectCustom; +} + +let lazyIsDeepStrictEqual; +function isDeepStrictEqual(a, b) { + if (lazyIsDeepStrictEqual === undefined) { + lazyIsDeepStrictEqual = + require('internal/util/comparisons').isDeepStrictEqual; + } + return lazyIsDeepStrictEqual(a, b); +} + +// A Symbol.for'd key so alternative implementations can still be recognized +// across realms. Detection uses `hasOwnProperty` so hostile Proxies that throw +// on unknown `get` traps (e.g. the `test/common` guard) don't break comparisons +// that walk arbitrary object graphs. +const kAsymmetricMatcher = SymbolFor('nodejs.assert.asymmetricMatcher'); + +function isAsymmetricMatcher(value) { + return value !== null && + typeof value === 'object' && + hasOwn(value, kAsymmetricMatcher); +} + +function fnNameFor(ctor) { + if (typeof ctor === 'function' && ctor.name) { + return ctor.name; + } + return String(ctor); +} + +class AsymmetricMatcher { + constructor(sample, inverse = false) { + this.sample = sample; + this.inverse = inverse; + // Own property so `hasOwnProperty` can safely detect matchers without + // tripping hostile Proxies in tests. + this[kAsymmetricMatcher] = true; + } + + asymmetricMatch(_other) { // eslint-disable-line no-unused-vars + throw new Error('asymmetricMatch must be implemented by subclasses'); + } + + toString() { + return 'AsymmetricMatcher'; + } + + getExpectedType() { + return undefined; + } + + toAsymmetricMatcher() { + return this.toString(); + } +} + + +// Custom inspect so that `util.inspect(any(String))` renders as `Any` +// instead of a structural object dump. This drives the diff output via +// assertion_error.js, which pre-walks matchers into inspect-friendly tokens. +function setInspect(proto) { + proto[inspectCustom()] = function inspect() { + return this.toAsymmetricMatcher(); + }; +} + +class Any extends AsymmetricMatcher { + constructor(sample) { + if (sample === undefined) { + throw new ERR_INVALID_ARG_VALUE( + 'sample', + sample, + 'any() expects to be passed a constructor function. ' + + 'Use anything() to match any value except null and undefined.', + ); + } + super(sample); + } + + asymmetricMatch(other) { + if (this.sample === String) { + return typeof other === 'string' || other instanceof String; + } + if (this.sample === Number) { + return typeof other === 'number' || other instanceof Number; + } + if (this.sample === Function) { + return typeof other === 'function' || other instanceof Function; + } + if (this.sample === Boolean) { + return typeof other === 'boolean' || other instanceof Boolean; + } + if (this.sample === BigInt) { + return typeof other === 'bigint' || other instanceof BigInt; + } + if (this.sample === Symbol) { + return typeof other === 'symbol' || other instanceof Symbol; + } + if (this.sample === Object) { + return typeof other === 'object' && other !== null; + } + if (this.sample === Array) { + return ArrayIsArray(other); + } + if (typeof this.sample !== 'function') { + throw new ERR_INVALID_ARG_TYPE('sample', 'Function', this.sample); + } + return other instanceof this.sample; + } + + toString() { + return 'Any'; + } + + getExpectedType() { + if (this.sample === String) return 'string'; + if (this.sample === Number) return 'number'; + if (this.sample === Function) return 'function'; + if (this.sample === Object) return 'object'; + if (this.sample === Boolean) return 'boolean'; + if (this.sample === BigInt) return 'bigint'; + if (this.sample === Symbol) return 'symbol'; + if (this.sample === Array) return 'array'; + return fnNameFor(this.sample); + } + + toAsymmetricMatcher() { + return `Any<${fnNameFor(this.sample)}>`; + } +} +setInspect(Any.prototype); + +class Anything extends AsymmetricMatcher { + constructor() { + super(undefined); + } + + asymmetricMatch(other) { + return other !== null && other !== undefined; + } + + toString() { + return 'Anything'; + } + + toAsymmetricMatcher() { + return 'Anything'; + } +} +setInspect(Anything.prototype); + +class StringContaining extends AsymmetricMatcher { + constructor(sample, inverse = false) { + if (typeof sample !== 'string') { + throw new ERR_INVALID_ARG_TYPE('sample', 'string', sample); + } + super(sample, inverse); + } + + asymmetricMatch(other) { + const result = typeof other === 'string' && + StringPrototypeIncludes(other, this.sample); + return this.inverse ? !result : result; + } + + toString() { + return this.inverse ? 'StringNotContaining' : 'StringContaining'; + } + + getExpectedType() { + return 'string'; + } + + toAsymmetricMatcher() { + return `${this.toString()}<${JSON.stringify(this.sample)}>`; + } +} +setInspect(StringContaining.prototype); + +class StringMatching extends AsymmetricMatcher { + constructor(sample, inverse = false) { + if (typeof sample !== 'string' && !(sample instanceof RegExp)) { + throw new ERR_INVALID_ARG_TYPE('sample', ['string', 'RegExp'], sample); + } + super(new RegExp(sample), inverse); + } + + asymmetricMatch(other) { + const result = typeof other === 'string' && + RegExpPrototypeTest(this.sample, other); + return this.inverse ? !result : result; + } + + toString() { + return this.inverse ? 'StringNotMatching' : 'StringMatching'; + } + + getExpectedType() { + return 'string'; + } + + toAsymmetricMatcher() { + return `${this.toString()}<${this.sample}>`; + } +} +setInspect(StringMatching.prototype); + +class ArrayContaining extends AsymmetricMatcher { + constructor(sample, inverse = false) { + if (!ArrayIsArray(sample)) { + throw new ERR_INVALID_ARG_TYPE('sample', 'Array', sample); + } + super(sample, inverse); + } + + asymmetricMatch(other) { + const result = this.sample.length === 0 || + (ArrayIsArray(other) && + ArrayPrototypeEvery(this.sample, (item) => + ArrayPrototypeSome(other, (another) => isDeepStrictEqual(item, another)), + )); + return this.inverse ? !result : result; + } + + toString() { + return this.inverse ? 'ArrayNotContaining' : 'ArrayContaining'; + } + + getExpectedType() { + return 'array'; + } + + toAsymmetricMatcher() { + return this.toString(); + } +} +setInspect(ArrayContaining.prototype); + +class ObjectContaining extends AsymmetricMatcher { + constructor(sample, inverse = false) { + if (sample === null || typeof sample !== 'object') { + throw new ERR_INVALID_ARG_TYPE('sample', 'Object', sample); + } + super(sample, inverse); + } + + asymmetricMatch(other) { + if (other === null || typeof other !== 'object' || ArrayIsArray(other)) { + return this.inverse ? true : false; + } + let result = true; + const keys = ObjectKeys(this.sample); + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + if (!hasOwnProperty(other, key) || + !isDeepStrictEqual(this.sample[key], other[key])) { + result = false; + break; + } + } + return this.inverse ? !result : result; + } + + toString() { + return this.inverse ? 'ObjectNotContaining' : 'ObjectContaining'; + } + + getExpectedType() { + return 'object'; + } + + toAsymmetricMatcher() { + return this.toString(); + } +} +setInspect(ObjectContaining.prototype); + +function hasOwnProperty(obj, key) { + let current = obj; + while (current !== null && current !== undefined) { + if (hasOwn(current, key)) return true; + current = ObjectGetPrototypeOf(current); + } + return false; +} + +class CloseTo extends AsymmetricMatcher { + constructor(sample, precision = 2, inverse = false) { + if (typeof sample !== 'number') { + throw new ERR_INVALID_ARG_TYPE('sample', 'number', sample); + } + if (typeof precision !== 'number') { + throw new ERR_INVALID_ARG_TYPE('precision', 'number', precision); + } + super(sample, inverse); + this.precision = precision; + } + + asymmetricMatch(other) { + if (typeof other !== 'number' || NumberIsNaN(other)) { + return this.inverse ? true : false; + } + let result; + const posInf = 1 / 0; + const negInf = -1 / 0; + if (other === posInf && this.sample === posInf) { + result = true; + } else if (other === negInf && this.sample === negInf) { + result = true; + } else { + result = MathAbs(this.sample - other) < MathPow(10, -this.precision) / 2; + } + return this.inverse ? !result : result; + } + + toString() { + return this.inverse ? 'NumberNotCloseTo' : 'NumberCloseTo'; + } + + getExpectedType() { + return 'number'; + } + + toAsymmetricMatcher() { + return `${this.toString()}<${this.sample}, precision=${this.precision}>`; + } +} +setInspect(CloseTo.prototype); + +module.exports = { + kAsymmetricMatcher, + isAsymmetricMatcher, + AsymmetricMatcher, + asymmetric: { + any: (sample) => new Any(sample), + anything: () => new Anything(), + stringContaining: (sample) => new StringContaining(sample), + stringNotContaining: (sample) => new StringContaining(sample, true), + stringMatching: (sample) => new StringMatching(sample), + stringNotMatching: (sample) => new StringMatching(sample, true), + arrayContaining: (sample) => new ArrayContaining(sample), + arrayNotContaining: (sample) => new ArrayContaining(sample, true), + objectContaining: (sample) => new ObjectContaining(sample), + objectNotContaining: (sample) => new ObjectContaining(sample, true), + closeTo: (sample, precision) => new CloseTo(sample, precision), + notCloseTo: (sample, precision) => new CloseTo(sample, precision, true), + }, +}; diff --git a/lib/internal/test_runner/assert.js b/lib/internal/test_runner/assert.js index 776c1e25cbfb61..678eebfa39336d 100644 --- a/lib/internal/test_runner/assert.js +++ b/lib/internal/test_runner/assert.js @@ -8,6 +8,7 @@ const { } = require('internal/validators'); const assert = require('assert'); const methodsToCopy = [ + 'asymmetric', 'deepEqual', 'deepStrictEqual', 'doesNotMatch', diff --git a/lib/internal/test_runner/test.js b/lib/internal/test_runner/test.js index bdeeddd0892c1b..7f1ef93a46b608 100644 --- a/lib/internal/test_runner/test.js +++ b/lib/internal/test_runner/test.js @@ -313,6 +313,12 @@ class TestContext { this.#assert = assert; map.forEach((method, name) => { + // Non-function entries (e.g. the `asymmetric` matcher factory namespace) + // are exposed directly rather than wrapped with plan-count instrumentation. + if (typeof method !== 'function') { + assert[name] = method; + return; + } assert[name] = (...args) => { if (plan !== null) { plan.count(); diff --git a/lib/internal/util/comparisons.js b/lib/internal/util/comparisons.js index 7eb9c72119eb92..5f026eb90899e9 100644 --- a/lib/internal/util/comparisons.js +++ b/lib/internal/util/comparisons.js @@ -70,6 +70,21 @@ const kIsMap = 3; let kKeyObject; +let lazyAsymmetricMatcherKey; +function asymmetricMatcherKey() { + if (lazyAsymmetricMatcherKey === undefined) { + lazyAsymmetricMatcherKey = + require('internal/assert/asymmetric_matchers').kAsymmetricMatcher; + } + return lazyAsymmetricMatcherKey; +} + +function isAsymmetricMatcher(val) { + return val !== null && + typeof val === 'object' && + hasOwn(val, asymmetricMatcherKey()); +} + // Check if they have the same source and flags function areSimilarRegExps(a, b) { return a.source === b.source && @@ -177,6 +192,15 @@ function isEnumerableOrIdentical(val1, val2, prop, mode, memos, method) { // b) The same prototypes. function innerDeepEqual(val1, val2, mode, memos) { + // Asymmetric matchers short-circuit equality. Checked before the identity + // fast path so matchers are never compared by reference to themselves. + if (isAsymmetricMatcher(val2)) { + return val2.asymmetricMatch(val1); + } + if (isAsymmetricMatcher(val1)) { + return val1.asymmetricMatch(val2); + } + // All identical values are equivalent, as determined by ===. if (val1 === val2) { return val1 !== 0 || ObjectIs(val1, val2) || mode === kLoose; diff --git a/test/parallel/test-assert-asymmetric-matchers.mjs b/test/parallel/test-assert-asymmetric-matchers.mjs new file mode 100644 index 00000000000000..b3ff93595794ba --- /dev/null +++ b/test/parallel/test-assert-asymmetric-matchers.mjs @@ -0,0 +1,198 @@ +// Flags: --expose-internals +import '../common/index.mjs'; +import assert from 'node:assert'; +import { test } from 'node:test'; + +const { asymmetric } = assert; + +test('asymmetric.any matches primitives and boxed versions', () => { + assert.equal('Node.js', asymmetric.any(String)); + assert.equal(new String('Node.js'), asymmetric.any(String)); + assert.equal(123, asymmetric.any(Number)); + assert.equal(4.56, asymmetric.any(Number)); + assert.equal(new Number(123), asymmetric.any(Number)); + assert.equal(true, asymmetric.any(Boolean)); + assert.equal(1n, asymmetric.any(BigInt)); + assert.equal(() => {}, asymmetric.any(Function)); + assert.equal({}, asymmetric.any(Object)); + assert.equal([], asymmetric.any(Array)); +}); + +test('asymmetric.any rejects wrong types', () => { + assert.throws(() => assert.equal(123, asymmetric.any(String)), { + name: 'AssertionError', + }); + assert.throws(() => assert.equal('x', asymmetric.any(Number)), { + name: 'AssertionError', + }); +}); + +test('asymmetric.any works with user-defined classes', () => { + class Animal {} + class Dog extends Animal {} + assert.equal(new Dog(), asymmetric.any(Animal)); + assert.equal(new Dog(), asymmetric.any(Dog)); + assert.throws(() => assert.equal(new Animal(), asymmetric.any(Dog))); +}); + +test('asymmetric.any requires a constructor', () => { + assert.throws(() => asymmetric.any(), { code: 'ERR_INVALID_ARG_VALUE' }); +}); + +test('asymmetric matchers work inside deepStrictEqual', () => { + assert.deepStrictEqual( + { name: 'foo', age: 42 }, + { name: asymmetric.any(String), age: asymmetric.any(Number) }, + ); + + assert.deepStrictEqual( + { a: { b: { c: 42, d: 'edy', e: { f: 'foo', g: 'bar' } } } }, + { + a: { + b: { + c: asymmetric.any(Number), + d: asymmetric.any(String), + e: asymmetric.any(Object), + }, + }, + }, + ); +}); + +test('asymmetric matchers work inside arrays', () => { + assert.deepStrictEqual( + [1, 'two', true], + [asymmetric.any(Number), asymmetric.any(String), asymmetric.any(Boolean)], + ); +}); + +test('asymmetric matchers fail deepStrictEqual on mismatch', () => { + assert.throws( + () => assert.deepStrictEqual( + { name: 'foo' }, + { name: asymmetric.any(Number) }, + ), + { name: 'AssertionError' }, + ); +}); + +test('asymmetric.anything matches anything except null/undefined', () => { + assert.equal(0, asymmetric.anything()); + assert.equal('', asymmetric.anything()); + assert.equal(false, asymmetric.anything()); + assert.equal({}, asymmetric.anything()); + + assert.throws(() => assert.equal(null, asymmetric.anything())); + assert.throws(() => assert.equal(undefined, asymmetric.anything())); +}); + +test('asymmetric.stringContaining matches substring', () => { + assert.equal('Node.js is fast', asymmetric.stringContaining('Node')); + assert.throws(() => assert.equal('Python', asymmetric.stringContaining('Node'))); + assert.throws(() => assert.equal(42, asymmetric.stringContaining('4'))); +}); + +test('asymmetric.stringMatching matches regex', () => { + assert.equal('v20.1.0', asymmetric.stringMatching(/^v\d+\.\d+\.\d+$/)); + assert.equal('hello', asymmetric.stringMatching('hel')); + assert.throws(() => assert.equal('x', asymmetric.stringMatching(/^y/))); +}); + +test('asymmetric.arrayContaining matches subset', () => { + assert.deepStrictEqual( + { list: [1, 2, 3, 4] }, + { list: asymmetric.arrayContaining([2, 3]) }, + ); + assert.throws(() => assert.deepStrictEqual( + { list: [1, 2] }, + { list: asymmetric.arrayContaining([3]) }, + )); +}); + +test('asymmetric.objectContaining matches subset', () => { + assert.deepStrictEqual( + { name: 'foo', age: 42, extra: 'x' }, + asymmetric.objectContaining({ name: 'foo', age: 42 }), + ); + assert.throws(() => assert.deepStrictEqual( + { name: 'foo' }, + asymmetric.objectContaining({ name: 'foo', age: 42 }), + )); +}); + +test('asymmetric.closeTo matches numeric proximity', () => { + assert.equal(1.2345, asymmetric.closeTo(1.23)); + assert.equal(0.1 + 0.2, asymmetric.closeTo(0.3, 5)); + assert.throws(() => assert.equal(1.5, asymmetric.closeTo(1.0, 2))); +}); + +test('asymmetric.not* variants invert', () => { + assert.equal('Python', asymmetric.stringNotContaining('Node')); + assert.throws(() => assert.equal('Node.js', asymmetric.stringNotContaining('Node'))); +}); + +test('error message shows matcher inline in diff', () => { + let err; + try { + assert.deepStrictEqual( + { name: 'foo' }, + { name: asymmetric.any(Number) }, + ); + } catch (e) { + err = e; + } + assert(err, 'expected AssertionError to be thrown'); + // Matcher should appear without surrounding quotes in the diff. + assert.match(err.message, /Any/); + assert.doesNotMatch(err.message, /'Any'/); +}); + +test('error message for simple equal shows Any', () => { + let err; + try { + assert.equal('Node.js', asymmetric.any(Number)); + } catch (e) { + err = e; + } + assert(err, 'expected AssertionError to be thrown'); + assert.match(err.message, /Any/); +}); + +test('strictEqual with asymmetric matcher', () => { + assert.strictEqual('Node.js', asymmetric.any(String)); + assert.throws(() => assert.strictEqual(123, asymmetric.any(String))); +}); + +test('cycles in expected are handled', () => { + const a = {}; + a.self = a; + // Should match structurally — matcher comparison won't recurse into the cycle. + assert.deepStrictEqual( + { name: 'foo', ref: a }, + { name: asymmetric.any(String), ref: a }, + ); +}); + +test('nested matcher inside objectContaining', () => { + assert.deepStrictEqual( + { id: 1, name: 'alice' }, + asymmetric.objectContaining({ name: asymmetric.any(String) }), + ); +}); + +test('asymmetric is also exposed on assert/strict', () => { + const strict = assert.strict; + assert.strictEqual(strict.asymmetric, asymmetric); +}); + +test('matcher used on the actual side also works', () => { + assert.equal(asymmetric.any(String), 'foo'); + assert.throws(() => assert.equal(asymmetric.any(Number), 'foo')); +}); + +test('matchers integrate with node:test t.assert.asymmetric', (t) => { + t.assert.deepStrictEqual( + { name: 'foo', age: 42 }, + { name: t.assert.asymmetric.any(String), age: t.assert.asymmetric.any(Number) }, + ); +});