@appthrust/kest 0.4.0 → 0.5.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.
@@ -219,6 +226,37 @@ await ns.assertList<ConfigMap>({
219
226
  });
220
227
  ```
221
228
 
229
+ ### Single-Resource List Assertions
230
+
231
+ Assert that exactly one resource of a kind exists (or matches a predicate) and test it:
232
+
233
+ ```ts
234
+ // Assert exactly one ConfigMap exists and check its data
235
+ await ns.assertOne<ConfigMap>({
236
+ apiVersion: "v1",
237
+ kind: "ConfigMap",
238
+ test() {
239
+ expect(this.data?.mode).toBe("demo");
240
+ },
241
+ });
242
+ ```
243
+
244
+ Use the optional `where` predicate to narrow candidates when multiple resources exist:
245
+
246
+ ```ts
247
+ // Find the one ConfigMap whose name starts with "generated-"
248
+ await ns.assertOne<ConfigMap>({
249
+ apiVersion: "v1",
250
+ kind: "ConfigMap",
251
+ where: (cm) => cm.metadata.name.startsWith("generated-"),
252
+ test() {
253
+ expect(this.data?.mode).toBe("auto");
254
+ },
255
+ }, { timeout: "30s", interval: "1s" });
256
+ ```
257
+
258
+ `assertOne` throws if zero or more than one resource matches, and retries until exactly one is found or the timeout expires.
259
+
222
260
  ### Absence Assertions
223
261
 
224
262
  Assert that a resource does not exist (e.g. after deletion or to verify a controller hasn't created something):
@@ -439,6 +477,7 @@ The top-level API surface available in every test callback.
439
477
  | `assert(resource, options?)` | Fetch a resource and run assertions with retries |
440
478
  | `assertAbsence(resource, options?)` | Assert that a resource does not exist |
441
479
  | `assertList(resource, options?)` | Fetch a list of resources and run assertions |
480
+ | `assertOne(resource, options?)` | Assert exactly one resource matches, then run assertions |
442
481
  | `newNamespace(name?, options?)` | Create an ephemeral namespace (supports `{ generateName }`) |
443
482
  | `generateName(prefix)` | Generate a random-suffix name (statistical uniqueness) |
444
483
  | `exec(input, options?)` | Execute shell commands with optional revert |
@@ -447,7 +486,13 @@ The top-level API surface available in every test callback.
447
486
 
448
487
  ### Namespace / Cluster
449
488
 
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`.
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`.
490
+
491
+ `Namespace` also exposes a `name` property:
492
+
493
+ | Property | Type | Description |
494
+ | -------- | -------- | -------------------------------------------------- |
495
+ | `name` | `string` | The generated namespace name (e.g. `"kest-abc12"`) |
451
496
 
452
497
  ### Action Options
453
498
 
@@ -462,17 +507,366 @@ Duration strings support units like `"200ms"`, `"5s"`, `"1m"`.
462
507
 
463
508
  ## Best Practices
464
509
 
510
+ ### Test API contracts, not controllers
511
+
512
+ E2E tests should describe **how an API behaves from the user's perspective**, not how a specific controller implements that behavior internally. The subject of every test should be the API resource, not the controller behind it.
513
+
514
+ Why? Controllers are an implementation detail. They get renamed, split, merged, or rewritten -- but as long as the API contract is unchanged, users are unaffected. If your tests are written in terms of controllers, a harmless refactor can break your entire test suite.
515
+
516
+ ```ts
517
+ // ✅ Good — the subject is the API resource
518
+ test("Tenant API creates namespaces for each tenant", async (s) => {
519
+ s.given("a Tenant resource is applied");
520
+ const ns = await s.newNamespace();
521
+ await ns.apply({
522
+ apiVersion: "example.com/v1",
523
+ kind: "Tenant",
524
+ metadata: { name: "acme" },
525
+ spec: { namespaces: ["dev", "staging"] },
526
+ });
527
+
528
+ s.then("the Tenant reports Ready=True");
529
+ await ns.assert({
530
+ apiVersion: "example.com/v1",
531
+ kind: "Tenant",
532
+ name: "acme",
533
+ test() {
534
+ expect(this.status?.conditions).toContainEqual(
535
+ expect.objectContaining({ type: "Ready", status: "True" }),
536
+ );
537
+ },
538
+ });
539
+ });
540
+ ```
541
+
542
+ ```ts
543
+ // ❌ Bad — the subject is the controller (implementation detail)
544
+ test("tenant-controller creates namespaces", async (s) => {
545
+ s.given("tenant-controller is running");
546
+ // ...
547
+ s.then("tenant-controller creates child namespaces");
548
+ // ...
549
+ });
550
+ ```
551
+
552
+ The same principle applies to BDD annotations -- keep `s.given()`, `s.when()`, and `s.then()` free of controller names:
553
+
554
+ | ❌ Controller-centric | ✅ API-centric |
555
+ | ------------------------------------------------- | ----------------------------------------- |
556
+ | `s.given("tenant-controller is running")` | `s.given("a Tenant resource exists")` |
557
+ | `s.when("tenant-controller reconciles")` | `s.when("the Tenant spec is updated")` |
558
+ | `s.then("tenant-controller creates a Namespace")` | `s.then("the expected Namespace exists")` |
559
+
560
+ ### Choosing what to test in E2E
561
+
562
+ E2E tests are powerful for validating **user-observable behavior** but expensive for verifying internal details. Placing implementation details in E2E tests makes refactoring harder without giving users any extra confidence.
563
+
564
+ **Good candidates for E2E (API contract):**
565
+
566
+ - Status transitions -- e.g. a resource reaches `Ready=True` after creation
567
+ - Error feedback -- e.g. invalid input produces an explanatory condition like `Ready=False, reason=InvalidSpec`
568
+ - User-facing side effects -- e.g. resources that users are expected to observe or interact with
569
+
570
+ ```ts
571
+ // ✅ Assert a user-observable status condition
572
+ await ns.assert({
573
+ apiVersion: "example.com/v1",
574
+ kind: "Database",
575
+ name: "my-db",
576
+ test() {
577
+ expect(this.status?.conditions).toContainEqual(
578
+ expect.objectContaining({ type: "Ready", status: "True" }),
579
+ );
580
+ },
581
+ });
582
+ ```
583
+
584
+ **Better left to unit / integration tests (implementation details):**
585
+
586
+ - Internal label keys, annotation formats, or hash values
587
+ - Intermediate resources that users don't directly interact with
588
+ - Controller-internal reconciliation logic and branching
589
+
590
+ ```ts
591
+ // ❌ Avoid — internal label format is an implementation detail
592
+ await ns.assert({
593
+ apiVersion: "example.com/v1",
594
+ kind: "Database",
595
+ name: "my-db",
596
+ test() {
597
+ // This label may change without affecting users
598
+ expect(this.metadata?.labels?.["internal.example.com/config-hash"]).toBe("a1b2c3");
599
+ },
600
+ });
601
+ ```
602
+
603
+ When you find yourself wanting to E2E-test an intermediate resource, ask: _"Is this part of the public API contract?"_ If yes, document it as such and test it. If no, push the assertion down to a cheaper test layer and keep E2E focused on what users actually see.
604
+
605
+ ### Organizing test files
606
+
607
+ Structure test directories around **API resources**, not controllers. This makes the test suite resilient to internal refactoring and immediately tells readers _which API behavior_ is being verified.
608
+
609
+ ```
610
+ # ✅ Good — organized by API resource
611
+ tests/e2e/
612
+ ├── tenant-api/
613
+ │ ├── creation.test.ts
614
+ │ └── deletion.test.ts
615
+ ├── database-api/
616
+ │ └── provisioning.test.ts
617
+
618
+ # ❌ Bad — organized by controller (implementation detail)
619
+ tests/e2e/
620
+ ├── tenant-controller/
621
+ │ ├── creation.test.ts
622
+ │ └── deletion.test.ts
623
+ ├── database-controller/
624
+ │ └── provisioning.test.ts
625
+ ```
626
+
627
+ **Refactoring-friendliness checklist** -- the more "yes" answers, the better your E2E tests:
628
+
629
+ - [ ] Is the subject of every test an API resource (not a controller)?
630
+ - [ ] Can a reader understand the test from the manifest and assertions alone?
631
+ - [ ] Do `then` assertions only check user-observable state (`status`, contracted outputs)?
632
+ - [ ] Would splitting, merging, or renaming controllers leave all tests passing?
633
+
634
+ ### One scenario per file
635
+
636
+ Put exactly one test scenario in one file -- from the start, not "when it gets big enough."
637
+
638
+ It may be tempting to group several short scenarios into one file while they are small. But E2E tests grow: assertions get richer, edge-case inputs appear, debug aids are added. Splitting later is far harder than starting separate, and deciding _when_ to split is an unnecessary judgment call. The simplest policy is the best one: one scenario, one file, always.
639
+
640
+ ```
641
+ tests/e2e/tenant-api/
642
+ ├── creates-namespaces-for-each-tenant.test.ts
643
+ ├── rejects-duplicate-tenant-names.test.ts
644
+ ├── updates-status-on-namespace-failure.test.ts
645
+ └── deletes-child-namespaces-on-removal.test.ts
646
+ ```
647
+
648
+ **Why not "split when it gets big"?**
649
+
650
+ - Tests grow incrementally -- no single commit feels like "the moment to split," so it never happens.
651
+ - Splitting a file retroactively means rewriting imports, moving fixtures, and touching unrelated tests in the same PR.
652
+ - The threshold itself becomes a debate ("Is 250 lines too many? 400?"). A universal rule eliminates the discussion.
653
+
654
+ Starting with one file per scenario reserves room to grow. Each file has built-in headroom for richer assertions, additional setup steps, and debug annotations -- without ever crowding a neighbor.
655
+
656
+ **What you get:**
657
+
658
+ - **Self-contained reading** -- open one file, see the full Given/When/Then without scrolling past unrelated scenarios.
659
+ - **Surgical diffs** -- a change touches exactly one scenario, keeping PRs small and reviews focused.
660
+ - **Failure as an address** -- a failing file name tells you which API contract broke, before you read a single line of output.
661
+ - **Conflict-free collaboration** -- teammates edit different files, not different sections of the same file.
662
+
663
+ **Name files after the behavior they verify.** A reader should know what a test checks without opening the file:
664
+
665
+ | ✅ Good | ❌ Bad |
666
+ | ------------------------------------------ | -------------------------- |
667
+ | `creates-namespaces-with-labels.test.ts` | `tenant-test-1.test.ts` |
668
+ | `rejects-reserved-selector-labels.test.ts` | `validation.test.ts` |
669
+ | `rolls-out-when-image-changes.test.ts` | `deployment-tests.test.ts` |
670
+
671
+ **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.
672
+
673
+ **One-scenario-per-file checklist:**
674
+
675
+ - [ ] Does each test file contain exactly one scenario?
676
+ - [ ] Does the file name describe the behavior under test (not the controller)?
677
+ - [ ] If a file has multiple scenarios, is there a comment justifying the exception?
678
+
679
+ ### Keep manifests visible in your tests
680
+
681
+ E2E tests double as living documentation. Every test should read as a self-contained specification: what was applied (Given), what changed (When), and what should be true (Then). When a helper function assembles the entire manifest behind the scenes, the test body may _look_ cleaner -- but it stops serving as documentation because the reader can no longer see the actual input.
682
+
683
+ ```ts
684
+ // ❌ Bad — looks tidy, but the actual manifest is hidden inside makeDeployment().
685
+ // A reader cannot tell what is being applied without jumping to the helper.
686
+ // The diff between v1 and v2 is buried in a parameter change.
687
+ function makeDeployment(name: string, image: string) {
688
+ return {
689
+ apiVersion: "apps/v1",
690
+ kind: "Deployment",
691
+ metadata: { name, labels: { app: name } },
692
+ spec: {
693
+ replicas: 2,
694
+ selector: { matchLabels: { app: name } },
695
+ template: {
696
+ metadata: { labels: { app: name } },
697
+ spec: { containers: [{ name: "app", image }] },
698
+ },
699
+ },
700
+ };
701
+ }
702
+
703
+ test("rollout updates the image", async (s) => {
704
+ const ns = await s.newNamespace();
705
+ await ns.apply(makeDeployment("my-app", "my-app:v1"));
706
+ await ns.apply(makeDeployment("my-app", "my-app:v2"));
707
+ // What exactly changed? You have to read makeDeployment to find out.
708
+ });
709
+ ```
710
+
711
+ ```ts
712
+ // ✅ Good — the full manifest is right here; the diff between steps is obvious.
713
+ test("rollout updates the image", async (s) => {
714
+ const ns = await s.newNamespace();
715
+
716
+ s.when("I apply a Deployment at v1");
717
+ await ns.apply({
718
+ apiVersion: "apps/v1",
719
+ kind: "Deployment",
720
+ metadata: { name: "my-app" },
721
+ spec: {
722
+ replicas: 2,
723
+ selector: { matchLabels: { app: "my-app" } },
724
+ template: {
725
+ metadata: { labels: { app: "my-app" } },
726
+ spec: { containers: [{ name: "app", image: "my-app:v1" }] },
727
+ },
728
+ },
729
+ });
730
+
731
+ s.when("I update to v2");
732
+ await ns.apply({
733
+ apiVersion: "apps/v1",
734
+ kind: "Deployment",
735
+ metadata: { name: "my-app" },
736
+ spec: {
737
+ replicas: 2,
738
+ selector: { matchLabels: { app: "my-app" } },
739
+ template: {
740
+ metadata: { labels: { app: "my-app" } },
741
+ spec: { containers: [{ name: "app", image: "my-app:v2" }] },
742
+ // ^^^^^^^^^ the diff
743
+ },
744
+ },
745
+ });
746
+ });
747
+ ```
748
+
749
+ The duplication is intentional -- in E2E tests, **readability beats DRY**. Duplicated manifests make each test self-explanatory and keep failure reports easy to match against the test source. Notice the `^^^ the diff` comment in the example above -- adding a short inline comment to highlight the key change is a simple technique that makes the intent even clearer.
750
+
751
+ When manifests become too large to inline comfortably, extract them into **static fixture files** instead of helper functions. Both YAML and TypeScript files work:
752
+
753
+ ```ts
754
+ // ✅ Good — static fixture files keep the input visible at a glance
755
+ await ns.apply(import("./fixtures/deployment-v1.yaml"));
756
+ await ns.apply(import("./fixtures/deployment-v2.yaml"));
757
+
758
+ // TypeScript files work too — useful when converting from inline objects
759
+ await ns.apply(import("./fixtures/deployment-v1.ts"));
760
+ ```
761
+
762
+ **Manifest-visibility checklist:**
763
+
764
+ - [ ] Can you understand the full input by reading just the test file?
765
+ - [ ] Is the diff between test steps visible as a spec-level change?
766
+ - [ ] Can you reconstruct the applied manifest from the failure report alone?
767
+
465
768
  ### Avoiding naming collisions between tests
466
769
 
467
- When tests run in parallel, hard-coded resource names can collide (especially when you create cluster-scoped resources).
770
+ 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:
468
771
 
469
- Kest offers a few ways to avoid these collisions:
772
+ ```ts
773
+ const ns = await s.newNamespace();
470
774
 
471
- - Use `s.newNamespace()` to isolate namespaced resources per test (recommended default).
472
- - Use `s.newNamespace({ generateName: "prefix-" })` to keep isolation while making the namespace name easier to recognize in logs/reports.
473
- - Use `s.generateName("prefix-")` to generate a random-suffix name when you need additional names outside of `newNamespace` (e.g. cluster-scoped resources).
775
+ await ns.apply({
776
+ apiVersion: "v1",
777
+ kind: "ConfigMap",
778
+ metadata: { name: "app-config" }, // safe — no other test shares this namespace
779
+ data: { mode: "test" },
780
+ });
781
+ ```
782
+
783
+ 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.**
474
784
 
475
- `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).
785
+ 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:
786
+
787
+ ```ts
788
+ // ❌ Bad — generateName on namespaced resources inside an isolated namespace.
789
+ // The random suffixes add noise without preventing any actual collisions.
790
+ const ns = await s.newNamespace();
791
+
792
+ const configName = s.generateName("cfg-");
793
+ const deployName = s.generateName("deploy-");
794
+
795
+ await ns.apply({
796
+ apiVersion: "v1",
797
+ kind: "ConfigMap",
798
+ metadata: { name: configName }, // "cfg-x7k2m" — hard to find in logs
799
+ data: { mode: "test" },
800
+ });
801
+ await ns.apply({
802
+ apiVersion: "apps/v1",
803
+ kind: "Deployment",
804
+ metadata: { name: deployName }, // "deploy-p3n8r" — what was this test about?
805
+ // ...
806
+ });
807
+ ```
808
+
809
+ ```ts
810
+ // ✅ Good — fixed, descriptive names inside an isolated namespace.
811
+ // The namespace already guarantees no collisions. Fixed names are
812
+ // easy to read, easy to grep in logs, and match the failure report.
813
+ const ns = await s.newNamespace();
814
+
815
+ await ns.apply({
816
+ apiVersion: "v1",
817
+ kind: "ConfigMap",
818
+ metadata: { name: "app-config" },
819
+ data: { mode: "test" },
820
+ });
821
+ await ns.apply({
822
+ apiVersion: "apps/v1",
823
+ kind: "Deployment",
824
+ metadata: { name: "my-app" },
825
+ // ...
826
+ });
827
+ ```
828
+
829
+ **When to use `s.generateName`:**
830
+
831
+ | Situation | Approach |
832
+ | ------------------------------------------------------ | ----------------------------------------- |
833
+ | Namespaced resource inside `newNamespace()` | Use a fixed name (default) |
834
+ | Same-kind resources created multiple times in one test | `s.generateName` or numbered fixed names |
835
+ | Cluster-scoped resource (e.g. `ClusterRole`, `CRD`) | `s.generateName` (no namespace isolation) |
836
+ | Fixed name causes unintended upsert / side effects | `s.generateName` |
837
+
838
+ Choose **necessary and sufficient** over "safe side" -- every random suffix is a readability trade-off.
839
+
840
+ **How the two helpers work:**
841
+
842
+ - `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.
843
+ - `s.generateName(...)` is a pure string helper that provides **statistical uniqueness** only (collisions are extremely unlikely, but not impossible).
844
+
845
+ **Using `newNamespace` with `.name` (cross-namespace references):**
846
+
847
+ 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`:
848
+
849
+ ```ts
850
+ const nsA = await s.newNamespace({ generateName: "infra-" });
851
+ const nsB = await s.newNamespace({ generateName: "app-" });
852
+
853
+ s.when("I apply a resource in nsB that references nsA");
854
+ await nsB.apply({
855
+ apiVersion: "example.com/v1",
856
+ kind: "AppConfig",
857
+ metadata: { name: "my-app" },
858
+ spec: {
859
+ secretStoreRef: {
860
+ namespace: nsA.name, // e.g. "infra-k7rtn"
861
+ name: "vault",
862
+ },
863
+ },
864
+ });
865
+ ```
866
+
867
+ **Using `s.generateName` (for cluster-scoped resources):**
868
+
869
+ For cluster-scoped resources (where `newNamespace` is not applicable), use `s.generateName`:
476
870
 
477
871
  ```ts
478
872
  s.given("a cluster-scoped resource name should not collide with other tests");
@@ -492,6 +886,12 @@ await s.create({
492
886
  });
493
887
  ```
494
888
 
889
+ **Naming-collision checklist:**
890
+
891
+ - [ ] Are namespaced resources inside `newNamespace` using fixed names (not `generateName`)?
892
+ - [ ] Is `generateName` reserved for cluster-scoped resources or multi-instance cases?
893
+ - [ ] Can you identify every resource in the failure report without decoding random suffixes?
894
+
495
895
  ## Type Safety
496
896
 
497
897
  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.0",
3
+ "version": "0.5.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,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
+ }
package/ts/apis/index.ts CHANGED
@@ -296,6 +296,52 @@ export interface Scenario {
296
296
  options?: undefined | ActionOptions
297
297
  ): Promise<Array<T>>;
298
298
 
299
+ /**
300
+ * Fetches a list of Kubernetes resources, asserts that exactly one matches
301
+ * the optional `where` predicate, and runs a test function on it.
302
+ *
303
+ * When `where` is omitted, all resources of the given kind are candidates;
304
+ * the method then asserts that exactly one resource of that kind exists.
305
+ *
306
+ * The `test` callback is invoked with `this` bound to the matching resource.
307
+ * If the callback throws (or rejects), the assertion fails and the whole
308
+ * action is retried until it succeeds or times out.
309
+ *
310
+ * @template T - The expected Kubernetes resource shape.
311
+ * @param resource - Group/version/kind selector, optional `where` predicate,
312
+ * and test callback.
313
+ * @param options - Retry options such as timeout and polling interval.
314
+ *
315
+ * @example
316
+ * ```ts
317
+ * // Assert exactly one ConfigMap exists and check its data
318
+ * await s.assertOne({
319
+ * apiVersion: "v1",
320
+ * kind: "ConfigMap",
321
+ * test() {
322
+ * expect(this.data?.mode).toBe("demo");
323
+ * },
324
+ * });
325
+ * ```
326
+ *
327
+ * @example
328
+ * ```ts
329
+ * // Find the one ConfigMap whose name starts with "generated-"
330
+ * await s.assertOne({
331
+ * apiVersion: "v1",
332
+ * kind: "ConfigMap",
333
+ * where: (cm) => cm.metadata.name.startsWith("generated-"),
334
+ * test() {
335
+ * expect(this.data?.mode).toBe("auto");
336
+ * },
337
+ * });
338
+ * ```
339
+ */
340
+ assertOne<T extends K8sResource>(
341
+ resource: ResourceOneTest<T>,
342
+ options?: undefined | ActionOptions
343
+ ): Promise<T>;
344
+
299
345
  /**
300
346
  * Creates a new namespace and returns a namespaced API surface.
301
347
  *
@@ -686,6 +732,32 @@ export interface Cluster {
686
732
  options?: undefined | ActionOptions
687
733
  ): Promise<Array<T>>;
688
734
 
735
+ /**
736
+ * Fetches a list of Kubernetes resources, asserts that exactly one matches
737
+ * the optional `where` predicate, and runs a test function on it.
738
+ *
739
+ * @template T - The expected Kubernetes resource shape.
740
+ * @param resource - Group/version/kind selector, optional `where` predicate,
741
+ * and test callback.
742
+ * @param options - Retry options such as timeout and polling interval.
743
+ *
744
+ * @example
745
+ * ```ts
746
+ * await cluster.assertOne({
747
+ * apiVersion: "v1",
748
+ * kind: "Namespace",
749
+ * where: (ns) => ns.metadata.name === "my-namespace",
750
+ * test() {
751
+ * expect(this.metadata.labels?.env).toBe("production");
752
+ * },
753
+ * });
754
+ * ```
755
+ */
756
+ assertOne<T extends K8sResource>(
757
+ resource: ResourceOneTest<T>,
758
+ options?: undefined | ActionOptions
759
+ ): Promise<T>;
760
+
689
761
  /**
690
762
  * Creates a new namespace in this cluster and returns a namespaced API.
691
763
  *
@@ -963,6 +1035,32 @@ export interface Namespace {
963
1035
  resource: ResourceListTest<T>,
964
1036
  options?: undefined | ActionOptions
965
1037
  ): Promise<Array<T>>;
1038
+
1039
+ /**
1040
+ * Fetches a list of namespaced Kubernetes resources, asserts that exactly one
1041
+ * matches the optional `where` predicate, and runs a test function on it.
1042
+ *
1043
+ * @template T - The expected Kubernetes resource shape.
1044
+ * @param resource - Group/version/kind selector, optional `where` predicate,
1045
+ * and test callback.
1046
+ * @param options - Retry options such as timeout and polling interval.
1047
+ *
1048
+ * @example
1049
+ * ```ts
1050
+ * await ns.assertOne({
1051
+ * apiVersion: "v1",
1052
+ * kind: "ConfigMap",
1053
+ * where: (cm) => cm.metadata.name.startsWith("generated-"),
1054
+ * test() {
1055
+ * expect(this.data?.mode).toBe("auto");
1056
+ * },
1057
+ * });
1058
+ * ```
1059
+ */
1060
+ assertOne<T extends K8sResource>(
1061
+ resource: ResourceOneTest<T>,
1062
+ options?: undefined | ActionOptions
1063
+ ): Promise<T>;
966
1064
  }
967
1065
 
968
1066
  /**
@@ -1146,6 +1244,41 @@ export interface ResourceListTest<T extends K8sResource = K8sResource> {
1146
1244
  ) => unknown | Promise<unknown>;
1147
1245
  }
1148
1246
 
1247
+ /**
1248
+ * A test definition for {@link Scenario.assertOne}.
1249
+ *
1250
+ * Fetches a list of resources, filters by an optional `where` predicate, asserts
1251
+ * that exactly one resource matches, then runs the `test` callback on it.
1252
+ */
1253
+ export interface ResourceOneTest<T extends K8sResource = K8sResource> {
1254
+ /**
1255
+ * Kubernetes API version (e.g. `"v1"`, `"apps/v1"`).
1256
+ */
1257
+ readonly apiVersion: T["apiVersion"];
1258
+
1259
+ /**
1260
+ * Kubernetes kind (e.g. `"ConfigMap"`, `"Deployment"`).
1261
+ */
1262
+ readonly kind: T["kind"];
1263
+
1264
+ /**
1265
+ * Optional predicate to narrow which resources are candidates.
1266
+ *
1267
+ * When omitted, all resources of the given kind are candidates.
1268
+ * Combined with the strict uniqueness check this means "assert there is
1269
+ * exactly one resource of this kind."
1270
+ */
1271
+ readonly where?: undefined | ((resource: T) => boolean);
1272
+
1273
+ /**
1274
+ * Assertion callback.
1275
+ *
1276
+ * The callback is invoked with `this` bound to the single matching resource.
1277
+ * Throwing (or rejecting) signals a failed assertion.
1278
+ */
1279
+ readonly test: (this: T, resource: T) => unknown | Promise<unknown>;
1280
+ }
1281
+
1149
1282
  /**
1150
1283
  * Kubernetes cluster selector for {@link Scenario.useCluster}.
1151
1284
  */
@@ -3,6 +3,7 @@ import { applyStatus } from "../actions/apply-status";
3
3
  import { assert } from "../actions/assert";
4
4
  import { assertAbsence } from "../actions/assert-absence";
5
5
  import { assertList } from "../actions/assert-list";
6
+ import { assertOne } from "../actions/assert-one";
6
7
  import { create } from "../actions/create";
7
8
  import {
8
9
  type CreateNamespaceInput,
@@ -46,6 +47,7 @@ export function createScenario(deps: CreateScenarioOptions): InternalScenario {
46
47
  assert: createQueryFn(deps, assert),
47
48
  assertAbsence: createQueryFn(deps, assertAbsence),
48
49
  assertList: createQueryFn(deps, assertList),
50
+ assertOne: createQueryFn(deps, assertOne),
49
51
  given: bdd.given(deps),
50
52
  when: bdd.when(deps),
51
53
  // biome-ignore lint/suspicious/noThenProperty: BDD DSL uses `then()` method name
@@ -210,6 +212,7 @@ const createNewNamespaceFn =
210
212
  assert: createQueryFn(namespacedDeps, assert),
211
213
  assertAbsence: createQueryFn(namespacedDeps, assertAbsence),
212
214
  assertList: createQueryFn(namespacedDeps, assertList),
215
+ assertOne: createQueryFn(namespacedDeps, assertOne),
213
216
  };
214
217
  };
215
218
 
@@ -233,6 +236,7 @@ const createUseClusterFn =
233
236
  assert: createQueryFn(clusterDeps, assert),
234
237
  assertAbsence: createQueryFn(clusterDeps, assertAbsence),
235
238
  assertList: createQueryFn(clusterDeps, assertList),
239
+ assertOne: createQueryFn(clusterDeps, assertOne),
236
240
  newNamespace: createNewNamespaceFn(clusterDeps),
237
241
  };
238
242
  };