Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions lib/assert.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,22 @@
'use strict';

const {
ArrayIsArray,
ArrayPrototypeIndexOf,
ArrayPrototypeJoin,
ArrayPrototypePush,
ArrayPrototypeSlice,
Error,
NumberIsNaN,
ObjectAssign,
ObjectDefineProperty,
ObjectGetPrototypeOf,
ObjectIs,
ObjectKeys,
ObjectPrototypeIsPrototypeOf,
ReflectApply,
RegExpPrototypeExec,
SafeSet,
String,
StringPrototypeIndexOf,
StringPrototypeSlice,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down
114 changes: 112 additions & 2 deletions lib/internal/assert/assertion_error.js
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<String>`. 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) },
Expand All @@ -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,
Expand All @@ -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) {
Expand All @@ -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' &&
Expand Down
Loading