@appthrust/kest 0.13.1 → 0.15.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.
@@ -0,0 +1,142 @@
1
+ bun test v1.3.8 (b64edcb4)
2
+
3
+ example/example.test.ts:
4
+ # Example: applies ConfigMap using YAML, file import, and object literal
5
+
6
+ ## Scenario Overview
7
+
8
+ | # | Action | Status | Duration |
9
+ |---|--------|--------|----------|
10
+ | 1 | Create `Namespace` with auto-generated name | ✅ | 186ms |
11
+ | 2 | Apply `ConfigMap` "my-config-1" | ✅ | 55ms |
12
+ | 3 | Apply a resource | ✅ | 117ms |
13
+ | 4 | Apply `ConfigMap` "my-config-3" | ✅ | 135ms |
14
+ | 5 | Assert `ConfigMap` "my-config-1" | ✅ | 63ms |
15
+
16
+ ## Scenario Details
17
+
18
+ ### Given: a new namespace exists
19
+
20
+ **✅ Create `Namespace` with auto-generated name**
21
+
22
+ ```shell
23
+ kubectl create -f - <<EOF
24
+ apiVersion: v1
25
+ kind: Namespace
26
+ metadata:
27
+ name: kest-x173q
28
+ EOF
29
+ ```
30
+
31
+ ```text title="stdout"
32
+ namespace/kest-x173q created
33
+ ```
34
+
35
+ ### When: I apply ConfigMaps using different formats
36
+
37
+ **✅ Apply `ConfigMap` "my-config-1"**
38
+
39
+ ```shell
40
+ kubectl apply -f - -n kest-x173q <<EOF
41
+ apiVersion: v1
42
+ kind: ConfigMap
43
+ metadata:
44
+ name: my-config-1
45
+ data:
46
+ mode: demo-1
47
+ EOF
48
+ ```
49
+
50
+ ```text title="stdout"
51
+ configmap/my-config-1 created
52
+ ```
53
+
54
+ **✅ Apply a resource**
55
+
56
+ ```shell
57
+ kubectl apply -f - -n kest-x173q <<EOF
58
+ apiVersion: v1
59
+ kind: ConfigMap
60
+ metadata:
61
+ name: my-config-2
62
+ data:
63
+ mode: demo-2
64
+ EOF
65
+ ```
66
+
67
+ ```text title="stdout"
68
+ configmap/my-config-2 created
69
+ ```
70
+
71
+ **✅ Apply `ConfigMap` "my-config-3"**
72
+
73
+ ```shell
74
+ kubectl apply -f - -n kest-x173q <<EOF
75
+ apiVersion: v1
76
+ kind: ConfigMap
77
+ metadata:
78
+ name: my-config-3
79
+ data:
80
+ mode: demo-3
81
+ EOF
82
+ ```
83
+
84
+ ```text title="stdout"
85
+ configmap/my-config-3 created
86
+ ```
87
+
88
+ ### Then: the ConfigMap should have the expected data
89
+
90
+ **✅ Assert `ConfigMap` "my-config-1"**
91
+
92
+ ```shell
93
+ kubectl get ConfigMap my-config-1 -o yaml -n kest-x173q
94
+ ```
95
+
96
+ ```yaml title="stdout"
97
+ apiVersion: v1
98
+ data:
99
+ mode: demo-1
100
+ kind: ConfigMap
101
+ metadata:
102
+ annotations:
103
+ kubectl.kubernetes.io/last-applied-configuration: |
104
+ {"apiVersion":"v1","data":{"mode":"demo-1"},"kind":"ConfigMap","metadata":{"annotations":{},"name":"my-config-1","namespace":"kest-x173q"}}
105
+ creationTimestamp: "2026-03-02T02:39:15Z"
106
+ name: my-config-1
107
+ namespace: kest-x173q
108
+ resourceVersion: "585"
109
+ uid: 25339d11-cf6b-44a5-a680-1548d1e80252
110
+ ```
111
+
112
+ ### Cleanup
113
+
114
+ | # | Action | Status | Duration |
115
+ |---|--------|--------|----------|
116
+ | 1 | Apply `ConfigMap` "my-config-3" | ✅ | 75ms |
117
+ | 2 | Apply a resource | ✅ | 41ms |
118
+ | 3 | Apply `ConfigMap` "my-config-1" | ✅ | 36ms |
119
+ | 4 | Create `Namespace` with auto-generated name | ✅ | 5.103s |
120
+
121
+ ```shellsession
122
+ $ kubectl delete ConfigMap/my-config-3 --ignore-not-found -n kest-x173q
123
+ configmap "my-config-3" deleted
124
+
125
+ $ kubectl delete ConfigMap/my-config-2 --ignore-not-found -n kest-x173q
126
+ configmap "my-config-2" deleted
127
+
128
+ $ kubectl delete ConfigMap/my-config-1 --ignore-not-found -n kest-x173q
129
+ configmap "my-config-1" deleted
130
+
131
+ $ kubectl delete Namespace/kest-x173q --ignore-not-found
132
+ namespace "kest-x173q" deleted
133
+ ```
134
+
135
+ **Total: 5.815s** (Actions: 556ms, Cleanup: 5.255s)
136
+
137
+
138
+ 1 pass
139
+ 7 filtered out
140
+ 0 fail
141
+ 1 expect() calls
142
+ Ran 1 test across 1 file. [6.04s]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appthrust/kest",
3
- "version": "0.13.1",
3
+ "version": "0.15.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,5 +1,4 @@
1
1
  import { generateName } from "../naming";
2
- import { create } from "./create";
3
2
  import type { MutateDef } from "./types";
4
3
 
5
4
  /**
@@ -22,14 +21,20 @@ export const createNamespace = {
22
21
  ({ kubectl }) =>
23
22
  async (input) => {
24
23
  const name = resolveNamespaceName(input);
25
- const { revert } = await create.mutate({ kubectl })({
24
+ await kubectl.create({
26
25
  apiVersion: "v1",
27
26
  kind: "Namespace",
28
- metadata: {
29
- name,
30
- },
27
+ metadata: { name },
31
28
  });
32
- return { revert, output: name };
29
+ return {
30
+ async revert() {
31
+ await kubectl.delete("Namespace", name, {
32
+ ignoreNotFound: true,
33
+ wait: false,
34
+ });
35
+ },
36
+ output: name,
37
+ };
33
38
  },
34
39
  describe: (input) => {
35
40
  if (input === undefined) {
@@ -163,6 +163,8 @@ export interface KubectlDeleteOptions {
163
163
  * that does not exist succeeds silently instead of failing.
164
164
  */
165
165
  readonly ignoreNotFound?: undefined | boolean;
166
+ /** When false, adds --wait=false so delete returns immediately. */
167
+ readonly wait?: undefined | boolean;
166
168
  readonly context?: undefined | KubectlContext;
167
169
  }
168
170
 
@@ -304,6 +306,9 @@ export class RealKubectl implements Kubectl {
304
306
  if (options?.ignoreNotFound) {
305
307
  args.push("--ignore-not-found");
306
308
  }
309
+ if (options?.wait === false) {
310
+ args.push("--wait=false");
311
+ }
307
312
  return await this.runKubectl({
308
313
  args,
309
314
  stdoutLanguage: "text",
@@ -87,13 +87,14 @@ interface BaseEvent<
87
87
  > {
88
88
  readonly kind: Kind;
89
89
  readonly data: Data;
90
+ readonly timestamp: number;
90
91
  }
91
92
 
92
93
  export class Recorder {
93
94
  private readonly events: Array<Event> = [];
94
95
 
95
96
  record<T extends Event>(kind: T["kind"], data: T["data"]) {
96
- this.events.push({ kind, data } as T);
97
+ this.events.push({ kind, data, timestamp: Date.now() } as T);
97
98
  }
98
99
 
99
100
  getEvents(): Array<Event> {
@@ -1,3 +1,5 @@
1
+ import type { Duration } from "../../duration";
2
+
1
3
  export interface Report {
2
4
  scenarios: Array<Scenario>;
3
5
  }
@@ -8,6 +10,7 @@ export interface Scenario {
8
10
  details: Array<Tagged<"BDDSection", BDDSection> | Tagged<"Action", Action>>;
9
11
  cleanup: Array<CleanupItem>;
10
12
  cleanupSkipped?: boolean;
13
+ duration?: undefined | Duration;
11
14
  }
12
15
 
13
16
  type Tagged<Tag extends string, Target extends object> = Target & {
@@ -17,6 +20,7 @@ type Tagged<Tag extends string, Target extends object> = Target & {
17
20
  export interface OverviewItem {
18
21
  name: string;
19
22
  status: "pending" | "success" | "failure";
23
+ duration?: undefined | Duration;
20
24
  }
21
25
 
22
26
  export interface BDDSection {
@@ -30,6 +34,7 @@ export interface Action {
30
34
  attempts?: undefined | number;
31
35
  commands: Array<Command>;
32
36
  error?: undefined | Error;
37
+ duration?: undefined | Duration;
33
38
  }
34
39
 
35
40
  export interface Command {
@@ -55,6 +60,7 @@ export interface CleanupItem {
55
60
  resource?: undefined | string;
56
61
  status: "success" | "failure";
57
62
  command: CleanupCommand;
63
+ duration?: undefined | Duration;
58
64
  }
59
65
 
60
66
  export interface CleanupCommand {
@@ -1,3 +1,4 @@
1
+ import { Duration } from "../../../duration";
1
2
  import type { Event } from "../../../recording";
2
3
  import type {
3
4
  Action,
@@ -30,6 +31,8 @@ interface ParseState {
30
31
  currentAction: Action | undefined;
31
32
  currentOverviewIndex: number | undefined;
32
33
  currentCleanup: CleanupItem | undefined;
34
+ currentActionStartTimestamp: number | undefined;
35
+ scenarioStartTimestamp: number | undefined;
33
36
  }
34
37
 
35
38
  export function parseEvents(events: ReadonlyArray<Event>): Report {
@@ -41,6 +44,8 @@ export function parseEvents(events: ReadonlyArray<Event>): Report {
41
44
  currentAction: undefined,
42
45
  currentOverviewIndex: undefined,
43
46
  currentCleanup: undefined,
47
+ currentActionStartTimestamp: undefined,
48
+ scenarioStartTimestamp: undefined,
44
49
  };
45
50
 
46
51
  for (const event of events) {
@@ -64,9 +69,19 @@ function handleNonBDDEvent(state: ParseState, event: Event): void {
64
69
  case "ScenarioStart":
65
70
  handleScenarioStart(state, event);
66
71
  return;
67
- case "ScenarioEnd":
72
+ case "ScenarioEnd": {
73
+ if (state.currentScenario) {
74
+ const duration = computeDuration(
75
+ state.scenarioStartTimestamp,
76
+ event.timestamp
77
+ );
78
+ if (duration) {
79
+ state.currentScenario.duration = duration;
80
+ }
81
+ }
68
82
  clearScenarioProgressState(state);
69
83
  return;
84
+ }
70
85
  case "RevertingsStart":
71
86
  state.inCleanup = true;
72
87
  clearCurrentActionState(state);
@@ -109,6 +124,7 @@ function handleActionStart(
109
124
  state: ParseState,
110
125
  event: Extract<Event, { kind: "ActionStart" }>
111
126
  ): void {
127
+ state.currentActionStartTimestamp = event.timestamp;
112
128
  const scenario = ensureScenario(state.currentScenario, state.report);
113
129
  if (state.inCleanup) {
114
130
  const cleanup: CleanupItem = {
@@ -156,10 +172,17 @@ function applyRegularActionEnd(
156
172
  return;
157
173
  }
158
174
 
175
+ const duration = computeDuration(
176
+ state.currentActionStartTimestamp,
177
+ event.timestamp
178
+ );
179
+ currentAction.duration = duration;
180
+
159
181
  if (state.currentOverviewIndex !== undefined) {
160
182
  const overviewItem = currentScenario.overview[state.currentOverviewIndex];
161
183
  if (overviewItem) {
162
184
  overviewItem.status = event.data.ok ? "success" : "failure";
185
+ overviewItem.duration = duration;
163
186
  }
164
187
  }
165
188
 
@@ -177,6 +200,7 @@ function applyRegularActionEnd(
177
200
 
178
201
  state.currentAction = undefined;
179
202
  state.currentOverviewIndex = undefined;
203
+ state.currentActionStartTimestamp = undefined;
180
204
  }
181
205
 
182
206
  function handleCommandResult(
@@ -247,6 +271,7 @@ function handleScenarioStart(
247
271
  };
248
272
  state.report.scenarios.push(state.currentScenario);
249
273
  clearScenarioProgressState(state);
274
+ state.scenarioStartTimestamp = event.timestamp;
250
275
  }
251
276
 
252
277
  function handleActionEnd(
@@ -270,8 +295,16 @@ function handleCleanupActionEnd(
270
295
 
271
296
  if (state.currentCleanup) {
272
297
  state.currentCleanup.status = event.data.ok ? "success" : "failure";
298
+ const duration = computeDuration(
299
+ state.currentActionStartTimestamp,
300
+ event.timestamp
301
+ );
302
+ if (duration) {
303
+ state.currentCleanup.duration = duration;
304
+ }
273
305
  }
274
306
  state.currentCleanup = undefined;
307
+ state.currentActionStartTimestamp = undefined;
275
308
  return true;
276
309
  }
277
310
 
@@ -344,9 +377,24 @@ function ensureScenario(
344
377
  return created;
345
378
  }
346
379
 
380
+ function computeDuration(
381
+ start: number | undefined,
382
+ end: number | undefined
383
+ ): Duration | undefined {
384
+ if (typeof start !== "number" || typeof end !== "number") {
385
+ return undefined;
386
+ }
387
+ const ms = end - start;
388
+ if (ms < 0 || !Number.isFinite(ms) || !Number.isInteger(ms)) {
389
+ return undefined;
390
+ }
391
+ return new Duration(ms);
392
+ }
393
+
347
394
  function clearScenarioProgressState(state: ParseState): void {
348
395
  state.currentBDDSection = undefined;
349
396
  state.inCleanup = false;
397
+ state.scenarioStartTimestamp = undefined;
350
398
  clearCurrentActionState(state);
351
399
  }
352
400
 
@@ -354,6 +402,7 @@ function clearCurrentActionState(state: ParseState): void {
354
402
  state.currentAction = undefined;
355
403
  state.currentOverviewIndex = undefined;
356
404
  state.currentCleanup = undefined;
405
+ state.currentActionStartTimestamp = undefined;
357
406
  }
358
407
 
359
408
  function bddFromEvent(event: Event): BDDSection | undefined {
@@ -1,3 +1,4 @@
1
+ import { Duration } from "../../../duration";
1
2
  import { codeToANSIForcedColors } from "../../shiki";
2
3
  import type { MarkdownReporterOptions } from "../index";
3
4
  import type { Action, Report } from "../model";
@@ -174,14 +175,26 @@ export async function renderReport(
174
175
  lines.push("");
175
176
 
176
177
  // Overview
178
+ const overviewHasDuration = scenario.overview.some((o) => o.duration);
177
179
  lines.push("## Scenario Overview");
178
180
  lines.push("");
179
- lines.push("| # | Action | Status |");
180
- lines.push("|---|--------|--------|");
181
+ if (overviewHasDuration) {
182
+ lines.push("| # | Action | Status | Duration |");
183
+ lines.push("|---|--------|--------|----------|");
184
+ } else {
185
+ lines.push("| # | Action | Status |");
186
+ lines.push("|---|--------|--------|");
187
+ }
181
188
  for (const [i, item] of scenario.overview.entries()) {
182
- lines.push(
183
- `| ${i + 1} | ${stripAnsi(item.name)} | ${statusEmoji(item.status)} |`
184
- );
189
+ if (overviewHasDuration) {
190
+ lines.push(
191
+ `| ${i + 1} | ${stripAnsi(item.name)} | ${statusEmoji(item.status)} | ${item.duration?.toString() ?? ""} |`
192
+ );
193
+ } else {
194
+ lines.push(
195
+ `| ${i + 1} | ${stripAnsi(item.name)} | ${statusEmoji(item.status)} |`
196
+ );
197
+ }
185
198
  }
186
199
  lines.push("");
187
200
 
@@ -308,14 +321,26 @@ export async function renderReport(
308
321
  "To clean up manually, run the revert commands from a passing test run."
309
322
  );
310
323
  } else if (scenario.cleanup.length > 0) {
324
+ const cleanupHasDuration = scenario.cleanup.some((c) => c.duration);
311
325
  lines.push("### Cleanup");
312
326
  lines.push("");
313
- lines.push("| # | Action | Status |");
314
- lines.push("|---|--------|--------|");
327
+ if (cleanupHasDuration) {
328
+ lines.push("| # | Action | Status | Duration |");
329
+ lines.push("|---|--------|--------|----------|");
330
+ } else {
331
+ lines.push("| # | Action | Status |");
332
+ lines.push("|---|--------|--------|");
333
+ }
315
334
  for (const [i, item] of scenario.cleanup.entries()) {
316
- lines.push(
317
- `| ${i + 1} | ${stripAnsi(item.action)} | ${item.status === "success" ? "✅" : "❌"} |`
318
- );
335
+ if (cleanupHasDuration) {
336
+ lines.push(
337
+ `| ${i + 1} | ${stripAnsi(item.action)} | ${item.status === "success" ? "✅" : "❌"} | ${item.duration?.toString() ?? ""} |`
338
+ );
339
+ } else {
340
+ lines.push(
341
+ `| ${i + 1} | ${stripAnsi(item.action)} | ${item.status === "success" ? "✅" : "❌"} |`
342
+ );
343
+ }
319
344
  }
320
345
  lines.push("");
321
346
 
@@ -334,6 +359,33 @@ export async function renderReport(
334
359
  lines.push("```");
335
360
  }
336
361
 
362
+ // Total duration summary
363
+ if (scenario.duration) {
364
+ lines.push("");
365
+ const actionsDurationMs = scenario.overview.reduce(
366
+ (sum, o) => sum + (o.duration?.milliseconds ?? 0),
367
+ 0
368
+ );
369
+ const cleanupDurationMs = scenario.cleanup.reduce(
370
+ (sum, c) => sum + (c.duration?.milliseconds ?? 0),
371
+ 0
372
+ );
373
+ const parts: Array<string> = [];
374
+ if (actionsDurationMs > 0) {
375
+ parts.push(`Actions: ${new Duration(actionsDurationMs).toString()}`);
376
+ }
377
+ if (cleanupDurationMs > 0) {
378
+ parts.push(`Cleanup: ${new Duration(cleanupDurationMs).toString()}`);
379
+ }
380
+ if (parts.length > 0) {
381
+ lines.push(
382
+ `**Total: ${scenario.duration.toString()}** (${parts.join(", ")})`
383
+ );
384
+ } else {
385
+ lines.push(`**Total: ${scenario.duration.toString()}**`);
386
+ }
387
+ }
388
+
337
389
  renderedScenarios.push(lines.join("\n"));
338
390
  }
339
391