@appthrust/kest 0.10.0 → 0.12.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 CHANGED
@@ -1168,12 +1168,25 @@ await ns.assert<MyCustomResource>({
1168
1168
  });
1169
1169
  ```
1170
1170
 
1171
+ ## Tips
1172
+
1173
+ ### Debugging failed tests
1174
+
1175
+ By default, Kest cleans up all resources after every test -- even when the test fails. This keeps the cluster tidy but destroys the state you need for debugging. To preserve resources on failure, set `KEST_PRESERVE_ON_FAILURE=1`:
1176
+
1177
+ ```sh
1178
+ KEST_PRESERVE_ON_FAILURE=1 bun test
1179
+ ```
1180
+
1181
+ When active, Kest skips cleanup for failed scenarios so you can inspect the cluster with `kubectl`. The test report will show a "Cleanup (skipped)" notice instead of the usual cleanup table. Remember to delete the leftover resources manually once you're done investigating.
1182
+
1171
1183
  ## Environment Variables
1172
1184
 
1173
- | Variable | Description |
1174
- | ------------------ | ----------------------------------------------------------------------- |
1175
- | `KEST_SHOW_REPORT` | Set to `"1"` to show Markdown reports for all tests (not just failures) |
1176
- | `KEST_SHOW_EVENTS` | Set to `"1"` to dump raw recorder events for debugging |
1185
+ | Variable | Description |
1186
+ | --------------------------- | ----------------------------------------------------------------------- |
1187
+ | `KEST_SHOW_REPORT` | Set to `"1"` to show Markdown reports for all tests (not just failures) |
1188
+ | `KEST_SHOW_EVENTS` | Set to `"1"` to dump raw recorder events for debugging |
1189
+ | `KEST_PRESERVE_ON_FAILURE` | Set to `"1"` to skip cleanup when a test fails, preserving cluster state for debugging |
1177
1190
 
1178
1191
  ## License
1179
1192
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appthrust/kest",
3
- "version": "0.10.0",
3
+ "version": "0.12.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 };
@@ -71,7 +71,8 @@ type RetryEvent =
71
71
 
72
72
  type RevertingsEvent =
73
73
  | BaseEvent<"RevertingsStart">
74
- | BaseEvent<"RevertingsEnd">;
74
+ | BaseEvent<"RevertingsEnd">
75
+ | BaseEvent<"RevertingsSkipped">;
75
76
 
76
77
  type BDDEvent =
77
78
  | BaseEvent<"BDDGiven", { readonly description: string }>
@@ -7,6 +7,7 @@ export interface Scenario {
7
7
  overview: Array<OverviewItem>;
8
8
  details: Array<Tagged<"BDDSection", BDDSection> | Tagged<"Action", Action>>;
9
9
  cleanup: Array<CleanupItem>;
10
+ cleanupSkipped?: boolean;
10
11
  }
11
12
 
12
13
  type Tagged<Tag extends string, Target extends object> = Target & {
@@ -75,6 +75,11 @@ function handleNonBDDEvent(state: ParseState, event: Event): void {
75
75
  state.inCleanup = false;
76
76
  clearCurrentActionState(state);
77
77
  return;
78
+ case "RevertingsSkipped": {
79
+ const scenario = ensureScenario(state.currentScenario, state.report);
80
+ scenario.cleanupSkipped = true;
81
+ return;
82
+ }
78
83
  case "ActionStart":
79
84
  handleActionStart(state, event);
80
85
  return;
@@ -157,7 +157,8 @@ export async function renderReport(
157
157
  const isEmpty =
158
158
  scenario.overview.length === 0 &&
159
159
  scenario.details.length === 0 &&
160
- scenario.cleanup.length === 0;
160
+ scenario.cleanup.length === 0 &&
161
+ !scenario.cleanupSkipped;
161
162
  if (isEmpty) {
162
163
  continue;
163
164
  }
@@ -294,7 +295,19 @@ export async function renderReport(
294
295
  }
295
296
 
296
297
  // Cleanup
297
- if (scenario.cleanup.length > 0) {
298
+ if (scenario.cleanupSkipped) {
299
+ lines.push("### Cleanup (skipped)");
300
+ lines.push("");
301
+ lines.push(
302
+ "Cleanup was skipped because `KEST_PRESERVE_ON_FAILURE=1` is set."
303
+ );
304
+ lines.push(
305
+ "Resources created during this scenario were **not** deleted."
306
+ );
307
+ lines.push(
308
+ "To clean up manually, run the revert commands from a passing test run."
309
+ );
310
+ } else if (scenario.cleanup.length > 0) {
298
311
  lines.push("### Cleanup");
299
312
  lines.push("");
300
313
  lines.push("| # | Action | Status |");
@@ -26,6 +26,9 @@ export function createReverting(deps: Deps) {
26
26
  recorder.record("RevertingsEnd", {});
27
27
  }
28
28
  },
29
+ skip(): void {
30
+ recorder.record("RevertingsSkipped", {});
31
+ },
29
32
  };
30
33
  }
31
34
 
@@ -32,7 +32,7 @@ import { retryUntil } from "../retry";
32
32
  import type { Reverting } from "../reverting";
33
33
 
34
34
  export interface InternalScenario extends Scenario {
35
- cleanup(): Promise<void>;
35
+ cleanup(options?: { skip?: boolean }): Promise<void>;
36
36
  getReport(): Promise<string>;
37
37
  }
38
38
 
@@ -61,8 +61,12 @@ export function createScenario(deps: CreateScenarioOptions): InternalScenario {
61
61
  generateName: (prefix: string) => generateRandomName(prefix),
62
62
  newNamespace: createNewNamespaceFn(deps),
63
63
  useCluster: createUseClusterFn(deps),
64
- async cleanup() {
65
- await reverting.revert();
64
+ async cleanup(options?: { skip?: boolean }) {
65
+ if (options?.skip) {
66
+ reverting.skip();
67
+ } else {
68
+ await reverting.revert();
69
+ }
66
70
  },
67
71
  async getReport() {
68
72
  return await reporter.report(recorder.getEvents());
package/ts/test.ts CHANGED
@@ -43,6 +43,7 @@ type BunTestRunner = (
43
43
  const workspaceRoot = await getWorkspaceRoot();
44
44
  const showReport = process.env["KEST_SHOW_REPORT"] === "1";
45
45
  const showEvents = process.env["KEST_SHOW_EVENTS"] === "1";
46
+ const preserveOnFailure = process.env["KEST_PRESERVE_ON_FAILURE"] === "1";
46
47
 
47
48
  function makeScenarioTest(runner: BunTestRunner): TestFunction {
48
49
  return (label, fn, options) => {
@@ -66,7 +67,9 @@ function makeScenarioTest(runner: BunTestRunner): TestFunction {
66
67
  } catch (error) {
67
68
  testErr = error as Error;
68
69
  }
69
- await scenario.cleanup();
70
+ await scenario.cleanup({
71
+ skip: preserveOnFailure && testErr !== undefined,
72
+ });
70
73
  recorder.record("ScenarioEnd", {});
71
74
  await report(recorder, scenario, testErr);
72
75
  if (testErr) {