From 71c6f83ba0d4886dbd0d3ee4c7826bf3e4fc00e9 Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Sat, 10 Oct 2026 06:36:44 +0400 Subject: [PATCH 1/2] dev-test: asymmetric matchers in .toEqual; arrayContaining checks entries .toEqual compared with assert.deepEqual and never looked at matchers, so expect(x).toEqual(expect.arrayContaining(...)) or .toEqual(expect.stringContaining(...)) always failed; matchers only worked inside toHaveBeenCalledWith. arrayContaining was also typed to take a string and checked value.includes(theWholeArray). .toEqual (and .not.toEqual) now apply matchers wherever they appear in the expected value, with the previous deepEqual unchanged when there are none; arrayContaining asserts that every expected entry equals (or matches) one of the array's entries. Seven new cases; with the old .toEqual six of them fail. --- packages/dev-test/src/env/expect.spec.ts | 36 ++++++++++++ packages/dev-test/src/env/expect.ts | 71 +++++++++++++++++++++--- 2 files changed, 99 insertions(+), 8 deletions(-) diff --git a/packages/dev-test/src/env/expect.spec.ts b/packages/dev-test/src/env/expect.spec.ts index ade828b..2de1cf8 100644 --- a/packages/dev-test/src/env/expect.spec.ts +++ b/packages/dev-test/src/env/expect.spec.ts @@ -219,4 +219,40 @@ describe('expect', () => { }); }); }); + + describe('asymmetric matchers in .toEqual', () => { + it('matches an array containing all expected entries, in any order', () => { + expect([1, 2, 3]).toEqual(expect.arrayContaining([3, 1])); + expect(['a', 'b']).toEqual(expect.arrayContaining(['b', 'a'])); + }); + + it('fails when an expected entry is missing', () => { + expect(() => expect([1, 2]).toEqual(expect.arrayContaining([3]))).toThrow(); + }); + + it('compares array entries deeply', () => { + expect([{ a: 1 }, { b: 2 }]).toEqual(expect.arrayContaining([{ b: 2 }])); + expect(() => expect([{ a: 1 }]).toEqual(expect.arrayContaining([{ a: 2 }]))).toThrow(); + }); + + it('matches a string containing the expected part', () => { + expect('hello world').toEqual(expect.stringContaining('lo w')); + expect(() => expect('hello').toEqual(expect.stringContaining('xyz'))).toThrow(); + }); + + it('applies matchers nested in objects and arrays', () => { + expect({ a: [1, 2], b: 'x' }).toEqual({ a: expect.arrayContaining([2]), b: 'x' }); + expect([{ a: 'xyz' }]).toEqual([{ a: expect.stringContaining('y') }]); + expect(() => expect({ a: [1], b: 'x' }).toEqual({ a: expect.arrayContaining([2]), b: 'x' })).toThrow(); + }); + + it('still requires equal keys around a nested matcher', () => { + expect(() => expect({ a: [1], b: 'x', c: 1 }).toEqual({ a: expect.arrayContaining([1]), b: 'x' })).toThrow(); + }); + + it('supports .not with a matcher', () => { + expect([1, 2]).not.toEqual(expect.arrayContaining([3])); + expect(() => expect([1, 2]).not.toEqual(expect.arrayContaining([2]))).toThrow(); + }); + }); }); diff --git a/packages/dev-test/src/env/expect.ts b/packages/dev-test/src/env/expect.ts index ca2d6fb..72ef0d0 100644 --- a/packages/dev-test/src/env/expect.ts +++ b/packages/dev-test/src/env/expect.ts @@ -183,15 +183,66 @@ function assertInstanceOf (value: unknown, Clazz: Function): void { /** * @internal * - * A helper to ensure that the supplied string/array does include the checker string. + * True when an asymmetric matcher appears anywhere in the expected value + */ +function containsMatcher (check: unknown): boolean { + return check instanceof Matcher || + (Array.isArray(check) + ? check.some(containsMatcher) + : !!check && typeof check === 'object' && Object.values(check).some(containsMatcher)); +} + +/** + * @internal * - * @param {string | unknown[]} value - * @param {string} check + * Equality as .toEqual has it (deep, loose), with asymmetric matchers + * applied wherever they appear in the expected value + */ +function assertEqualMatch (value: unknown, check: unknown): void { + if (check instanceof Matcher) { + check.assertMatch(value); + } else if (Array.isArray(check)) { + assert.ok(Array.isArray(value), `Expected array value, found ${typeof value}`); + assert.ok(value.length === check.length, `Expected array with ${check.length} entries, found ${value.length}`); + check.forEach((other, i) => assertEqualMatch(value[i], other)); + } else if (check && typeof check === 'object' && containsMatcher(check)) { + assert.ok(value && typeof value === 'object', `Expected object value, found ${typeof value}`); + assert.deepEqual(Object.keys(value).sort(), Object.keys(check).sort()); + Object + .entries(check) + .forEach(([key, other]) => assertEqualMatch((value as Record)[key], other)); + } else { + assert.deepEqual(value, check); + } +} + +/** + * @internal + * + * For a string, that it contains the expected substring; for an array, that + * every expected entry equals (or matches) one of its entries */ // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type -function assertIncludes (value: string | unknown[], [check, Clazz]: [string, Function]): void { +function assertIncludes (value: string | unknown[], [check, Clazz]: [string | unknown[], Function]): void { assertInstanceOf(value, Clazz); - assert.ok(value?.includes(check), `${value as string} does not include ${check}`); + + if (Array.isArray(check)) { + const entries = value as unknown[]; + + check.forEach((other) => { + assert.ok(entries.some((entry) => { + try { + assertEqualMatch(entry, other); + + return true; + } catch { + return false; + } + }), `Expected array to contain ${JSON.stringify(other)}`); + }); + } else { + assert.ok((value as string).includes(check), `${value as string} does not include ${check}`); + } } /** @@ -204,7 +255,7 @@ export function expect () { // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type any: (Clazz: Function) => new Matcher(assertInstanceOf, Clazz), anything: () => new Matcher(assertNonNullish), - arrayContaining: (check: string) => new Matcher(assertIncludes, [check, Array]), + arrayContaining: (check: unknown[]) => new Matcher(assertIncludes, [check, Array]), objectContaining: (check: object) => new Matcher(assertMatchObj, check), stringContaining: (check: string) => new Matcher(assertIncludes, [check, String]), stringMatching: (check: string | RegExp) => new Matcher(assertMatchStr, check) @@ -218,7 +269,9 @@ export function expect () { toBeDefined: () => assert.ok(value === undefined), toBeNull: (value: unknown) => assert.ok(value !== null), toBeUndefined: () => assert.ok(value !== undefined), - toEqual: (other: unknown) => assert.notDeepEqual(value, other), + toEqual: (other: unknown) => containsMatcher(other) + ? assert.throws(() => assertEqualMatch(value, other)) + : assert.notDeepEqual(value, other), toHaveBeenCalled: () => assert.ok(!(value as Mocked | undefined)?.mock?.calls.length), toThrow: (message?: RegExp | Error | string) => assert.doesNotThrow(value as () => unknown, message && { message } as Error) }, stubExpectFnNot), @@ -234,7 +287,9 @@ export function expect () { toBeNull: (value: unknown) => assert.ok(value === null), toBeTruthy: () => assert.ok(value), toBeUndefined: () => assert.ok(value === undefined), - toEqual: (other: unknown) => assert.deepEqual(value, other), + toEqual: (other: unknown) => containsMatcher(other) + ? assertEqualMatch(value, other) + : assert.deepEqual(value, other), toHaveBeenCalled: () => assert.ok((value as Mocked | undefined)?.mock?.calls.length), toHaveBeenCalledTimes: (count: number) => assert.equal((value as Mocked | undefined)?.mock?.calls.length, count), toHaveBeenCalledWith: (...args: unknown[]) => assertSomeCallHasArgs((value as Mocked | undefined), args), From 2b557de3033a6f65af7e94f337aae9ef9e5ce5e4 Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Sat, 10 Oct 2026 06:36:45 +0400 Subject: [PATCH 2/2] Release dev, dev-test and dev-ts together as 0.86.5 Carries the dev-test fix for asymmetric matchers in .toEqual and arrayContaining. dev depends on ^0.86.5 of the other two. --- packages/dev-test/package.json | 2 +- packages/dev-test/src/packageInfo.ts | 2 +- packages/dev-ts/package.json | 2 +- packages/dev-ts/src/packageInfo.ts | 2 +- packages/dev/package.json | 6 +++--- packages/dev/src/packageInfo.ts | 2 +- yarn.lock | 8 ++++---- 7 files changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/dev-test/package.json b/packages/dev-test/package.json index 7227ff3..03d6f04 100644 --- a/packages/dev-test/package.json +++ b/packages/dev-test/package.json @@ -15,7 +15,7 @@ }, "sideEffects": false, "type": "module", - "version": "0.86.4", + "version": "0.86.5", "main": "./index.js", "exports": { "./globals.d.ts": "./src/globals.d.ts" diff --git a/packages/dev-test/src/packageInfo.ts b/packages/dev-test/src/packageInfo.ts index 1225d35..058817c 100644 --- a/packages/dev-test/src/packageInfo.ts +++ b/packages/dev-test/src/packageInfo.ts @@ -3,4 +3,4 @@ // Do not edit, auto-generated by @pezkuwi/dev -export const packageInfo = { name: '@pezkuwi/dev-test', path: 'auto', type: 'auto', version: '0.86.4' }; +export const packageInfo = { name: '@pezkuwi/dev-test', path: 'auto', type: 'auto', version: '0.86.5' }; diff --git a/packages/dev-ts/package.json b/packages/dev-ts/package.json index b70711e..6a93946 100644 --- a/packages/dev-ts/package.json +++ b/packages/dev-ts/package.json @@ -15,7 +15,7 @@ }, "sideEffects": false, "type": "module", - "version": "0.86.4", + "version": "0.86.5", "main": "./index.js", "exports": { "./globals.d.ts": "./src/globals.d.ts" diff --git a/packages/dev-ts/src/packageInfo.ts b/packages/dev-ts/src/packageInfo.ts index 4706747..58403f8 100644 --- a/packages/dev-ts/src/packageInfo.ts +++ b/packages/dev-ts/src/packageInfo.ts @@ -3,4 +3,4 @@ // Do not edit, auto-generated by @pezkuwi/dev -export const packageInfo = { name: '@pezkuwi/dev-ts', path: 'auto', type: 'auto', version: '0.86.4' }; +export const packageInfo = { name: '@pezkuwi/dev-ts', path: 'auto', type: 'auto', version: '0.86.5' }; diff --git a/packages/dev/package.json b/packages/dev/package.json index 030430e..6cc6e20 100644 --- a/packages/dev/package.json +++ b/packages/dev/package.json @@ -15,7 +15,7 @@ }, "sideEffects": false, "type": "module", - "version": "0.86.4", + "version": "0.86.5", "bin": { "pezkuwi-ci-ghact-build": "./scripts/pezkuwi-ci-ghact-build.mjs", "pezkuwi-ci-ghact-docs": "./scripts/pezkuwi-ci-ghact-docs.mjs", @@ -52,8 +52,8 @@ "dependencies": { "@eslint/compat": "^2.1.1", "@eslint/js": "^10.0.1", - "@pezkuwi/dev-test": "^0.86.4", - "@pezkuwi/dev-ts": "^0.86.4", + "@pezkuwi/dev-test": "^0.86.5", + "@pezkuwi/dev-ts": "^0.86.5", "@rollup/plugin-alias": "^5.1.1", "@rollup/plugin-commonjs": "^29.0.3", "@rollup/plugin-dynamic-import-vars": "^2.1.5", diff --git a/packages/dev/src/packageInfo.ts b/packages/dev/src/packageInfo.ts index f38d5a4..8f31535 100644 --- a/packages/dev/src/packageInfo.ts +++ b/packages/dev/src/packageInfo.ts @@ -3,4 +3,4 @@ // Do not edit, auto-generated by @pezkuwi/dev -export const packageInfo = { name: '@pezkuwi/dev', path: 'auto', type: 'auto', version: '0.86.4' }; +export const packageInfo = { name: '@pezkuwi/dev', path: 'auto', type: 'auto', version: '0.86.5' }; diff --git a/yarn.lock b/yarn.lock index 9a2dde6..9a80126 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1189,7 +1189,7 @@ __metadata: languageName: node linkType: hard -"@pezkuwi/dev-test@npm:^0.86.4, @pezkuwi/dev-test@workspace:packages/dev-test": +"@pezkuwi/dev-test@npm:^0.86.5, @pezkuwi/dev-test@workspace:packages/dev-test": version: 0.0.0-use.local resolution: "@pezkuwi/dev-test@workspace:packages/dev-test" dependencies: @@ -1205,7 +1205,7 @@ __metadata: languageName: unknown linkType: soft -"@pezkuwi/dev-ts@npm:^0.86.4, @pezkuwi/dev-ts@workspace:packages/dev-ts": +"@pezkuwi/dev-ts@npm:^0.86.5, @pezkuwi/dev-ts@workspace:packages/dev-ts": version: 0.0.0-use.local resolution: "@pezkuwi/dev-ts@workspace:packages/dev-ts" dependencies: @@ -1221,8 +1221,8 @@ __metadata: dependencies: "@eslint/compat": "npm:^2.1.1" "@eslint/js": "npm:^10.0.1" - "@pezkuwi/dev-test": "npm:^0.86.4" - "@pezkuwi/dev-ts": "npm:^0.86.4" + "@pezkuwi/dev-test": "npm:^0.86.5" + "@pezkuwi/dev-ts": "npm:^0.86.5" "@rollup/plugin-alias": "npm:^5.1.1" "@rollup/plugin-commonjs": "npm:^29.0.3" "@rollup/plugin-dynamic-import-vars": "npm:^2.1.5"