@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 +17 -4
- package/package.json +1 -1
- package/ts/matchers/to-match-unordered.ts +177 -45
- package/ts/recording/index.ts +2 -1
- package/ts/reporter/markdown/model.ts +1 -0
- package/ts/reporter/markdown/parser/index.ts +5 -0
- package/ts/reporter/markdown/renderer/index.ts +15 -2
- package/ts/reverting/index.ts +3 -0
- package/ts/scenario/index.ts +7 -3
- package/ts/test.ts +4 -1
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
|
|
1174
|
-
|
|
|
1175
|
-
| `KEST_SHOW_REPORT`
|
|
1176
|
-
| `KEST_SHOW_EVENTS`
|
|
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,40 +1,65 @@
|
|
|
1
|
-
import type
|
|
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(
|
|
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
|
|
31
|
+
return false;
|
|
15
32
|
}
|
|
16
33
|
|
|
17
34
|
if (typeof expected !== "object" || typeof actual !== "object") {
|
|
18
|
-
return
|
|
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
|
|
66
|
-
*
|
|
98
|
+
* deep partial match semantics. Returns a pairing array and unmatched
|
|
99
|
+
* actual indices.
|
|
67
100
|
*/
|
|
68
|
-
function
|
|
101
|
+
function findMatching(
|
|
69
102
|
actual: ReadonlyArray<unknown>,
|
|
70
|
-
expected: ReadonlyArray<unknown
|
|
71
|
-
|
|
103
|
+
expected: ReadonlyArray<unknown>,
|
|
104
|
+
equals?: EqualsFunction
|
|
105
|
+
): MatchResult {
|
|
72
106
|
const usedActual = new Set<number>();
|
|
73
|
-
const
|
|
107
|
+
const pairing: Array<number> = [];
|
|
74
108
|
|
|
75
|
-
for (
|
|
76
|
-
let
|
|
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],
|
|
115
|
+
if (deepPartialMatch(actual[ai], expectedItem, equals)) {
|
|
82
116
|
usedActual.add(ai);
|
|
83
|
-
|
|
117
|
+
matched = ai;
|
|
84
118
|
break;
|
|
85
119
|
}
|
|
86
120
|
}
|
|
87
|
-
|
|
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 {
|
|
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: {
|
|
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
|
|
236
|
+
`expect(received).toMatchUnordered(expected)\n\nReceived value must be an array, but got ${typeof actual}`,
|
|
119
237
|
};
|
|
120
238
|
}
|
|
121
|
-
|
|
122
|
-
const
|
|
123
|
-
const pass
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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 };
|
package/ts/recording/index.ts
CHANGED
|
@@ -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.
|
|
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 |");
|
package/ts/reverting/index.ts
CHANGED
package/ts/scenario/index.ts
CHANGED
|
@@ -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
|
-
|
|
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) {
|