@appthrust/kest 0.5.0 → 0.7.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
@@ -113,17 +113,14 @@ await ns.assert({
113
113
  Custom timeouts are supported per action:
114
114
 
115
115
  ```ts
116
- await ns.assert(
117
- {
118
- apiVersion: "apps/v1",
119
- kind: "Deployment",
120
- name: "my-app",
121
- test() {
122
- expect(this.status?.availableReplicas).toBe(3);
123
- },
116
+ await ns.assert({
117
+ apiVersion: "apps/v1",
118
+ kind: "Deployment",
119
+ name: "my-app",
120
+ test() {
121
+ expect(this.status?.availableReplicas).toBe(3);
124
122
  },
125
- { timeout: "30s", interval: "1s" },
126
- );
123
+ });
127
124
  ```
128
125
 
129
126
  ### Create Resources
@@ -252,7 +249,7 @@ await ns.assertOne<ConfigMap>({
252
249
  test() {
253
250
  expect(this.data?.mode).toBe("auto");
254
251
  },
255
- }, { timeout: "30s", interval: "1s" });
252
+ });
256
253
  ```
257
254
 
258
255
  `assertOne` throws if zero or more than one resource matches, and retries until exactly one is found or the timeout expires.
@@ -269,19 +266,57 @@ await ns.assertAbsence({
269
266
  });
270
267
  ```
271
268
 
272
- With retry-based polling to wait for a resource to disappear:
269
+ ### Error Assertions
270
+
271
+ Assert that applying or creating a resource produces an error (e.g. an admission webhook rejects the request, or a validation rule fails). The `test` callback inspects the error -- `this` is bound to the `Error`:
273
272
 
274
273
  ```ts
275
- await ns.assertAbsence(
276
- {
277
- apiVersion: "apps/v1",
278
- kind: "Deployment",
279
- name: "my-app",
274
+ await ns.assertApplyError({
275
+ apply: {
276
+ apiVersion: "example.com/v1",
277
+ kind: "MyResource",
278
+ metadata: { name: "my-resource" },
279
+ spec: { immutableField: "changed" },
280
+ },
281
+ test() {
282
+ expect(this.message).toContain("field is immutable");
280
283
  },
281
- { timeout: "30s", interval: "1s" },
282
- );
284
+ });
285
+ ```
286
+
287
+ The `test` callback participates in retry -- if it throws, the action is retried until the callback passes or the timeout expires. This is useful when a webhook is being set up asynchronously:
288
+
289
+ ```ts
290
+ await ns.assertApplyError({
291
+ apply: {
292
+ apiVersion: "example.com/v1",
293
+ kind: "MyResource",
294
+ metadata: { name: "my-resource" },
295
+ spec: { immutableField: "changed" },
296
+ },
297
+ test(error) {
298
+ expect(error.message).toContain("field is immutable");
299
+ },
300
+ });
283
301
  ```
284
302
 
303
+ `assertCreateError` works identically for `kubectl create`:
304
+
305
+ ```ts
306
+ await ns.assertCreateError({
307
+ create: {
308
+ apiVersion: "v1",
309
+ kind: "ConfigMap",
310
+ metadata: { name: "already-exists" },
311
+ },
312
+ test(error) {
313
+ expect(error.message).toContain("already exists");
314
+ },
315
+ });
316
+ ```
317
+
318
+ If the apply/create unexpectedly succeeds (e.g. the webhook is not yet active), the resource is immediately reverted and the action retries until the expected error occurs.
319
+
285
320
  ### Label Resources
286
321
 
287
322
  Add, update, or remove labels on Kubernetes resources using `kubectl label`:
@@ -470,6 +505,8 @@ The top-level API surface available in every test callback.
470
505
  | ----------------------------------------------------------------------- | ----------------------------------------------------------- |
471
506
  | `apply(manifest, options?)` | Apply a Kubernetes manifest and register cleanup |
472
507
  | `create(manifest, options?)` | Create a Kubernetes resource and register cleanup |
508
+ | `assertApplyError(input, options?)` | Assert that `kubectl apply` produces an error |
509
+ | `assertCreateError(input, options?)` | Assert that `kubectl create` produces an error |
473
510
  | `applyStatus(manifest, options?)` | Apply a status subresource (server-side apply) |
474
511
  | `delete(resource, options?)` | Delete a resource by API version, kind, and name |
475
512
  | `label(input, options?)` | Add, update, or remove labels on a resource |
@@ -486,7 +523,7 @@ The top-level API surface available in every test callback.
486
523
 
487
524
  ### Namespace / Cluster
488
525
 
489
- Returned by `newNamespace()` and `useCluster()` respectively. They expose the same core methods (`apply`, `create`, `applyStatus`, `delete`, `label`, `get`, `assert`, `assertAbsence`, `assertList`, `assertOne`) scoped to their namespace or cluster context. `Cluster` additionally supports `newNamespace`.
526
+ Returned by `newNamespace()` and `useCluster()` respectively. They expose the same core methods (`apply`, `create`, `assertApplyError`, `assertCreateError`, `applyStatus`, `delete`, `label`, `get`, `assert`, `assertAbsence`, `assertList`, `assertOne`) scoped to their namespace or cluster context. `Cluster` additionally supports `newNamespace`.
490
527
 
491
528
  `Namespace` also exposes a `name` property:
492
529
 
@@ -60,6 +60,34 @@ test("Example: applies ConfigMap using YAML, file import, and object literal", a
60
60
  // 4. Namespace
61
61
  });
62
62
 
63
+ test("Example: diff demo - ConfigMap data mismatch (expected to fail)", async (s) => {
64
+ s.given("a new namespace exists");
65
+ const ns = await s.newNamespace();
66
+
67
+ s.when("I apply a ConfigMap with actual data");
68
+ await ns.apply<ConfigMap>({
69
+ apiVersion: "v1",
70
+ kind: "ConfigMap",
71
+ metadata: { name: "diff-demo" },
72
+ data: { mode: "actual-value", env: "production" },
73
+ });
74
+
75
+ s.then("asserting with different expected data should produce a diff");
76
+ await ns.assert<ConfigMap>({
77
+ apiVersion: "v1",
78
+ kind: "ConfigMap",
79
+ name: "diff-demo",
80
+ test() {
81
+ expect(this).toMatchObject({
82
+ data: {
83
+ mode: "expected-value",
84
+ env: "staging",
85
+ },
86
+ });
87
+ },
88
+ });
89
+ });
90
+
63
91
  test("Example: asserts a non-existent ConfigMap (expected to fail)", async (s) => {
64
92
  s.given("a new namespace exists");
65
93
  const ns = await s.newNamespace();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appthrust/kest",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Kubernetes E2E testing framework designed for humans and AI alike",
5
5
  "type": "module",
6
6
  "module": "ts/index.ts",
@@ -0,0 +1,43 @@
1
+ import type { AssertApplyErrorInput } from "../apis";
2
+ import { apply } from "./apply";
3
+ import type { MutateDef } from "./types";
4
+
5
+ export const assertApplyError = {
6
+ type: "mutate",
7
+ name: "AssertApplyError",
8
+ mutate:
9
+ ({ kubectl }) =>
10
+ async (input) => {
11
+ const applyFn = apply.mutate({ kubectl });
12
+ let result: Awaited<ReturnType<typeof applyFn>> | undefined;
13
+ let rejection: Error | undefined;
14
+ try {
15
+ result = await applyFn(input.apply);
16
+ } catch (err) {
17
+ rejection = err as Error;
18
+ }
19
+
20
+ // Apply succeeded unexpectedly -- revert immediately and throw so that
21
+ // the scenario wrapper retries.
22
+ if (result !== undefined) {
23
+ await result.revert();
24
+ throw new Error(
25
+ `Expected ${apply.describe(input.apply)} to err, but it succeeded`
26
+ );
27
+ }
28
+
29
+ // Apply erred as expected -- run test callback.
30
+ // biome-ignore lint/style/noNonNullAssertion: rejection is guaranteed non-undefined when result is undefined
31
+ await input.test.call(rejection!, rejection!);
32
+
33
+ return {
34
+ async revert() {
35
+ // Nothing to clean up -- the resource was never created.
36
+ },
37
+ output: undefined,
38
+ };
39
+ },
40
+ describe: (input) => {
41
+ return `${apply.describe(input.apply)} (expected error)`;
42
+ },
43
+ } satisfies MutateDef<AssertApplyErrorInput, void>;
@@ -0,0 +1,43 @@
1
+ import type { AssertCreateErrorInput } from "../apis";
2
+ import { create } from "./create";
3
+ import type { MutateDef } from "./types";
4
+
5
+ export const assertCreateError = {
6
+ type: "mutate",
7
+ name: "AssertCreateError",
8
+ mutate:
9
+ ({ kubectl }) =>
10
+ async (input) => {
11
+ const createFn = create.mutate({ kubectl });
12
+ let result: Awaited<ReturnType<typeof createFn>> | undefined;
13
+ let rejection: Error | undefined;
14
+ try {
15
+ result = await createFn(input.create);
16
+ } catch (err) {
17
+ rejection = err as Error;
18
+ }
19
+
20
+ // Create succeeded unexpectedly -- revert immediately and throw so that
21
+ // the scenario wrapper retries.
22
+ if (result !== undefined) {
23
+ await result.revert();
24
+ throw new Error(
25
+ `Expected ${create.describe(input.create)} to err, but it succeeded`
26
+ );
27
+ }
28
+
29
+ // Create erred as expected -- run test callback.
30
+ // biome-ignore lint/style/noNonNullAssertion: rejection is guaranteed non-undefined when result is undefined
31
+ await input.test.call(rejection!, rejection!);
32
+
33
+ return {
34
+ async revert() {
35
+ // Nothing to clean up -- the resource was never created.
36
+ },
37
+ output: undefined,
38
+ };
39
+ },
40
+ describe: (input) => {
41
+ return `${create.describe(input.create)} (expected error)`;
42
+ },
43
+ } satisfies MutateDef<AssertCreateErrorInput, void>;
@@ -1,6 +1,6 @@
1
+ import { generateName } from "../naming";
1
2
  import { create } from "./create";
2
3
  import type { MutateDef } from "./types";
3
- import { generateName } from "../naming";
4
4
 
5
5
  /**
6
6
  * Input for namespace creation.
package/ts/apis/index.ts CHANGED
@@ -87,6 +87,75 @@ export interface Scenario {
87
87
  options?: undefined | ActionOptions
88
88
  ): Promise<void>;
89
89
 
90
+ /**
91
+ * Asserts that `kubectl apply` produces an error.
92
+ *
93
+ * The manifest is applied, and the action succeeds when the API server
94
+ * returns an error (e.g. an admission webhook rejects the request). The
95
+ * `test` callback must also pass for the action to succeed.
96
+ *
97
+ * If the apply unexpectedly succeeds, the created resource is immediately
98
+ * reverted and the action is retried until the expected error occurs or the
99
+ * timeout expires.
100
+ *
101
+ * @template T - The expected Kubernetes resource shape.
102
+ * @param input - Manifest to apply and error assertion callback.
103
+ * @param options - Retry options such as timeout and polling interval.
104
+ *
105
+ * @example
106
+ * ```ts
107
+ * await s.assertApplyError({
108
+ * apply: {
109
+ * apiVersion: "example.com/v1",
110
+ * kind: "MyResource",
111
+ * metadata: { name: "my-resource" },
112
+ * spec: { immutableField: "changed" },
113
+ * },
114
+ * test() {
115
+ * expect(this.message).toContain("field is immutable");
116
+ * },
117
+ * });
118
+ * ```
119
+ */
120
+ assertApplyError<T extends K8sResource>(
121
+ input: AssertApplyErrorInput<T>,
122
+ options?: undefined | ActionOptions
123
+ ): Promise<void>;
124
+
125
+ /**
126
+ * Asserts that `kubectl create` produces an error.
127
+ *
128
+ * The manifest is created, and the action succeeds when the API server
129
+ * returns an error. The `test` callback must also pass for the action to
130
+ * succeed.
131
+ *
132
+ * If the create unexpectedly succeeds, the created resource is immediately
133
+ * reverted and the action is retried until the expected error occurs or the
134
+ * timeout expires.
135
+ *
136
+ * @template T - The expected Kubernetes resource shape.
137
+ * @param input - Manifest to create and error assertion callback.
138
+ * @param options - Retry options such as timeout and polling interval.
139
+ *
140
+ * @example
141
+ * ```ts
142
+ * await s.assertCreateError({
143
+ * create: {
144
+ * apiVersion: "v1",
145
+ * kind: "ConfigMap",
146
+ * metadata: { name: "already-exists" },
147
+ * },
148
+ * test(error) {
149
+ * expect(error.message).toContain("already exists");
150
+ * },
151
+ * });
152
+ * ```
153
+ */
154
+ assertCreateError<T extends K8sResource>(
155
+ input: AssertCreateErrorInput<T>,
156
+ options?: undefined | ActionOptions
157
+ ): Promise<void>;
158
+
90
159
  /**
91
160
  * Applies the `status` subresource using server-side apply.
92
161
  *
@@ -573,6 +642,30 @@ export interface Cluster {
573
642
  options?: undefined | ActionOptions
574
643
  ): Promise<void>;
575
644
 
645
+ /**
646
+ * Asserts that `kubectl apply` produces an error.
647
+ *
648
+ * @template T - The expected Kubernetes resource shape.
649
+ * @param input - Manifest to apply and error assertion callback.
650
+ * @param options - Retry options such as timeout and polling interval.
651
+ */
652
+ assertApplyError<T extends K8sResource>(
653
+ input: AssertApplyErrorInput<T>,
654
+ options?: undefined | ActionOptions
655
+ ): Promise<void>;
656
+
657
+ /**
658
+ * Asserts that `kubectl create` produces an error.
659
+ *
660
+ * @template T - The expected Kubernetes resource shape.
661
+ * @param input - Manifest to create and error assertion callback.
662
+ * @param options - Retry options such as timeout and polling interval.
663
+ */
664
+ assertCreateError<T extends K8sResource>(
665
+ input: AssertCreateErrorInput<T>,
666
+ options?: undefined | ActionOptions
667
+ ): Promise<void>;
668
+
576
669
  /**
577
670
  * Applies the `status` subresource using server-side apply.
578
671
  *
@@ -871,6 +964,30 @@ export interface Namespace {
871
964
  options?: undefined | ActionOptions
872
965
  ): Promise<void>;
873
966
 
967
+ /**
968
+ * Asserts that `kubectl apply` produces an error in this namespace.
969
+ *
970
+ * @template T - The expected Kubernetes resource shape.
971
+ * @param input - Manifest to apply and error assertion callback.
972
+ * @param options - Retry options such as timeout and polling interval.
973
+ */
974
+ assertApplyError<T extends K8sResource>(
975
+ input: AssertApplyErrorInput<T>,
976
+ options?: undefined | ActionOptions
977
+ ): Promise<void>;
978
+
979
+ /**
980
+ * Asserts that `kubectl create` produces an error in this namespace.
981
+ *
982
+ * @template T - The expected Kubernetes resource shape.
983
+ * @param input - Manifest to create and error assertion callback.
984
+ * @param options - Retry options such as timeout and polling interval.
985
+ */
986
+ assertCreateError<T extends K8sResource>(
987
+ input: AssertCreateErrorInput<T>,
988
+ options?: undefined | ActionOptions
989
+ ): Promise<void>;
990
+
874
991
  /**
875
992
  * Applies the `status` subresource in this namespace using server-side apply.
876
993
  *
@@ -1279,6 +1396,56 @@ export interface ResourceOneTest<T extends K8sResource = K8sResource> {
1279
1396
  readonly test: (this: T, resource: T) => unknown | Promise<unknown>;
1280
1397
  }
1281
1398
 
1399
+ /**
1400
+ * A test definition for {@link Scenario.assertApplyError},
1401
+ * {@link Cluster.assertApplyError}, and {@link Namespace.assertApplyError}.
1402
+ *
1403
+ * Attempts `kubectl apply` and asserts that the API server returns an error.
1404
+ * When the operation errors as expected, the `test` callback is invoked with
1405
+ * `this` bound to the {@link Error}.
1406
+ */
1407
+ export interface AssertApplyErrorInput<T extends K8sResource = K8sResource> {
1408
+ /**
1409
+ * The manifest to apply. Accepts the same formats as {@link Scenario.apply}:
1410
+ * an object literal, a YAML string, or an imported YAML module.
1411
+ */
1412
+ readonly apply: ApplyingManifest<T>;
1413
+
1414
+ /**
1415
+ * Assertion callback invoked when the apply errors as expected.
1416
+ *
1417
+ * `this` is bound to the {@link Error} returned by the API server.
1418
+ * Throwing (or rejecting) signals that the error did not match expectations
1419
+ * and triggers a retry (if timeout allows).
1420
+ */
1421
+ readonly test: (this: Error, error: Error) => unknown | Promise<unknown>;
1422
+ }
1423
+
1424
+ /**
1425
+ * A test definition for {@link Scenario.assertCreateError},
1426
+ * {@link Cluster.assertCreateError}, and {@link Namespace.assertCreateError}.
1427
+ *
1428
+ * Attempts `kubectl create` and asserts that the API server returns an error.
1429
+ * When the operation errors as expected, the `test` callback is invoked with
1430
+ * `this` bound to the {@link Error}.
1431
+ */
1432
+ export interface AssertCreateErrorInput<T extends K8sResource = K8sResource> {
1433
+ /**
1434
+ * The manifest to create. Accepts the same formats as {@link Scenario.create}:
1435
+ * an object literal, a YAML string, or an imported YAML module.
1436
+ */
1437
+ readonly create: ApplyingManifest<T>;
1438
+
1439
+ /**
1440
+ * Assertion callback invoked when the create errors as expected.
1441
+ *
1442
+ * `this` is bound to the {@link Error} returned by the API server.
1443
+ * Throwing (or rejecting) signals that the error did not match expectations
1444
+ * and triggers a retry (if timeout allows).
1445
+ */
1446
+ readonly test: (this: Error, error: Error) => unknown | Promise<unknown>;
1447
+ }
1448
+
1282
1449
  /**
1283
1450
  * Kubernetes cluster selector for {@link Scenario.useCluster}.
1284
1451
  */
@@ -8,7 +8,8 @@ const consonantDigits = "bcdfghjklmnpqrstvwxyz0123456789";
8
8
  export function randomConsonantDigits(length = 8): string {
9
9
  let result = "";
10
10
  for (let i = 0; i < length; i++) {
11
- result += consonantDigits[Math.floor(Math.random() * consonantDigits.length)];
11
+ result +=
12
+ consonantDigits[Math.floor(Math.random() * consonantDigits.length)];
12
13
  }
13
14
  return result;
14
15
  }
@@ -24,4 +25,3 @@ export function randomConsonantDigits(length = 8): string {
24
25
  export function generateName(prefix: string, suffixLength = 5): string {
25
26
  return `${prefix}${randomConsonantDigits(suffixLength)}`;
26
27
  }
27
-
@@ -53,6 +53,7 @@ type CommandEvent =
53
53
 
54
54
  type RetryEvent =
55
55
  | BaseEvent<"RetryStart", Record<string, never>>
56
+ | BaseEvent<"RetryAttempt", { readonly attempt: number }>
56
57
  | BaseEvent<
57
58
  "RetryEnd",
58
59
  | {
@@ -27,7 +27,7 @@ export interface BDDSection {
27
27
  export interface Action {
28
28
  name: string;
29
29
  attempts?: undefined | number;
30
- command?: undefined | Command;
30
+ commands: Array<Command>;
31
31
  error?: undefined | Error;
32
32
  }
33
33
 
@@ -7,6 +7,7 @@ import type {
7
7
  Report,
8
8
  Scenario,
9
9
  } from "../model";
10
+ import { stripAnsi } from "../strip-ansi";
10
11
 
11
12
  const bddKeywordByKind = {
12
13
  BDDGiven: "given",
@@ -89,6 +90,9 @@ function handleNonBDDEvent(state: ParseState, event: Event): void {
89
90
  case "RetryEnd":
90
91
  handleRetryEnd(state, event);
91
92
  return;
93
+ case "RetryAttempt":
94
+ handleRetryAttempt(state);
95
+ return;
92
96
  case "RetryStart":
93
97
  return;
94
98
  default:
@@ -114,7 +118,7 @@ function handleActionStart(
114
118
  return;
115
119
  }
116
120
 
117
- const action: Action = { name: event.data.description };
121
+ const action: Action = { name: event.data.description, commands: [] };
118
122
  scenario.overview.push({
119
123
  name: event.data.description,
120
124
  status: "pending",
@@ -158,7 +162,9 @@ function applyRegularActionEnd(
158
162
  currentAction.error = {
159
163
  message: {
160
164
  text: event.data.error.message,
161
- language: isDiffLike(event.data.error.message) ? "diff" : "text",
165
+ language: isDiffLike(stripAnsi(event.data.error.message))
166
+ ? "diff"
167
+ : "text",
162
168
  },
163
169
  };
164
170
  }
@@ -180,17 +186,21 @@ function handleCommandResult(
180
186
  }
181
187
 
182
188
  const { currentAction } = state;
183
- if (!currentAction?.command) {
189
+ if (!currentAction || currentAction.commands.length === 0) {
184
190
  return;
185
191
  }
186
192
 
187
- currentAction.command.stdout = {
193
+ const command = currentAction.commands.at(-1);
194
+ if (!command) {
195
+ return;
196
+ }
197
+ command.stdout = {
188
198
  text: event.data.stdout,
189
199
  ...(event.data.stdoutLanguage
190
200
  ? { language: event.data.stdoutLanguage }
191
201
  : {}),
192
202
  };
193
- currentAction.command.stderr = {
203
+ command.stderr = {
194
204
  text: event.data.stderr,
195
205
  ...(event.data.stderrLanguage
196
206
  ? { language: event.data.stderrLanguage }
@@ -216,7 +226,7 @@ function handleCommandRun(
216
226
  if (!state.currentAction) {
217
227
  return;
218
228
  }
219
- state.currentAction.command = createCommandFromRun(event);
229
+ state.currentAction.commands.push(createCommandFromRun(event));
220
230
  }
221
231
 
222
232
  function handleScenarioStart(
@@ -259,6 +269,16 @@ function handleCleanupActionEnd(
259
269
  return true;
260
270
  }
261
271
 
272
+ function handleRetryAttempt(state: ParseState): void {
273
+ if (state.inCleanup) {
274
+ return;
275
+ }
276
+ if (!state.currentAction) {
277
+ return;
278
+ }
279
+ state.currentAction.commands = [];
280
+ }
281
+
262
282
  function handleRetryEnd(
263
283
  state: ParseState,
264
284
  event: Extract<Event, { kind: "RetryEnd" }>
@@ -338,7 +358,7 @@ function bddFromEvent(event: Event): BDDSection | undefined {
338
358
  return { keyword, description: event.data.description, actions: [] };
339
359
  }
340
360
 
341
- function isDiffLike(message: string): boolean {
361
+ export function isDiffLike(message: string): boolean {
342
362
  const lines = message.split(/\r?\n/);
343
363
  let sawPlus = false;
344
364
  let sawMinus = false;
@@ -1,6 +1,7 @@
1
1
  import { codeToANSIForcedColors } from "../../shiki";
2
2
  import type { MarkdownReporterOptions } from "../index";
3
3
  import type { Action, Report } from "../model";
4
+ import { stripAnsi } from "../strip-ansi";
4
5
 
5
6
  const markdownLang = "markdown";
6
7
  const markdownTheme = "catppuccin-mocha";
@@ -83,15 +84,6 @@ async function highlightMarkdown(
83
84
  }
84
85
  }
85
86
 
86
- function stripAnsi(input: string): string {
87
- // Prefer Bun's built-in ANSI stripper when available.
88
- if (typeof Bun !== "undefined" && typeof Bun.stripANSI === "function") {
89
- return Bun.stripANSI(input);
90
- }
91
- // biome-ignore lint/suspicious/noControlCharactersInRegex: intended
92
- return input.replace(/\u001b\[[0-9;]*m/g, "");
93
- }
94
-
95
87
  function trimFinalNewline(input: string): string {
96
88
  return input.replace(/\n$/, "");
97
89
  }
@@ -165,7 +157,7 @@ export function renderReport(
165
157
  if (!status) {
166
158
  if (action.error) {
167
159
  status = "failure";
168
- } else if (action.command) {
160
+ } else if (action.commands.length > 0) {
169
161
  status = "success";
170
162
  } else {
171
163
  status = "pending";
@@ -180,8 +172,7 @@ export function renderReport(
180
172
  lines.push(`**${emoji} ${stripAnsi(action.name)}**${attemptsSuffix}`);
181
173
  lines.push("");
182
174
 
183
- const cmd = action.command;
184
- if (cmd) {
175
+ for (const cmd of action.commands) {
185
176
  const base = [cmd.cmd, ...cmd.args].join(" ").trim();
186
177
  const stdin = cmd.stdin?.text;
187
178
  const stdinLanguage = cmd.stdin?.language ?? "text";
@@ -0,0 +1,8 @@
1
+ export function stripAnsi(input: string): string {
2
+ // Prefer Bun's built-in ANSI stripper when available.
3
+ if (typeof Bun !== "undefined" && typeof Bun.stripANSI === "function") {
4
+ return Bun.stripANSI(input);
5
+ }
6
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: intended
7
+ return input.replace(/\u001b\[[0-9;]*m/g, "");
8
+ }
package/ts/retry.ts CHANGED
@@ -77,6 +77,7 @@ export async function retryUntil<T>(
77
77
  }
78
78
 
79
79
  retries += 1;
80
+ recorder?.record("RetryAttempt", { attempt: retries });
80
81
 
81
82
  try {
82
83
  const value = await fn();
@@ -2,6 +2,8 @@ import { apply } from "../actions/apply";
2
2
  import { applyStatus } from "../actions/apply-status";
3
3
  import { assert } from "../actions/assert";
4
4
  import { assertAbsence } from "../actions/assert-absence";
5
+ import { assertApplyError } from "../actions/assert-apply-error";
6
+ import { assertCreateError } from "../actions/assert-create-error";
5
7
  import { assertList } from "../actions/assert-list";
6
8
  import { assertOne } from "../actions/assert-one";
7
9
  import { create } from "../actions/create";
@@ -39,6 +41,8 @@ export function createScenario(deps: CreateScenarioOptions): InternalScenario {
39
41
  return {
40
42
  apply: createMutateFn(deps, apply),
41
43
  create: createMutateFn(deps, create),
44
+ assertApplyError: createMutateFn(deps, assertApplyError),
45
+ assertCreateError: createMutateFn(deps, assertCreateError),
42
46
  applyStatus: createOneWayMutateFn(deps, applyStatus),
43
47
  delete: createOneWayMutateFn(deps, deleteResource),
44
48
  label: createOneWayMutateFn(deps, label),
@@ -205,6 +209,8 @@ const createNewNamespaceFn =
205
209
  name: namespaceName,
206
210
  apply: createMutateFn(namespacedDeps, apply),
207
211
  create: createMutateFn(namespacedDeps, create),
212
+ assertApplyError: createMutateFn(namespacedDeps, assertApplyError),
213
+ assertCreateError: createMutateFn(namespacedDeps, assertCreateError),
208
214
  applyStatus: createOneWayMutateFn(namespacedDeps, applyStatus),
209
215
  delete: createOneWayMutateFn(namespacedDeps, deleteResource),
210
216
  label: createOneWayMutateFn(namespacedDeps, label),
@@ -229,6 +235,8 @@ const createUseClusterFn =
229
235
  return {
230
236
  apply: createMutateFn(clusterDeps, apply),
231
237
  create: createMutateFn(clusterDeps, create),
238
+ assertApplyError: createMutateFn(clusterDeps, assertApplyError),
239
+ assertCreateError: createMutateFn(clusterDeps, assertCreateError),
232
240
  applyStatus: createOneWayMutateFn(clusterDeps, applyStatus),
233
241
  delete: createOneWayMutateFn(clusterDeps, deleteResource),
234
242
  label: createOneWayMutateFn(clusterDeps, label),