@appthrust/kest 0.4.1 → 0.6.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
@@ -78,6 +78,13 @@ const ns = await s.newNamespace({ generateName: "foo-" });
78
78
  // Namespace name will be like "foo-d7kpn"
79
79
  ```
80
80
 
81
+ The returned object has a `name` property that holds the generated namespace name:
82
+
83
+ ```ts
84
+ const ns = await s.newNamespace({ generateName: "foo-" });
85
+ console.log(ns.name); //=> "foo-d7kpn"
86
+ ```
87
+
81
88
  ### Automatic Cleanup (Reverse-Order, Blocking)
82
89
 
83
90
  Resources are deleted in the reverse order they were created (LIFO). Kest waits until each resource is fully removed before proceeding, preventing flaky failures caused by lingering resources or `Terminating` namespaces.
@@ -106,17 +113,14 @@ await ns.assert({
106
113
  Custom timeouts are supported per action:
107
114
 
108
115
  ```ts
109
- await ns.assert(
110
- {
111
- apiVersion: "apps/v1",
112
- kind: "Deployment",
113
- name: "my-app",
114
- test() {
115
- expect(this.status?.availableReplicas).toBe(3);
116
- },
116
+ await ns.assert({
117
+ apiVersion: "apps/v1",
118
+ kind: "Deployment",
119
+ name: "my-app",
120
+ test() {
121
+ expect(this.status?.availableReplicas).toBe(3);
117
122
  },
118
- { timeout: "30s", interval: "1s" },
119
- );
123
+ });
120
124
  ```
121
125
 
122
126
  ### Create Resources
@@ -219,6 +223,37 @@ await ns.assertList<ConfigMap>({
219
223
  });
220
224
  ```
221
225
 
226
+ ### Single-Resource List Assertions
227
+
228
+ Assert that exactly one resource of a kind exists (or matches a predicate) and test it:
229
+
230
+ ```ts
231
+ // Assert exactly one ConfigMap exists and check its data
232
+ await ns.assertOne<ConfigMap>({
233
+ apiVersion: "v1",
234
+ kind: "ConfigMap",
235
+ test() {
236
+ expect(this.data?.mode).toBe("demo");
237
+ },
238
+ });
239
+ ```
240
+
241
+ Use the optional `where` predicate to narrow candidates when multiple resources exist:
242
+
243
+ ```ts
244
+ // Find the one ConfigMap whose name starts with "generated-"
245
+ await ns.assertOne<ConfigMap>({
246
+ apiVersion: "v1",
247
+ kind: "ConfigMap",
248
+ where: (cm) => cm.metadata.name.startsWith("generated-"),
249
+ test() {
250
+ expect(this.data?.mode).toBe("auto");
251
+ },
252
+ });
253
+ ```
254
+
255
+ `assertOne` throws if zero or more than one resource matches, and retries until exactly one is found or the timeout expires.
256
+
222
257
  ### Absence Assertions
223
258
 
224
259
  Assert that a resource does not exist (e.g. after deletion or to verify a controller hasn't created something):
@@ -231,19 +266,57 @@ await ns.assertAbsence({
231
266
  });
232
267
  ```
233
268
 
234
- 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`:
235
272
 
236
273
  ```ts
237
- await ns.assertAbsence(
238
- {
239
- apiVersion: "apps/v1",
240
- kind: "Deployment",
241
- 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");
242
283
  },
243
- { timeout: "30s", interval: "1s" },
244
- );
284
+ });
245
285
  ```
246
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
+ });
301
+ ```
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
+
247
320
  ### Label Resources
248
321
 
249
322
  Add, update, or remove labels on Kubernetes resources using `kubectl label`:
@@ -432,6 +505,8 @@ The top-level API surface available in every test callback.
432
505
  | ----------------------------------------------------------------------- | ----------------------------------------------------------- |
433
506
  | `apply(manifest, options?)` | Apply a Kubernetes manifest and register cleanup |
434
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 |
435
510
  | `applyStatus(manifest, options?)` | Apply a status subresource (server-side apply) |
436
511
  | `delete(resource, options?)` | Delete a resource by API version, kind, and name |
437
512
  | `label(input, options?)` | Add, update, or remove labels on a resource |
@@ -439,6 +514,7 @@ The top-level API surface available in every test callback.
439
514
  | `assert(resource, options?)` | Fetch a resource and run assertions with retries |
440
515
  | `assertAbsence(resource, options?)` | Assert that a resource does not exist |
441
516
  | `assertList(resource, options?)` | Fetch a list of resources and run assertions |
517
+ | `assertOne(resource, options?)` | Assert exactly one resource matches, then run assertions |
442
518
  | `newNamespace(name?, options?)` | Create an ephemeral namespace (supports `{ generateName }`) |
443
519
  | `generateName(prefix)` | Generate a random-suffix name (statistical uniqueness) |
444
520
  | `exec(input, options?)` | Execute shell commands with optional revert |
@@ -447,7 +523,13 @@ The top-level API surface available in every test callback.
447
523
 
448
524
  ### Namespace / Cluster
449
525
 
450
- Returned by `newNamespace()` and `useCluster()` respectively. They expose the same core methods (`apply`, `create`, `applyStatus`, `delete`, `label`, `get`, `assert`, `assertAbsence`, `assertList`) 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`.
527
+
528
+ `Namespace` also exposes a `name` property:
529
+
530
+ | Property | Type | Description |
531
+ | -------- | -------- | -------------------------------------------------- |
532
+ | `name` | `string` | The generated namespace name (e.g. `"kest-abc12"`) |
451
533
 
452
534
  ### Action Options
453
535
 
@@ -617,11 +699,11 @@ Starting with one file per scenario reserves room to grow. Each file has built-i
617
699
 
618
700
  **Name files after the behavior they verify.** A reader should know what a test checks without opening the file:
619
701
 
620
- | ✅ Good | ❌ Bad |
621
- | --- | --- |
622
- | `creates-namespaces-with-labels.test.ts` | `tenant-test-1.test.ts` |
623
- | `rejects-reserved-selector-labels.test.ts` | `validation.test.ts` |
624
- | `rolls-out-when-image-changes.test.ts` | `deployment-tests.test.ts` |
702
+ | ✅ Good | ❌ Bad |
703
+ | ------------------------------------------ | -------------------------- |
704
+ | `creates-namespaces-with-labels.test.ts` | `tenant-test-1.test.ts` |
705
+ | `rejects-reserved-selector-labels.test.ts` | `validation.test.ts` |
706
+ | `rolls-out-when-image-changes.test.ts` | `deployment-tests.test.ts` |
625
707
 
626
708
  **Exceptions.** Tiny negative-case variations of the same API -- where each case is only a few lines and they are always read together (e.g. boundary-condition lists) -- may share a file. When you do this, leave a comment explaining why, because the exception easily becomes the norm.
627
709
 
@@ -714,39 +796,114 @@ await ns.apply(import("./fixtures/deployment-v2.yaml"));
714
796
  await ns.apply(import("./fixtures/deployment-v1.ts"));
715
797
  ```
716
798
 
717
- If you do use helpers, limit them to **name generation** -- never to assembling `spec`:
799
+ **Manifest-visibility checklist:**
800
+
801
+ - [ ] Can you understand the full input by reading just the test file?
802
+ - [ ] Is the diff between test steps visible as a spec-level change?
803
+ - [ ] Can you reconstruct the applied manifest from the failure report alone?
804
+
805
+ ### Avoiding naming collisions between tests
806
+
807
+ When tests run in parallel, hard-coded resource names can collide. In most cases, `newNamespace()` is all you need -- each test gets its own namespace, so names like `"my-config"` or `"my-app"` are already isolated:
718
808
 
719
809
  ```ts
720
- // Good helper generates a name; the manifest stays in the test
721
- const name = s.generateName("deploy-");
810
+ const ns = await s.newNamespace();
811
+
812
+ await ns.apply({
813
+ apiVersion: "v1",
814
+ kind: "ConfigMap",
815
+ metadata: { name: "app-config" }, // safe — no other test shares this namespace
816
+ data: { mode: "test" },
817
+ });
818
+ ```
819
+
820
+ Because the namespace itself is unique, the resource names inside it don't need to be randomized. **You only need `s.generateName` when `newNamespace` alone is not enough.**
821
+
822
+ A common mistake is to reach for `s.generateName` on _every_ resource "just to be safe." This adds no real protection when the resources are already in an isolated namespace, and it makes tests harder to read and debug:
823
+
824
+ ```ts
825
+ // ❌ Bad — generateName on namespaced resources inside an isolated namespace.
826
+ // The random suffixes add noise without preventing any actual collisions.
827
+ const ns = await s.newNamespace();
828
+
829
+ const configName = s.generateName("cfg-");
830
+ const deployName = s.generateName("deploy-");
831
+
832
+ await ns.apply({
833
+ apiVersion: "v1",
834
+ kind: "ConfigMap",
835
+ metadata: { name: configName }, // "cfg-x7k2m" — hard to find in logs
836
+ data: { mode: "test" },
837
+ });
722
838
  await ns.apply({
723
839
  apiVersion: "apps/v1",
724
840
  kind: "Deployment",
725
- metadata: { name },
726
- spec: {
727
- /* full spec here, not hidden in a function */
728
- },
841
+ metadata: { name: deployName }, // "deploy-p3n8r" — what was this test about?
842
+ // ...
729
843
  });
730
844
  ```
731
845
 
732
- **Manifest-visibility checklist:**
846
+ ```ts
847
+ // ✅ Good — fixed, descriptive names inside an isolated namespace.
848
+ // The namespace already guarantees no collisions. Fixed names are
849
+ // easy to read, easy to grep in logs, and match the failure report.
850
+ const ns = await s.newNamespace();
733
851
 
734
- - [ ] Can you understand the full input by reading just the test file?
735
- - [ ] Is the diff between test steps visible as a spec-level change?
736
- - [ ] Can you reconstruct the applied manifest from the failure report alone?
737
- - [ ] Are helper functions limited to name generation (no `spec` assembly)?
852
+ await ns.apply({
853
+ apiVersion: "v1",
854
+ kind: "ConfigMap",
855
+ metadata: { name: "app-config" },
856
+ data: { mode: "test" },
857
+ });
858
+ await ns.apply({
859
+ apiVersion: "apps/v1",
860
+ kind: "Deployment",
861
+ metadata: { name: "my-app" },
862
+ // ...
863
+ });
864
+ ```
738
865
 
739
- ### Avoiding naming collisions between tests
866
+ **When to use `s.generateName`:**
740
867
 
741
- When tests run in parallel, hard-coded resource names can collide (especially when you create cluster-scoped resources).
868
+ | Situation | Approach |
869
+ | ------------------------------------------------------ | ----------------------------------------- |
870
+ | Namespaced resource inside `newNamespace()` | Use a fixed name (default) |
871
+ | Same-kind resources created multiple times in one test | `s.generateName` or numbered fixed names |
872
+ | Cluster-scoped resource (e.g. `ClusterRole`, `CRD`) | `s.generateName` (no namespace isolation) |
873
+ | Fixed name causes unintended upsert / side effects | `s.generateName` |
742
874
 
743
- Kest offers a few ways to avoid these collisions:
875
+ Choose **necessary and sufficient** over "safe side" -- every random suffix is a readability trade-off.
744
876
 
745
- - Use `s.newNamespace()` to isolate namespaced resources per test (recommended default).
746
- - Use `s.newNamespace({ generateName: "prefix-" })` to keep isolation while making the namespace name easier to recognize in logs/reports.
747
- - Use `s.generateName("prefix-")` to generate a random-suffix name when you need additional names outside of `newNamespace` (e.g. cluster-scoped resources).
877
+ **How the two helpers work:**
748
878
 
749
- `s.newNamespace(...)` actually creates the `Namespace` via `kubectl create` and retries on name collisions (regenerating a new name each attempt), so once it succeeds the namespace name is unique in the cluster. `s.generateName(...)` is a pure string helper and provides **statistical uniqueness** only (collisions are extremely unlikely, but not impossible).
879
+ - `newNamespace(...)` creates a `Namespace` via `kubectl create` and retries on name collisions (regenerating a new name each attempt), so once it succeeds the namespace name is guaranteed unique in the cluster.
880
+ - `s.generateName(...)` is a pure string helper that provides **statistical uniqueness** only (collisions are extremely unlikely, but not impossible).
881
+
882
+ **Using `newNamespace` with `.name` (cross-namespace references):**
883
+
884
+ Every `Namespace` object returned by `newNamespace` has a `.name` property. When resources in one namespace need to reference another namespace by name -- for example in cross-namespace `spec.*Ref.namespace` fields -- use `.name`:
885
+
886
+ ```ts
887
+ const nsA = await s.newNamespace({ generateName: "infra-" });
888
+ const nsB = await s.newNamespace({ generateName: "app-" });
889
+
890
+ s.when("I apply a resource in nsB that references nsA");
891
+ await nsB.apply({
892
+ apiVersion: "example.com/v1",
893
+ kind: "AppConfig",
894
+ metadata: { name: "my-app" },
895
+ spec: {
896
+ secretStoreRef: {
897
+ namespace: nsA.name, // e.g. "infra-k7rtn"
898
+ name: "vault",
899
+ },
900
+ },
901
+ });
902
+ ```
903
+
904
+ **Using `s.generateName` (for cluster-scoped resources):**
905
+
906
+ For cluster-scoped resources (where `newNamespace` is not applicable), use `s.generateName`:
750
907
 
751
908
  ```ts
752
909
  s.given("a cluster-scoped resource name should not collide with other tests");
@@ -766,6 +923,12 @@ await s.create({
766
923
  });
767
924
  ```
768
925
 
926
+ **Naming-collision checklist:**
927
+
928
+ - [ ] Are namespaced resources inside `newNamespace` using fixed names (not `generateName`)?
929
+ - [ ] Is `generateName` reserved for cluster-scoped resources or multi-instance cases?
930
+ - [ ] Can you identify every resource in the failure report without decoding random suffixes?
931
+
769
932
  ## Type Safety
770
933
 
771
934
  Define TypeScript interfaces for your Kubernetes resources to get full type checking in manifests and assertions:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appthrust/kest",
3
- "version": "0.4.1",
3
+ "version": "0.6.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>;
@@ -0,0 +1,93 @@
1
+ import { YAML } from "bun";
2
+ import type { K8sResource, ResourceOneTest } from "../apis";
3
+ import { parseK8sResourceListYaml } from "../k8s-resource";
4
+ import type { QueryDef } from "./types";
5
+
6
+ export const assertOne = {
7
+ type: "query",
8
+ name: "AssertOne",
9
+ query:
10
+ ({ kubectl }) =>
11
+ async <T extends K8sResource>(
12
+ condition: ResourceOneTest<T>
13
+ ): Promise<T> => {
14
+ const yaml = await kubectl.list(toKubectlType(condition));
15
+ const result = parseK8sResourceListYaml(yaml);
16
+ if (!result.ok) {
17
+ throw new Error(
18
+ `Invalid Kubernetes resource list: ${result.violations.join(", ")}`
19
+ );
20
+ }
21
+
22
+ const fetched = result.value;
23
+ for (const item of fetched) {
24
+ assertSameGVK(condition, item);
25
+ }
26
+
27
+ const typed = fetched as Array<T>;
28
+ const found = findExactlyOne(typed, condition);
29
+ await condition.test.call(found, found);
30
+ return found;
31
+ },
32
+ describe: <T extends K8sResource>(condition: ResourceOneTest<T>): string => {
33
+ return `Assert one \`${condition.kind}\` resource`;
34
+ },
35
+ } satisfies QueryDef<ResourceOneTest, K8sResource>;
36
+
37
+ function findExactlyOne<T extends K8sResource>(
38
+ items: Array<T>,
39
+ condition: ResourceOneTest<T>
40
+ ): T {
41
+ const candidates = condition.where ? items.filter(condition.where) : items;
42
+ const hasWhere = condition.where !== undefined;
43
+
44
+ if (candidates.length === 0) {
45
+ throw new Error(
46
+ hasWhere
47
+ ? `No ${condition.kind} resource found matching the "where" predicate`
48
+ : `No ${condition.kind} resource found`
49
+ );
50
+ }
51
+
52
+ if (candidates.length > 1) {
53
+ throw new Error(
54
+ hasWhere
55
+ ? `Expected exactly one ${condition.kind} matching the "where" predicate, but found ${candidates.length}`
56
+ : `Expected exactly one ${condition.kind}, but found ${candidates.length}`
57
+ );
58
+ }
59
+
60
+ return candidates[0] as T;
61
+ }
62
+
63
+ function isSameGVK<T extends K8sResource>(
64
+ finding: Pick<ResourceOneTest<T>, "apiVersion" | "kind">,
65
+ fetched: K8sResource
66
+ ): fetched is T {
67
+ return (
68
+ finding.apiVersion === fetched.apiVersion && finding.kind === fetched.kind
69
+ );
70
+ }
71
+
72
+ function assertSameGVK<T extends K8sResource>(
73
+ finding: Pick<ResourceOneTest<T>, "apiVersion" | "kind">,
74
+ fetched: K8sResource
75
+ ): void {
76
+ if (!isSameGVK(finding, fetched)) {
77
+ throw new Error(
78
+ `Fetched Kubernetes resource: ${YAML.stringify(fetched)} is not expected: ${YAML.stringify(finding)}`
79
+ );
80
+ }
81
+ }
82
+
83
+ function toKubectlType<T extends K8sResource>(
84
+ condition: Pick<ResourceOneTest<T>, "apiVersion" | "kind">
85
+ ): string {
86
+ const { kind, apiVersion } = condition;
87
+ const [group, version] = apiVersion.split("/");
88
+ if (version === undefined) {
89
+ // core group cannot include version in the type
90
+ return kind;
91
+ }
92
+ return [kind, version, group].filter(Boolean).join(".");
93
+ }
@@ -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
  *
@@ -296,6 +365,52 @@ export interface Scenario {
296
365
  options?: undefined | ActionOptions
297
366
  ): Promise<Array<T>>;
298
367
 
368
+ /**
369
+ * Fetches a list of Kubernetes resources, asserts that exactly one matches
370
+ * the optional `where` predicate, and runs a test function on it.
371
+ *
372
+ * When `where` is omitted, all resources of the given kind are candidates;
373
+ * the method then asserts that exactly one resource of that kind exists.
374
+ *
375
+ * The `test` callback is invoked with `this` bound to the matching resource.
376
+ * If the callback throws (or rejects), the assertion fails and the whole
377
+ * action is retried until it succeeds or times out.
378
+ *
379
+ * @template T - The expected Kubernetes resource shape.
380
+ * @param resource - Group/version/kind selector, optional `where` predicate,
381
+ * and test callback.
382
+ * @param options - Retry options such as timeout and polling interval.
383
+ *
384
+ * @example
385
+ * ```ts
386
+ * // Assert exactly one ConfigMap exists and check its data
387
+ * await s.assertOne({
388
+ * apiVersion: "v1",
389
+ * kind: "ConfigMap",
390
+ * test() {
391
+ * expect(this.data?.mode).toBe("demo");
392
+ * },
393
+ * });
394
+ * ```
395
+ *
396
+ * @example
397
+ * ```ts
398
+ * // Find the one ConfigMap whose name starts with "generated-"
399
+ * await s.assertOne({
400
+ * apiVersion: "v1",
401
+ * kind: "ConfigMap",
402
+ * where: (cm) => cm.metadata.name.startsWith("generated-"),
403
+ * test() {
404
+ * expect(this.data?.mode).toBe("auto");
405
+ * },
406
+ * });
407
+ * ```
408
+ */
409
+ assertOne<T extends K8sResource>(
410
+ resource: ResourceOneTest<T>,
411
+ options?: undefined | ActionOptions
412
+ ): Promise<T>;
413
+
299
414
  /**
300
415
  * Creates a new namespace and returns a namespaced API surface.
301
416
  *
@@ -527,6 +642,30 @@ export interface Cluster {
527
642
  options?: undefined | ActionOptions
528
643
  ): Promise<void>;
529
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
+
530
669
  /**
531
670
  * Applies the `status` subresource using server-side apply.
532
671
  *
@@ -686,6 +825,32 @@ export interface Cluster {
686
825
  options?: undefined | ActionOptions
687
826
  ): Promise<Array<T>>;
688
827
 
828
+ /**
829
+ * Fetches a list of Kubernetes resources, asserts that exactly one matches
830
+ * the optional `where` predicate, and runs a test function on it.
831
+ *
832
+ * @template T - The expected Kubernetes resource shape.
833
+ * @param resource - Group/version/kind selector, optional `where` predicate,
834
+ * and test callback.
835
+ * @param options - Retry options such as timeout and polling interval.
836
+ *
837
+ * @example
838
+ * ```ts
839
+ * await cluster.assertOne({
840
+ * apiVersion: "v1",
841
+ * kind: "Namespace",
842
+ * where: (ns) => ns.metadata.name === "my-namespace",
843
+ * test() {
844
+ * expect(this.metadata.labels?.env).toBe("production");
845
+ * },
846
+ * });
847
+ * ```
848
+ */
849
+ assertOne<T extends K8sResource>(
850
+ resource: ResourceOneTest<T>,
851
+ options?: undefined | ActionOptions
852
+ ): Promise<T>;
853
+
689
854
  /**
690
855
  * Creates a new namespace in this cluster and returns a namespaced API.
691
856
  *
@@ -799,6 +964,30 @@ export interface Namespace {
799
964
  options?: undefined | ActionOptions
800
965
  ): Promise<void>;
801
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
+
802
991
  /**
803
992
  * Applies the `status` subresource in this namespace using server-side apply.
804
993
  *
@@ -963,6 +1152,32 @@ export interface Namespace {
963
1152
  resource: ResourceListTest<T>,
964
1153
  options?: undefined | ActionOptions
965
1154
  ): Promise<Array<T>>;
1155
+
1156
+ /**
1157
+ * Fetches a list of namespaced Kubernetes resources, asserts that exactly one
1158
+ * matches the optional `where` predicate, and runs a test function on it.
1159
+ *
1160
+ * @template T - The expected Kubernetes resource shape.
1161
+ * @param resource - Group/version/kind selector, optional `where` predicate,
1162
+ * and test callback.
1163
+ * @param options - Retry options such as timeout and polling interval.
1164
+ *
1165
+ * @example
1166
+ * ```ts
1167
+ * await ns.assertOne({
1168
+ * apiVersion: "v1",
1169
+ * kind: "ConfigMap",
1170
+ * where: (cm) => cm.metadata.name.startsWith("generated-"),
1171
+ * test() {
1172
+ * expect(this.data?.mode).toBe("auto");
1173
+ * },
1174
+ * });
1175
+ * ```
1176
+ */
1177
+ assertOne<T extends K8sResource>(
1178
+ resource: ResourceOneTest<T>,
1179
+ options?: undefined | ActionOptions
1180
+ ): Promise<T>;
966
1181
  }
967
1182
 
968
1183
  /**
@@ -1146,6 +1361,91 @@ export interface ResourceListTest<T extends K8sResource = K8sResource> {
1146
1361
  ) => unknown | Promise<unknown>;
1147
1362
  }
1148
1363
 
1364
+ /**
1365
+ * A test definition for {@link Scenario.assertOne}.
1366
+ *
1367
+ * Fetches a list of resources, filters by an optional `where` predicate, asserts
1368
+ * that exactly one resource matches, then runs the `test` callback on it.
1369
+ */
1370
+ export interface ResourceOneTest<T extends K8sResource = K8sResource> {
1371
+ /**
1372
+ * Kubernetes API version (e.g. `"v1"`, `"apps/v1"`).
1373
+ */
1374
+ readonly apiVersion: T["apiVersion"];
1375
+
1376
+ /**
1377
+ * Kubernetes kind (e.g. `"ConfigMap"`, `"Deployment"`).
1378
+ */
1379
+ readonly kind: T["kind"];
1380
+
1381
+ /**
1382
+ * Optional predicate to narrow which resources are candidates.
1383
+ *
1384
+ * When omitted, all resources of the given kind are candidates.
1385
+ * Combined with the strict uniqueness check this means "assert there is
1386
+ * exactly one resource of this kind."
1387
+ */
1388
+ readonly where?: undefined | ((resource: T) => boolean);
1389
+
1390
+ /**
1391
+ * Assertion callback.
1392
+ *
1393
+ * The callback is invoked with `this` bound to the single matching resource.
1394
+ * Throwing (or rejecting) signals a failed assertion.
1395
+ */
1396
+ readonly test: (this: T, resource: T) => unknown | Promise<unknown>;
1397
+ }
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
+
1149
1449
  /**
1150
1450
  * Kubernetes cluster selector for {@link Scenario.useCluster}.
1151
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
-
@@ -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
 
@@ -114,7 +114,7 @@ function handleActionStart(
114
114
  return;
115
115
  }
116
116
 
117
- const action: Action = { name: event.data.description };
117
+ const action: Action = { name: event.data.description, commands: [] };
118
118
  scenario.overview.push({
119
119
  name: event.data.description,
120
120
  status: "pending",
@@ -180,17 +180,21 @@ function handleCommandResult(
180
180
  }
181
181
 
182
182
  const { currentAction } = state;
183
- if (!currentAction?.command) {
183
+ if (!currentAction || currentAction.commands.length === 0) {
184
184
  return;
185
185
  }
186
186
 
187
- currentAction.command.stdout = {
187
+ const command = currentAction.commands.at(-1);
188
+ if (!command) {
189
+ return;
190
+ }
191
+ command.stdout = {
188
192
  text: event.data.stdout,
189
193
  ...(event.data.stdoutLanguage
190
194
  ? { language: event.data.stdoutLanguage }
191
195
  : {}),
192
196
  };
193
- currentAction.command.stderr = {
197
+ command.stderr = {
194
198
  text: event.data.stderr,
195
199
  ...(event.data.stderrLanguage
196
200
  ? { language: event.data.stderrLanguage }
@@ -216,7 +220,7 @@ function handleCommandRun(
216
220
  if (!state.currentAction) {
217
221
  return;
218
222
  }
219
- state.currentAction.command = createCommandFromRun(event);
223
+ state.currentAction.commands.push(createCommandFromRun(event));
220
224
  }
221
225
 
222
226
  function handleScenarioStart(
@@ -165,7 +165,7 @@ export function renderReport(
165
165
  if (!status) {
166
166
  if (action.error) {
167
167
  status = "failure";
168
- } else if (action.command) {
168
+ } else if (action.commands.length > 0) {
169
169
  status = "success";
170
170
  } else {
171
171
  status = "pending";
@@ -180,8 +180,7 @@ export function renderReport(
180
180
  lines.push(`**${emoji} ${stripAnsi(action.name)}**${attemptsSuffix}`);
181
181
  lines.push("");
182
182
 
183
- const cmd = action.command;
184
- if (cmd) {
183
+ for (const cmd of action.commands) {
185
184
  const base = [cmd.cmd, ...cmd.args].join(" ").trim();
186
185
  const stdin = cmd.stdin?.text;
187
186
  const stdinLanguage = cmd.stdin?.language ?? "text";
@@ -2,7 +2,10 @@ 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";
8
+ import { assertOne } from "../actions/assert-one";
6
9
  import { create } from "../actions/create";
7
10
  import {
8
11
  type CreateNamespaceInput,
@@ -38,6 +41,8 @@ export function createScenario(deps: CreateScenarioOptions): InternalScenario {
38
41
  return {
39
42
  apply: createMutateFn(deps, apply),
40
43
  create: createMutateFn(deps, create),
44
+ assertApplyError: createMutateFn(deps, assertApplyError),
45
+ assertCreateError: createMutateFn(deps, assertCreateError),
41
46
  applyStatus: createOneWayMutateFn(deps, applyStatus),
42
47
  delete: createOneWayMutateFn(deps, deleteResource),
43
48
  label: createOneWayMutateFn(deps, label),
@@ -46,6 +51,7 @@ export function createScenario(deps: CreateScenarioOptions): InternalScenario {
46
51
  assert: createQueryFn(deps, assert),
47
52
  assertAbsence: createQueryFn(deps, assertAbsence),
48
53
  assertList: createQueryFn(deps, assertList),
54
+ assertOne: createQueryFn(deps, assertOne),
49
55
  given: bdd.given(deps),
50
56
  when: bdd.when(deps),
51
57
  // biome-ignore lint/suspicious/noThenProperty: BDD DSL uses `then()` method name
@@ -203,6 +209,8 @@ const createNewNamespaceFn =
203
209
  name: namespaceName,
204
210
  apply: createMutateFn(namespacedDeps, apply),
205
211
  create: createMutateFn(namespacedDeps, create),
212
+ assertApplyError: createMutateFn(namespacedDeps, assertApplyError),
213
+ assertCreateError: createMutateFn(namespacedDeps, assertCreateError),
206
214
  applyStatus: createOneWayMutateFn(namespacedDeps, applyStatus),
207
215
  delete: createOneWayMutateFn(namespacedDeps, deleteResource),
208
216
  label: createOneWayMutateFn(namespacedDeps, label),
@@ -210,6 +218,7 @@ const createNewNamespaceFn =
210
218
  assert: createQueryFn(namespacedDeps, assert),
211
219
  assertAbsence: createQueryFn(namespacedDeps, assertAbsence),
212
220
  assertList: createQueryFn(namespacedDeps, assertList),
221
+ assertOne: createQueryFn(namespacedDeps, assertOne),
213
222
  };
214
223
  };
215
224
 
@@ -226,6 +235,8 @@ const createUseClusterFn =
226
235
  return {
227
236
  apply: createMutateFn(clusterDeps, apply),
228
237
  create: createMutateFn(clusterDeps, create),
238
+ assertApplyError: createMutateFn(clusterDeps, assertApplyError),
239
+ assertCreateError: createMutateFn(clusterDeps, assertCreateError),
229
240
  applyStatus: createOneWayMutateFn(clusterDeps, applyStatus),
230
241
  delete: createOneWayMutateFn(clusterDeps, deleteResource),
231
242
  label: createOneWayMutateFn(clusterDeps, label),
@@ -233,6 +244,7 @@ const createUseClusterFn =
233
244
  assert: createQueryFn(clusterDeps, assert),
234
245
  assertAbsence: createQueryFn(clusterDeps, assertAbsence),
235
246
  assertList: createQueryFn(clusterDeps, assertList),
247
+ assertOne: createQueryFn(clusterDeps, assertOne),
236
248
  newNamespace: createNewNamespaceFn(clusterDeps),
237
249
  };
238
250
  };