@appthrust/kest 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appthrust/kest",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Kubernetes E2E testing framework designed for humans and AI alike",
5
5
  "type": "module",
6
6
  "module": "ts/index.ts",
@@ -1,40 +1,65 @@
1
- import type { MatcherResult } from "bun:test";
1
+ import { expect, type MatcherResult } from "bun:test";
2
+ import { stripAnsi } from "../reporter/markdown/strip-ansi";
3
+
4
+ type EqualsFunction = (a: unknown, b: unknown) => boolean;
2
5
 
3
6
  /**
4
7
  * Deep partial match: checks that every key in `expected` exists in `actual`
5
8
  * with a matching value. For nested objects, recurses. For nested arrays,
6
9
  * checks index-by-index with ordered semantics.
10
+ *
11
+ * When an `equals` function is provided (from the matcher context), it is
12
+ * called first for every comparison. This lets Bun's native equality handle
13
+ * asymmetric matchers (`expect.stringMatching`, `expect.any`, etc.)
14
+ * transparently — no special detection needed.
7
15
  */
8
- export function deepPartialMatch(actual: unknown, expected: unknown): boolean {
16
+ export function deepPartialMatch(
17
+ actual: unknown,
18
+ expected: unknown,
19
+ equals?: EqualsFunction
20
+ ): boolean {
9
21
  if (Object.is(actual, expected)) {
10
22
  return true;
11
23
  }
12
24
 
25
+ // Delegate to Bun's equals — handles asymmetric matchers and exact matches
26
+ if (equals?.(actual, expected)) {
27
+ return true;
28
+ }
29
+
13
30
  if (expected === null || actual === null) {
14
- return actual === expected;
31
+ return false;
15
32
  }
16
33
 
17
34
  if (typeof expected !== "object" || typeof actual !== "object") {
18
- return actual === expected;
35
+ return false;
19
36
  }
20
37
 
21
38
  if (Array.isArray(expected)) {
22
- return deepPartialMatchArrays(actual, expected);
39
+ return deepPartialMatchArrays(actual, expected, equals);
23
40
  }
24
41
 
25
42
  if (Array.isArray(actual)) {
26
43
  return false;
27
44
  }
28
45
 
46
+ // Only partial-match plain objects; for non-plain objects (Bun asymmetric
47
+ // matchers, Date, RegExp, etc.) the equals() check above is authoritative.
48
+ if (Object.getPrototypeOf(expected) !== Object.prototype) {
49
+ return false;
50
+ }
51
+
29
52
  return deepPartialMatchObjects(
30
53
  actual as Record<string, unknown>,
31
- expected as Record<string, unknown>
54
+ expected as Record<string, unknown>,
55
+ equals
32
56
  );
33
57
  }
34
58
 
35
59
  function deepPartialMatchArrays(
36
60
  actual: unknown,
37
- expected: Array<unknown>
61
+ expected: Array<unknown>,
62
+ equals?: EqualsFunction
38
63
  ): boolean {
39
64
  if (!Array.isArray(actual)) {
40
65
  return false;
@@ -42,51 +67,58 @@ function deepPartialMatchArrays(
42
67
  if (actual.length !== expected.length) {
43
68
  return false;
44
69
  }
45
- return expected.every((item, i) => deepPartialMatch(actual[i], item));
70
+ return expected.every((item, i) => deepPartialMatch(actual[i], item, equals));
46
71
  }
47
72
 
48
73
  function deepPartialMatchObjects(
49
74
  actual: Record<string, unknown>,
50
- expected: Record<string, unknown>
75
+ expected: Record<string, unknown>,
76
+ equals?: EqualsFunction
51
77
  ): boolean {
52
78
  for (const key of Object.keys(expected)) {
53
79
  if (!(key in actual)) {
54
80
  return false;
55
81
  }
56
- if (!deepPartialMatch(actual[key], expected[key])) {
82
+ if (!deepPartialMatch(actual[key], expected[key], equals)) {
57
83
  return false;
58
84
  }
59
85
  }
60
86
  return true;
61
87
  }
62
88
 
89
+ interface MatchResult {
90
+ pass: boolean;
91
+ /** Maps each expected index to its matched actual index (or -1 if unmatched). */
92
+ pairing: Array<number>;
93
+ unmatchedActual: Array<number>;
94
+ }
95
+
63
96
  /**
64
97
  * Find a one-to-one matching between expected and actual items using
65
- * deep partial match semantics. Returns the indices of unmatched expected
66
- * and unmatched actual items.
98
+ * deep partial match semantics. Returns a pairing array and unmatched
99
+ * actual indices.
67
100
  */
68
- function findUnmatched(
101
+ function findMatching(
69
102
  actual: ReadonlyArray<unknown>,
70
- expected: ReadonlyArray<unknown>
71
- ): { unmatchedExpected: Array<number>; unmatchedActual: Array<number> } {
103
+ expected: ReadonlyArray<unknown>,
104
+ equals?: EqualsFunction
105
+ ): MatchResult {
72
106
  const usedActual = new Set<number>();
73
- const unmatchedExpected: Array<number> = [];
107
+ const pairing: Array<number> = [];
74
108
 
75
- for (let ei = 0; ei < expected.length; ei++) {
76
- let found = false;
109
+ for (const [, expectedItem] of expected.entries()) {
110
+ let matched = -1;
77
111
  for (let ai = 0; ai < actual.length; ai++) {
78
112
  if (usedActual.has(ai)) {
79
113
  continue;
80
114
  }
81
- if (deepPartialMatch(actual[ai], expected[ei])) {
115
+ if (deepPartialMatch(actual[ai], expectedItem, equals)) {
82
116
  usedActual.add(ai);
83
- found = true;
117
+ matched = ai;
84
118
  break;
85
119
  }
86
120
  }
87
- if (!found) {
88
- unmatchedExpected.push(ei);
89
- }
121
+ pairing.push(matched);
90
122
  }
91
123
 
92
124
  const unmatchedActual: Array<number> = [];
@@ -96,45 +128,145 @@ function findUnmatched(
96
128
  }
97
129
  }
98
130
 
99
- return { unmatchedExpected, unmatchedActual };
131
+ return {
132
+ pass: pairing.every((ai) => ai !== -1),
133
+ pairing,
134
+ unmatchedActual,
135
+ };
136
+ }
137
+
138
+ /**
139
+ * Score how well an actual item matches an expected item by counting
140
+ * top-level keys in expected that have a matching value in actual.
141
+ */
142
+ function matchScore(
143
+ actual: unknown,
144
+ expected: unknown,
145
+ equals?: EqualsFunction
146
+ ): number {
147
+ if (
148
+ typeof expected !== "object" ||
149
+ expected === null ||
150
+ typeof actual !== "object" ||
151
+ actual === null ||
152
+ Array.isArray(expected) ||
153
+ Array.isArray(actual)
154
+ ) {
155
+ return deepPartialMatch(actual, expected, equals) ? 1 : 0;
156
+ }
157
+ const expectedObj = expected as Record<string, unknown>;
158
+ const actualObj = actual as Record<string, unknown>;
159
+ let score = 0;
160
+ for (const key of Object.keys(expectedObj)) {
161
+ if (
162
+ key in actualObj &&
163
+ deepPartialMatch(actualObj[key], expectedObj[key], equals)
164
+ ) {
165
+ score++;
166
+ }
167
+ }
168
+ return score;
169
+ }
170
+
171
+ /**
172
+ * Find the closest actual item for an expected item by scoring all
173
+ * candidates and returning the one with the highest match score.
174
+ */
175
+ function findClosestActual(
176
+ expectedItem: unknown,
177
+ candidates: ReadonlyArray<unknown>,
178
+ equals?: EqualsFunction
179
+ ): unknown {
180
+ let bestScore = -1;
181
+ let bestCandidate: unknown = candidates[0];
182
+ for (const candidate of candidates) {
183
+ const score = matchScore(candidate, expectedItem, equals);
184
+ if (score > bestScore) {
185
+ bestScore = score;
186
+ bestCandidate = candidate;
187
+ }
188
+ }
189
+ return bestCandidate;
190
+ }
191
+
192
+ /**
193
+ * Build a reordered actual array aligned with expected. Matched items
194
+ * keep their paired actual; unmatched expected items get their closest
195
+ * candidate from the unmatched actuals.
196
+ */
197
+ function buildReorderedActual(
198
+ actual: ReadonlyArray<unknown>,
199
+ expected: ReadonlyArray<unknown>,
200
+ pairing: ReadonlyArray<number>,
201
+ unmatchedActual: ReadonlyArray<number>,
202
+ equals?: EqualsFunction
203
+ ): Array<unknown> {
204
+ const unmatchedActualItems = unmatchedActual.map((i) => actual[i]);
205
+ const reordered: Array<unknown> = [];
206
+
207
+ for (let ei = 0; ei < expected.length; ei++) {
208
+ const paired = pairing[ei] ?? -1;
209
+ if (paired !== -1) {
210
+ reordered.push(actual[paired]);
211
+ } else if (unmatchedActualItems.length > 0) {
212
+ reordered.push(
213
+ findClosestActual(expected[ei], unmatchedActualItems, equals)
214
+ );
215
+ } else {
216
+ reordered.push(undefined);
217
+ }
218
+ }
219
+
220
+ return reordered;
100
221
  }
101
222
 
102
223
  export function toMatchUnordered(
103
- this: { isNot: boolean; utils: { stringify(v: unknown): string } },
224
+ this: {
225
+ isNot: boolean;
226
+ equals: EqualsFunction;
227
+ utils: { stringify(v: unknown): string };
228
+ },
104
229
  actual: unknown,
105
- expected: unknown
230
+ expected: ReadonlyArray<unknown>
106
231
  ): MatcherResult {
107
232
  if (!Array.isArray(actual)) {
108
- return {
109
- pass: false,
110
- message: () => `expected value to be an array, but got ${typeof actual}`,
111
- };
112
- }
113
-
114
- if (!Array.isArray(expected)) {
115
233
  return {
116
234
  pass: false,
117
235
  message: () =>
118
- `expected argument must be an array, but got ${typeof expected}`,
236
+ `expect(received).toMatchUnordered(expected)\n\nReceived value must be an array, but got ${typeof actual}`,
119
237
  };
120
238
  }
121
-
122
- const { unmatchedExpected } = findUnmatched(actual, expected);
123
- const pass = unmatchedExpected.length === 0;
239
+ const actualArray: ReadonlyArray<unknown> = actual;
240
+ const equals = this.equals.bind(this);
241
+ const { pass, pairing, unmatchedActual } = findMatching(
242
+ actualArray,
243
+ expected,
244
+ equals
245
+ );
124
246
 
125
247
  const message = (): string => {
126
248
  if (this.isNot) {
127
249
  return "expected arrays not to match (in any order), but every expected item had a match";
128
250
  }
129
- const lines: Array<string> = [
130
- "expected arrays to match (in any order), but some items did not match:",
131
- "",
132
- "Expected items without a match:",
133
- ];
134
- for (const i of unmatchedExpected) {
135
- lines.push(` [${i}]: ${this.utils.stringify(expected[i])}`);
251
+
252
+ const reordered = buildReorderedActual(
253
+ actualArray,
254
+ expected,
255
+ pairing,
256
+ unmatchedActual,
257
+ equals
258
+ );
259
+
260
+ try {
261
+ // biome-ignore lint: expect is used to generate diff output, not as a test assertion
262
+ expect(reordered).toMatchObject(expected as Array<unknown>);
263
+ return "expected arrays to match (in any order), but some items did not match";
264
+ } catch (e: unknown) {
265
+ return stripAnsi((e as Error).message).replace(
266
+ "expect(received).toMatchObject(expected)",
267
+ "expect(received).toMatchUnordered(expected)"
268
+ );
136
269
  }
137
- return lines.join("\n");
138
270
  };
139
271
 
140
272
  return { pass, message };