@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 +206 -43
- package/package.json +1 -1
- package/ts/actions/assert-apply-error.ts +43 -0
- package/ts/actions/assert-create-error.ts +43 -0
- package/ts/actions/assert-one.ts +93 -0
- package/ts/actions/create-namespace.ts +1 -1
- package/ts/apis/index.ts +300 -0
- package/ts/naming/index.ts +2 -2
- package/ts/reporter/markdown/model.ts +1 -1
- package/ts/reporter/markdown/parser/index.ts +9 -5
- package/ts/reporter/markdown/renderer/index.ts +2 -3
- package/ts/scenario/index.ts +12 -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.
|
|
@@ -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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
238
|
-
{
|
|
239
|
-
apiVersion: "
|
|
240
|
-
kind: "
|
|
241
|
-
name: "my-
|
|
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
|
-
|
|
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
|
|
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`
|
|
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
|
-
|
|
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
|
-
|
|
721
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
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
|
-
|
|
866
|
+
**When to use `s.generateName`:**
|
|
740
867
|
|
|
741
|
-
|
|
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
|
-
|
|
875
|
+
Choose **necessary and sufficient** over "safe side" -- every random suffix is a readability trade-off.
|
|
744
876
|
|
|
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).
|
|
877
|
+
**How the two helpers work:**
|
|
748
878
|
|
|
749
|
-
`
|
|
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
|
@@ -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
|
+
}
|
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
|
*/
|
package/ts/naming/index.ts
CHANGED
|
@@ -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 +=
|
|
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
|
-
|
|
@@ -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
|
|
183
|
+
if (!currentAction || currentAction.commands.length === 0) {
|
|
184
184
|
return;
|
|
185
185
|
}
|
|
186
186
|
|
|
187
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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";
|
package/ts/scenario/index.ts
CHANGED
|
@@ -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
|
};
|