@appthrust/kest 0.4.1 → 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 +151 -25
- package/package.json +1 -1
- package/ts/actions/assert-one.ts +93 -0
- package/ts/apis/index.ts +133 -0
- package/ts/scenario/index.ts +4 -0
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
|
|
|
@@ -617,11 +662,11 @@ Starting with one file per scenario reserves room to grow. Each file has built-i
|
|
|
617
662
|
|
|
618
663
|
**Name files after the behavior they verify.** A reader should know what a test checks without opening the file:
|
|
619
664
|
|
|
620
|
-
| ✅ Good
|
|
621
|
-
|
|
|
622
|
-
| `creates-namespaces-with-labels.test.ts`
|
|
623
|
-
| `rejects-reserved-selector-labels.test.ts` | `validation.test.ts`
|
|
624
|
-
| `rolls-out-when-image-changes.test.ts`
|
|
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` |
|
|
625
670
|
|
|
626
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.
|
|
627
672
|
|
|
@@ -714,39 +759,114 @@ await ns.apply(import("./fixtures/deployment-v2.yaml"));
|
|
|
714
759
|
await ns.apply(import("./fixtures/deployment-v1.ts"));
|
|
715
760
|
```
|
|
716
761
|
|
|
717
|
-
|
|
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
|
+
|
|
768
|
+
### Avoiding naming collisions between tests
|
|
769
|
+
|
|
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:
|
|
718
771
|
|
|
719
772
|
```ts
|
|
720
|
-
|
|
721
|
-
|
|
773
|
+
const ns = await s.newNamespace();
|
|
774
|
+
|
|
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.**
|
|
784
|
+
|
|
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
|
+
});
|
|
722
801
|
await ns.apply({
|
|
723
802
|
apiVersion: "apps/v1",
|
|
724
803
|
kind: "Deployment",
|
|
725
|
-
metadata: { name },
|
|
726
|
-
|
|
727
|
-
/* full spec here, not hidden in a function */
|
|
728
|
-
},
|
|
804
|
+
metadata: { name: deployName }, // "deploy-p3n8r" — what was this test about?
|
|
805
|
+
// ...
|
|
729
806
|
});
|
|
730
807
|
```
|
|
731
808
|
|
|
732
|
-
|
|
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();
|
|
733
814
|
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
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
|
+
```
|
|
738
828
|
|
|
739
|
-
|
|
829
|
+
**When to use `s.generateName`:**
|
|
740
830
|
|
|
741
|
-
|
|
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` |
|
|
742
837
|
|
|
743
|
-
|
|
838
|
+
Choose **necessary and sufficient** over "safe side" -- every random suffix is a readability trade-off.
|
|
744
839
|
|
|
745
|
-
|
|
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).
|
|
840
|
+
**How the two helpers work:**
|
|
748
841
|
|
|
749
|
-
`
|
|
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`:
|
|
750
870
|
|
|
751
871
|
```ts
|
|
752
872
|
s.given("a cluster-scoped resource name should not collide with other tests");
|
|
@@ -766,6 +886,12 @@ await s.create({
|
|
|
766
886
|
});
|
|
767
887
|
```
|
|
768
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
|
+
|
|
769
895
|
## Type Safety
|
|
770
896
|
|
|
771
897
|
Define TypeScript interfaces for your Kubernetes resources to get full type checking in manifests and assertions:
|
package/package.json
CHANGED
|
@@ -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
|
*/
|
package/ts/scenario/index.ts
CHANGED
|
@@ -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
|
};
|