From c988e2740046dafd7fc41c99b81eb31320c21305 Mon Sep 17 00:00:00 2001 From: Richard Gibson Date: Fri, 16 Jun 2023 11:38:43 -0400 Subject: [PATCH] Support Symbol keys and ignore non-enumerable properties in t.like() Fixes #3208 Co-authored-by: Mark Wubben --- docs/03-assertions.md | 4 ++-- lib/like-selector.js | 29 ++++++++++++++++++----------- test-tap/assert.js | 15 ++++++++++++++- 3 files changed, 34 insertions(+), 14 deletions(-) diff --git a/docs/03-assertions.md b/docs/03-assertions.md index 02461b5a2..cb04dcfdb 100644 --- a/docs/03-assertions.md +++ b/docs/03-assertions.md @@ -141,7 +141,7 @@ Assert that `actual` is not deeply equal to `expected`. The inverse of `.deepEqu Assert that `actual` is like `selector`. This is a variant of `.deepEqual()`, however `selector` does not need to have the same enumerable properties as `actual` does. -Instead AVA derives a *comparable* value from `actual`, recursively based on the shape of `selector`. This value is then compared to `selector` using `.deepEqual()`. +Instead AVA derives a *comparable* value from `actual`, recursively based on the enumerable shape of `selector`. This value is then compared to `selector` using `.deepEqual()`. Any values in `selector` that are not arrays or regular objects should be deeply equal to the corresponding values in `actual`. @@ -165,7 +165,7 @@ t.like({ You can also use arrays, but note that any indices in `actual` that are not in `selector` are ignored: ```js -t.like([1, 2, 3], [1, 2]) +t.like([1, 2, 3, 4], [1, , 3]) ``` Finally, this returns a boolean indicating whether the assertion passed. diff --git a/lib/like-selector.js b/lib/like-selector.js index 84bcbf8ce..8f9bfb64c 100644 --- a/lib/like-selector.js +++ b/lib/like-selector.js @@ -1,32 +1,39 @@ -const isObject = selector => Reflect.getPrototypeOf(selector) === Object.prototype; +const isPrimitive = value => value === null || typeof value !== 'object'; export function isLikeSelector(selector) { - if (selector === null || typeof selector !== 'object') { + // Require selector to be an array or plain object. + if ( + isPrimitive(selector) + || (!Array.isArray(selector) && Reflect.getPrototypeOf(selector) !== Object.prototype) + ) { return false; } - const keyCount = Reflect.ownKeys(selector).length; - return (Array.isArray(selector) && keyCount > 1) || (isObject(selector) && keyCount > 0); + // Also require at least one enumerable property. + const descriptors = Object.getOwnPropertyDescriptors(selector); + return Reflect.ownKeys(descriptors).some(key => descriptors[key].enumerable === true); } export const CIRCULAR_SELECTOR = new Error('Encountered a circular selector'); -export function selectComparable(lhs, selector, circular = new Set()) { +export function selectComparable(actual, selector, circular = new Set()) { if (circular.has(selector)) { throw CIRCULAR_SELECTOR; } circular.add(selector); - if (lhs === null || typeof lhs !== 'object') { - return lhs; + if (isPrimitive(actual)) { + return actual; } const comparable = Array.isArray(selector) ? [] : {}; - for (const [key, rhs] of Object.entries(selector)) { - comparable[key] = isLikeSelector(rhs) - ? selectComparable(Reflect.get(lhs, key), rhs, circular) - : Reflect.get(lhs, key); + const enumerableKeys = Reflect.ownKeys(selector).filter(key => Reflect.getOwnPropertyDescriptor(selector, key).enumerable); + for (const key of enumerableKeys) { + const subselector = Reflect.get(selector, key); + comparable[key] = isLikeSelector(subselector) + ? selectComparable(Reflect.get(actual, key), subselector, circular) + : Reflect.get(actual, key); } return comparable; diff --git a/test-tap/assert.js b/test-tap/assert.js index 46d2deac6..cc62ca1a1 100644 --- a/test-tap/assert.js +++ b/test-tap/assert.js @@ -720,7 +720,7 @@ test('.like()', t => { return assertions.like({xc: [circular, 'c']}, {xc: [circular, 'd']}); }); - failsWith(t, () => assertions.like({a: 'a'}, {}), { + failsWith(t, () => assertions.like({a: 'a'}, Object.defineProperties({}, {ignored: {}})), { assertion: 'like', message: '`t.like()` selector must be a non-empty object', values: [{label: 'Called with:', formatted: '{}'}], @@ -732,6 +732,15 @@ test('.like()', t => { values: [{label: 'Called with:', formatted: '\'bar\''}], }); + passes(t, () => { + const specimen = {[Symbol.toStringTag]: 'Custom', extra: true}; + const selector = Object.defineProperties( + {[Symbol.toStringTag]: 'Custom'}, + {ignored: {value: true}}, + ); + return assertions.like(specimen, selector); + }); + failsWith(t, () => { const likePattern = { a: 'a', @@ -767,8 +776,12 @@ test('.like()', t => { passes(t, () => assertions.like([1, 2, 3], [1, 2, 3])); passes(t, () => assertions.like([1, 2, 3], [1, 2])); + // eslint-disable-next-line no-sparse-arrays + passes(t, () => assertions.like([1, 2, 3], [1, , 3])); fails(t, () => assertions.like([1, 2, 3], [3, 2, 1])); + // eslint-disable-next-line no-sparse-arrays + fails(t, () => assertions.like([1, 2, 3], [1, , 4])); fails(t, () => assertions.like([1, 2], [1, 2, 3])); t.end();