@appthrust/kest 0.9.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/README.md +32 -4
- package/example/example.test.ts +33 -0
- package/package.json +1 -1
- package/ts/apis/index.ts +27 -0
- package/ts/index.ts +1 -0
- package/ts/matchers/index.ts +4 -0
- package/ts/matchers/to-match-unordered.ts +273 -0
package/README.md
CHANGED
|
@@ -915,11 +915,11 @@ test("scaling up increases available replicas", async (s) => {
|
|
|
915
915
|
|
|
916
916
|
**When to use which approach:**
|
|
917
917
|
|
|
918
|
-
| Scenario
|
|
919
|
-
|
|
|
918
|
+
| Scenario | Approach |
|
|
919
|
+
| --------------------------------------------------------- | -------------------------------------------------------- |
|
|
920
920
|
| Each `apply` is a different resource or independent input | Inline each manifest separately (keep manifests visible) |
|
|
921
|
-
| The same resource is applied twice with a small change
|
|
922
|
-
| The manifest is too large to inline comfortably
|
|
921
|
+
| The same resource is applied twice with a small change | `structuredClone` + targeted mutation |
|
|
922
|
+
| The manifest is too large to inline comfortably | Static fixture files (`import("./fixtures/...")`) |
|
|
923
923
|
|
|
924
924
|
**Mutation-readability checklist:**
|
|
925
925
|
|
|
@@ -1098,6 +1098,34 @@ await ns.assert({
|
|
|
1098
1098
|
- **With a type parameter** (e.g. `assert<MyResource>`) — either style works; `this` is fully typed, so it's a matter of preference.
|
|
1099
1099
|
- **Without a type parameter** — always use `toMatchObject`. `this.spec` is `unknown`, and `toMatchObject` scales naturally as you add more assertions.
|
|
1100
1100
|
|
|
1101
|
+
### Prefer whole-array assertions over for-loops in `assertList`
|
|
1102
|
+
|
|
1103
|
+
Avoid `for` loops inside `assertList` — when an assertion fails, the error doesn't identify which item caused it:
|
|
1104
|
+
|
|
1105
|
+
```ts
|
|
1106
|
+
// ❌ Failure messages are opaque — which Secret failed?
|
|
1107
|
+
test() {
|
|
1108
|
+
for (const s of this) {
|
|
1109
|
+
expect(s.metadata.labels).toMatchObject({ app: "my-app" });
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
// ✅ Assert the whole array — full per-item diff on failure
|
|
1114
|
+
test() {
|
|
1115
|
+
expect(this).toMatchUnordered([
|
|
1116
|
+
{ metadata: { name: "secret-a", labels: { app: "my-app" } } },
|
|
1117
|
+
{ metadata: { name: "secret-b", labels: { app: "my-app" } } },
|
|
1118
|
+
]);
|
|
1119
|
+
}
|
|
1120
|
+
```
|
|
1121
|
+
|
|
1122
|
+
`toMatchUnordered` uses deep partial matching (like `toMatchObject`) but ignores order. Neither matcher checks array length — use `toHaveLength` when you need an exact count.
|
|
1123
|
+
|
|
1124
|
+
| Matcher | Order-sensitive | Matching |
|
|
1125
|
+
|---|---|---|
|
|
1126
|
+
| `toMatchObject([...])` | yes | deep partial |
|
|
1127
|
+
| `toMatchUnordered([...])` | no | deep partial |
|
|
1128
|
+
|
|
1101
1129
|
## Type Safety
|
|
1102
1130
|
|
|
1103
1131
|
Define TypeScript interfaces for your Kubernetes resources to get full type checking in manifests and assertions:
|
package/example/example.test.ts
CHANGED
|
@@ -197,6 +197,39 @@ test("Example: asserts resource presence and absence in a list", async (s) => {
|
|
|
197
197
|
});
|
|
198
198
|
});
|
|
199
199
|
|
|
200
|
+
test("Example: asserts list items in any order with toMatchUnordered", async (s) => {
|
|
201
|
+
s.given("a new namespace exists");
|
|
202
|
+
const ns = await s.newNamespace();
|
|
203
|
+
|
|
204
|
+
s.when("I apply multiple Secrets");
|
|
205
|
+
await ns.apply({
|
|
206
|
+
apiVersion: "v1",
|
|
207
|
+
kind: "Secret",
|
|
208
|
+
metadata: { name: "app-secret" },
|
|
209
|
+
stringData: { token: "abc" },
|
|
210
|
+
});
|
|
211
|
+
await ns.apply({
|
|
212
|
+
apiVersion: "v1",
|
|
213
|
+
kind: "Secret",
|
|
214
|
+
metadata: { name: "db-secret" },
|
|
215
|
+
stringData: { token: "xyz" },
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
s.then("the list should contain both Secrets regardless of order");
|
|
219
|
+
await ns.assertList({
|
|
220
|
+
apiVersion: "v1",
|
|
221
|
+
kind: "Secret",
|
|
222
|
+
test() {
|
|
223
|
+
// toMatchUnordered uses deep partial matching (like toMatchObject)
|
|
224
|
+
// but ignores array order.
|
|
225
|
+
expect(this).toMatchUnordered([
|
|
226
|
+
{ metadata: { name: "db-secret" } },
|
|
227
|
+
{ metadata: { name: "app-secret" } },
|
|
228
|
+
]);
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
200
233
|
test("Example: applies status subresource to custom resource", async (s) => {
|
|
201
234
|
s.given("a HelloWorld custom resource definition exists");
|
|
202
235
|
await s.apply(import("./hello-world-crd.yaml"));
|
package/package.json
CHANGED
package/ts/apis/index.ts
CHANGED
|
@@ -1555,3 +1555,30 @@ export interface K8sResource {
|
|
|
1555
1555
|
export interface ImportedYaml {
|
|
1556
1556
|
readonly default: unknown;
|
|
1557
1557
|
}
|
|
1558
|
+
|
|
1559
|
+
// ---------------------------------------------------------------------------
|
|
1560
|
+
// Custom matchers – auto-registered when importing `@appthrust/kest`
|
|
1561
|
+
// ---------------------------------------------------------------------------
|
|
1562
|
+
|
|
1563
|
+
declare module "bun:test" {
|
|
1564
|
+
interface Matchers<T> {
|
|
1565
|
+
/**
|
|
1566
|
+
* Assert that an array contains the expected items using **deep partial
|
|
1567
|
+
* matching** (same semantics as `toMatchObject`) but **ignoring order**.
|
|
1568
|
+
*
|
|
1569
|
+
* - Each expected item must match exactly one actual item (one-to-one).
|
|
1570
|
+
* - Does not check array length (actual may have extra items).
|
|
1571
|
+
*
|
|
1572
|
+
* ```ts
|
|
1573
|
+
* expect(actual).toMatchUnordered([
|
|
1574
|
+
* { metadata: { name: "b" } },
|
|
1575
|
+
* { metadata: { name: "a" } },
|
|
1576
|
+
* ]);
|
|
1577
|
+
* ```
|
|
1578
|
+
*/
|
|
1579
|
+
toMatchUnordered(expected: ReadonlyArray<unknown>): void;
|
|
1580
|
+
}
|
|
1581
|
+
interface AsymmetricMatchers {
|
|
1582
|
+
toMatchUnordered(expected: ReadonlyArray<unknown>): void;
|
|
1583
|
+
}
|
|
1584
|
+
}
|
package/ts/index.ts
CHANGED
|
@@ -0,0 +1,273 @@
|
|
|
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;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Deep partial match: checks that every key in `expected` exists in `actual`
|
|
8
|
+
* with a matching value. For nested objects, recurses. For nested arrays,
|
|
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.
|
|
15
|
+
*/
|
|
16
|
+
export function deepPartialMatch(
|
|
17
|
+
actual: unknown,
|
|
18
|
+
expected: unknown,
|
|
19
|
+
equals?: EqualsFunction
|
|
20
|
+
): boolean {
|
|
21
|
+
if (Object.is(actual, expected)) {
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Delegate to Bun's equals — handles asymmetric matchers and exact matches
|
|
26
|
+
if (equals?.(actual, expected)) {
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (expected === null || actual === null) {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (typeof expected !== "object" || typeof actual !== "object") {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (Array.isArray(expected)) {
|
|
39
|
+
return deepPartialMatchArrays(actual, expected, equals);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (Array.isArray(actual)) {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
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
|
+
|
|
52
|
+
return deepPartialMatchObjects(
|
|
53
|
+
actual as Record<string, unknown>,
|
|
54
|
+
expected as Record<string, unknown>,
|
|
55
|
+
equals
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function deepPartialMatchArrays(
|
|
60
|
+
actual: unknown,
|
|
61
|
+
expected: Array<unknown>,
|
|
62
|
+
equals?: EqualsFunction
|
|
63
|
+
): boolean {
|
|
64
|
+
if (!Array.isArray(actual)) {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
if (actual.length !== expected.length) {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
return expected.every((item, i) => deepPartialMatch(actual[i], item, equals));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function deepPartialMatchObjects(
|
|
74
|
+
actual: Record<string, unknown>,
|
|
75
|
+
expected: Record<string, unknown>,
|
|
76
|
+
equals?: EqualsFunction
|
|
77
|
+
): boolean {
|
|
78
|
+
for (const key of Object.keys(expected)) {
|
|
79
|
+
if (!(key in actual)) {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
if (!deepPartialMatch(actual[key], expected[key], equals)) {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
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
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Find a one-to-one matching between expected and actual items using
|
|
98
|
+
* deep partial match semantics. Returns a pairing array and unmatched
|
|
99
|
+
* actual indices.
|
|
100
|
+
*/
|
|
101
|
+
function findMatching(
|
|
102
|
+
actual: ReadonlyArray<unknown>,
|
|
103
|
+
expected: ReadonlyArray<unknown>,
|
|
104
|
+
equals?: EqualsFunction
|
|
105
|
+
): MatchResult {
|
|
106
|
+
const usedActual = new Set<number>();
|
|
107
|
+
const pairing: Array<number> = [];
|
|
108
|
+
|
|
109
|
+
for (const [, expectedItem] of expected.entries()) {
|
|
110
|
+
let matched = -1;
|
|
111
|
+
for (let ai = 0; ai < actual.length; ai++) {
|
|
112
|
+
if (usedActual.has(ai)) {
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (deepPartialMatch(actual[ai], expectedItem, equals)) {
|
|
116
|
+
usedActual.add(ai);
|
|
117
|
+
matched = ai;
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
pairing.push(matched);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const unmatchedActual: Array<number> = [];
|
|
125
|
+
for (let ai = 0; ai < actual.length; ai++) {
|
|
126
|
+
if (!usedActual.has(ai)) {
|
|
127
|
+
unmatchedActual.push(ai);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
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;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function toMatchUnordered(
|
|
224
|
+
this: {
|
|
225
|
+
isNot: boolean;
|
|
226
|
+
equals: EqualsFunction;
|
|
227
|
+
utils: { stringify(v: unknown): string };
|
|
228
|
+
},
|
|
229
|
+
actual: unknown,
|
|
230
|
+
expected: ReadonlyArray<unknown>
|
|
231
|
+
): MatcherResult {
|
|
232
|
+
if (!Array.isArray(actual)) {
|
|
233
|
+
return {
|
|
234
|
+
pass: false,
|
|
235
|
+
message: () =>
|
|
236
|
+
`expect(received).toMatchUnordered(expected)\n\nReceived value must be an array, but got ${typeof actual}`,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
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
|
+
);
|
|
246
|
+
|
|
247
|
+
const message = (): string => {
|
|
248
|
+
if (this.isNot) {
|
|
249
|
+
return "expected arrays not to match (in any order), but every expected item had a match";
|
|
250
|
+
}
|
|
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
|
+
);
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
return { pass, message };
|
|
273
|
+
}
|