@appthrust/kest 0.9.0 → 0.10.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 +141 -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,141 @@
|
|
|
1
|
+
import type { MatcherResult } from "bun:test";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Deep partial match: checks that every key in `expected` exists in `actual`
|
|
5
|
+
* with a matching value. For nested objects, recurses. For nested arrays,
|
|
6
|
+
* checks index-by-index with ordered semantics.
|
|
7
|
+
*/
|
|
8
|
+
export function deepPartialMatch(actual: unknown, expected: unknown): boolean {
|
|
9
|
+
if (Object.is(actual, expected)) {
|
|
10
|
+
return true;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
if (expected === null || actual === null) {
|
|
14
|
+
return actual === expected;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (typeof expected !== "object" || typeof actual !== "object") {
|
|
18
|
+
return actual === expected;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (Array.isArray(expected)) {
|
|
22
|
+
return deepPartialMatchArrays(actual, expected);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (Array.isArray(actual)) {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return deepPartialMatchObjects(
|
|
30
|
+
actual as Record<string, unknown>,
|
|
31
|
+
expected as Record<string, unknown>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function deepPartialMatchArrays(
|
|
36
|
+
actual: unknown,
|
|
37
|
+
expected: Array<unknown>
|
|
38
|
+
): boolean {
|
|
39
|
+
if (!Array.isArray(actual)) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
if (actual.length !== expected.length) {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
return expected.every((item, i) => deepPartialMatch(actual[i], item));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function deepPartialMatchObjects(
|
|
49
|
+
actual: Record<string, unknown>,
|
|
50
|
+
expected: Record<string, unknown>
|
|
51
|
+
): boolean {
|
|
52
|
+
for (const key of Object.keys(expected)) {
|
|
53
|
+
if (!(key in actual)) {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
if (!deepPartialMatch(actual[key], expected[key])) {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 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.
|
|
67
|
+
*/
|
|
68
|
+
function findUnmatched(
|
|
69
|
+
actual: ReadonlyArray<unknown>,
|
|
70
|
+
expected: ReadonlyArray<unknown>
|
|
71
|
+
): { unmatchedExpected: Array<number>; unmatchedActual: Array<number> } {
|
|
72
|
+
const usedActual = new Set<number>();
|
|
73
|
+
const unmatchedExpected: Array<number> = [];
|
|
74
|
+
|
|
75
|
+
for (let ei = 0; ei < expected.length; ei++) {
|
|
76
|
+
let found = false;
|
|
77
|
+
for (let ai = 0; ai < actual.length; ai++) {
|
|
78
|
+
if (usedActual.has(ai)) {
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (deepPartialMatch(actual[ai], expected[ei])) {
|
|
82
|
+
usedActual.add(ai);
|
|
83
|
+
found = true;
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (!found) {
|
|
88
|
+
unmatchedExpected.push(ei);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const unmatchedActual: Array<number> = [];
|
|
93
|
+
for (let ai = 0; ai < actual.length; ai++) {
|
|
94
|
+
if (!usedActual.has(ai)) {
|
|
95
|
+
unmatchedActual.push(ai);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { unmatchedExpected, unmatchedActual };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function toMatchUnordered(
|
|
103
|
+
this: { isNot: boolean; utils: { stringify(v: unknown): string } },
|
|
104
|
+
actual: unknown,
|
|
105
|
+
expected: unknown
|
|
106
|
+
): MatcherResult {
|
|
107
|
+
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
|
+
return {
|
|
116
|
+
pass: false,
|
|
117
|
+
message: () =>
|
|
118
|
+
`expected argument must be an array, but got ${typeof expected}`,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const { unmatchedExpected } = findUnmatched(actual, expected);
|
|
123
|
+
const pass = unmatchedExpected.length === 0;
|
|
124
|
+
|
|
125
|
+
const message = (): string => {
|
|
126
|
+
if (this.isNot) {
|
|
127
|
+
return "expected arrays not to match (in any order), but every expected item had a match";
|
|
128
|
+
}
|
|
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])}`);
|
|
136
|
+
}
|
|
137
|
+
return lines.join("\n");
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
return { pass, message };
|
|
141
|
+
}
|