@appthrust/kest 0.3.1 → 0.4.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 +79 -28
- package/package.json +1 -1
- package/ts/actions/{apply-namespace.ts → create-namespace.ts} +13 -21
- package/ts/apis/index.ts +28 -0
- package/ts/naming/index.ts +27 -0
- package/ts/scenario/index.ts +8 -6
package/README.md
CHANGED
|
@@ -254,8 +254,8 @@ await ns.label({
|
|
|
254
254
|
kind: "ConfigMap",
|
|
255
255
|
name: "my-config",
|
|
256
256
|
labels: {
|
|
257
|
-
env: "production",
|
|
258
|
-
deprecated: null,
|
|
257
|
+
env: "production", // add a label
|
|
258
|
+
deprecated: null, // remove a label
|
|
259
259
|
},
|
|
260
260
|
});
|
|
261
261
|
```
|
|
@@ -310,33 +310,51 @@ test("ConfigMap lifecycle", async (s) => {
|
|
|
310
310
|
|
|
311
311
|
### Markdown Test Reports
|
|
312
312
|
|
|
313
|
-
When a test fails (or when `KEST_SHOW_REPORT=1` is set), Kest generates a detailed Markdown report showing every action, the exact `kubectl` commands executed, stdout/stderr output, and cleanup results. This provides full transparency into what happened during the test, making troubleshooting straightforward -- for both humans and AI assistants.
|
|
313
|
+
When a test fails (or when `KEST_SHOW_REPORT=1` is set), Kest generates a detailed Markdown report showing every action, the exact `kubectl` commands executed (including stdin manifests), stdout/stderr output, and cleanup results. This provides full transparency into what happened during the test, making troubleshooting straightforward -- for both humans and AI assistants.
|
|
314
314
|
|
|
315
|
-
|
|
315
|
+
````markdown
|
|
316
316
|
# ConfigMap lifecycle
|
|
317
317
|
|
|
318
318
|
## Scenario Overview
|
|
319
319
|
|
|
320
|
-
| # | Action
|
|
321
|
-
| --- |
|
|
322
|
-
| 1 |
|
|
323
|
-
| 2 | Apply
|
|
324
|
-
| 3 | Assert
|
|
320
|
+
| # | Action | Status |
|
|
321
|
+
| --- | ------------------------------ | ------ |
|
|
322
|
+
| 1 | Apply Namespace `kest-9hdhj` | ✅ |
|
|
323
|
+
| 2 | Apply `ConfigMap` "my-config" | ✅ |
|
|
324
|
+
| 3 | Assert `ConfigMap` "my-config" | ✅ |
|
|
325
325
|
|
|
326
326
|
## Scenario Details
|
|
327
327
|
|
|
328
328
|
### Given: a namespace exists
|
|
329
329
|
|
|
330
|
-
|
|
330
|
+
**✅ Apply Namespace `kest-9hdhj`**
|
|
331
|
+
|
|
332
|
+
```shell
|
|
333
|
+
kubectl apply -f - <<EOF
|
|
334
|
+
apiVersion: v1
|
|
335
|
+
kind: Namespace
|
|
336
|
+
metadata:
|
|
337
|
+
name: kest-9hdhj
|
|
338
|
+
EOF
|
|
339
|
+
```
|
|
340
|
+
|
|
331
341
|
...
|
|
332
342
|
|
|
333
343
|
### Cleanup
|
|
334
344
|
|
|
335
|
-
| # | Action
|
|
336
|
-
| --- |
|
|
337
|
-
| 1 | Delete
|
|
338
|
-
| 2 | Delete
|
|
345
|
+
| # | Action | Status |
|
|
346
|
+
| --- | ------------------------------ | ------ |
|
|
347
|
+
| 1 | Delete `ConfigMap` "my-config" | ✅ |
|
|
348
|
+
| 2 | Delete Namespace `kest-9hdhj` | ✅ |
|
|
349
|
+
|
|
350
|
+
```shellsession
|
|
351
|
+
$ kubectl delete ConfigMap/my-config -n kest-9hdhj
|
|
352
|
+
configmap "my-config" deleted
|
|
353
|
+
|
|
354
|
+
$ kubectl delete namespace/kest-9hdhj
|
|
355
|
+
namespace "kest-9hdhj" deleted
|
|
339
356
|
```
|
|
357
|
+
````
|
|
340
358
|
|
|
341
359
|
## Getting Started
|
|
342
360
|
|
|
@@ -410,21 +428,22 @@ Entry point for defining a test scenario. The callback receives a `Scenario` obj
|
|
|
410
428
|
|
|
411
429
|
The top-level API surface available in every test callback.
|
|
412
430
|
|
|
413
|
-
| Method | Description
|
|
414
|
-
| ----------------------------------------------------------------------- |
|
|
415
|
-
| `apply(manifest, options?)` | Apply a Kubernetes manifest and register cleanup
|
|
416
|
-
| `create(manifest, options?)` | Create a Kubernetes resource and register cleanup
|
|
417
|
-
| `applyStatus(manifest, options?)` | Apply a status subresource (server-side apply)
|
|
418
|
-
| `delete(resource, options?)` | Delete a resource by API version, kind, and name
|
|
419
|
-
| `label(input, options?)` | Add, update, or remove labels on a resource
|
|
420
|
-
| `get(resource, options?)` | Fetch a resource by API version, kind, and name
|
|
421
|
-
| `assert(resource, options?)` | Fetch a resource and run assertions with retries
|
|
422
|
-
| `assertAbsence(resource, options?)` | Assert that a resource does not exist
|
|
423
|
-
| `assertList(resource, options?)` | Fetch a list of resources and run assertions
|
|
431
|
+
| Method | Description |
|
|
432
|
+
| ----------------------------------------------------------------------- | ----------------------------------------------------------- |
|
|
433
|
+
| `apply(manifest, options?)` | Apply a Kubernetes manifest and register cleanup |
|
|
434
|
+
| `create(manifest, options?)` | Create a Kubernetes resource and register cleanup |
|
|
435
|
+
| `applyStatus(manifest, options?)` | Apply a status subresource (server-side apply) |
|
|
436
|
+
| `delete(resource, options?)` | Delete a resource by API version, kind, and name |
|
|
437
|
+
| `label(input, options?)` | Add, update, or remove labels on a resource |
|
|
438
|
+
| `get(resource, options?)` | Fetch a resource by API version, kind, and name |
|
|
439
|
+
| `assert(resource, options?)` | Fetch a resource and run assertions with retries |
|
|
440
|
+
| `assertAbsence(resource, options?)` | Assert that a resource does not exist |
|
|
441
|
+
| `assertList(resource, options?)` | Fetch a list of resources and run assertions |
|
|
424
442
|
| `newNamespace(name?, options?)` | Create an ephemeral namespace (supports `{ generateName }`) |
|
|
425
|
-
| `
|
|
426
|
-
| `
|
|
427
|
-
| `
|
|
443
|
+
| `generateName(prefix)` | Generate a random-suffix name (statistical uniqueness) |
|
|
444
|
+
| `exec(input, options?)` | Execute shell commands with optional revert |
|
|
445
|
+
| `useCluster(ref)` | Create a cluster-bound API surface |
|
|
446
|
+
| `given(desc)` / `when(desc)` / `then(desc)` / `and(desc)` / `but(desc)` | BDD annotations for reporting |
|
|
428
447
|
|
|
429
448
|
### Namespace / Cluster
|
|
430
449
|
|
|
@@ -441,6 +460,38 @@ All actions accept an optional options object for retry configuration.
|
|
|
441
460
|
|
|
442
461
|
Duration strings support units like `"200ms"`, `"5s"`, `"1m"`.
|
|
443
462
|
|
|
463
|
+
## Best Practices
|
|
464
|
+
|
|
465
|
+
### Avoiding naming collisions between tests
|
|
466
|
+
|
|
467
|
+
When tests run in parallel, hard-coded resource names can collide (especially when you create cluster-scoped resources).
|
|
468
|
+
|
|
469
|
+
Kest offers a few ways to avoid these collisions:
|
|
470
|
+
|
|
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).
|
|
474
|
+
|
|
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).
|
|
476
|
+
|
|
477
|
+
```ts
|
|
478
|
+
s.given("a cluster-scoped resource name should not collide with other tests");
|
|
479
|
+
const roleName = s.generateName("kest-e2e-role-");
|
|
480
|
+
|
|
481
|
+
await s.create({
|
|
482
|
+
apiVersion: "rbac.authorization.k8s.io/v1",
|
|
483
|
+
kind: "ClusterRole",
|
|
484
|
+
metadata: { name: roleName },
|
|
485
|
+
rules: [
|
|
486
|
+
{
|
|
487
|
+
apiGroups: [""],
|
|
488
|
+
resources: ["configmaps"],
|
|
489
|
+
verbs: ["get", "list"],
|
|
490
|
+
},
|
|
491
|
+
],
|
|
492
|
+
});
|
|
493
|
+
```
|
|
494
|
+
|
|
444
495
|
## Type Safety
|
|
445
496
|
|
|
446
497
|
Define TypeScript interfaces for your Kubernetes resources to get full type checking in manifests and assertions:
|
package/package.json
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { create } from "./create";
|
|
2
2
|
import type { MutateDef } from "./types";
|
|
3
|
+
import { generateName } from "../naming";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Input for namespace creation.
|
|
@@ -9,19 +10,19 @@ import type { MutateDef } from "./types";
|
|
|
9
10
|
* - `{ generateName: string }` -- use the string as a prefix followed by
|
|
10
11
|
* random characters (e.g. `{ generateName: "foo-" }` → `"foo-d7kpn"`).
|
|
11
12
|
*/
|
|
12
|
-
export type
|
|
13
|
+
export type CreateNamespaceInput =
|
|
13
14
|
| undefined
|
|
14
15
|
| string
|
|
15
16
|
| { readonly generateName: string };
|
|
16
17
|
|
|
17
|
-
export const
|
|
18
|
+
export const createNamespace = {
|
|
18
19
|
type: "mutate",
|
|
19
|
-
name: "
|
|
20
|
+
name: "CreateNamespace",
|
|
20
21
|
mutate:
|
|
21
22
|
({ kubectl }) =>
|
|
22
23
|
async (input) => {
|
|
23
24
|
const name = resolveNamespaceName(input);
|
|
24
|
-
const { revert } = await
|
|
25
|
+
const { revert } = await create.mutate({ kubectl })({
|
|
25
26
|
apiVersion: "v1",
|
|
26
27
|
kind: "Namespace",
|
|
27
28
|
metadata: {
|
|
@@ -32,30 +33,21 @@ export const applyNamespace = {
|
|
|
32
33
|
},
|
|
33
34
|
describe: (input) => {
|
|
34
35
|
if (input === undefined) {
|
|
35
|
-
return "
|
|
36
|
+
return "Create `Namespace` with auto-generated name";
|
|
36
37
|
}
|
|
37
38
|
if (typeof input === "string") {
|
|
38
|
-
return `
|
|
39
|
+
return `Create \`Namespace\` "${input}"`;
|
|
39
40
|
}
|
|
40
|
-
return `
|
|
41
|
+
return `Create \`Namespace\` with prefix "${input.generateName}"`;
|
|
41
42
|
},
|
|
42
|
-
} satisfies MutateDef<
|
|
43
|
+
} satisfies MutateDef<CreateNamespaceInput, string>;
|
|
43
44
|
|
|
44
|
-
function resolveNamespaceName(input:
|
|
45
|
+
function resolveNamespaceName(input: CreateNamespaceInput): string {
|
|
45
46
|
if (input === undefined) {
|
|
46
|
-
return
|
|
47
|
+
return generateName("kest-", 5);
|
|
47
48
|
}
|
|
48
49
|
if (typeof input === "string") {
|
|
49
50
|
return input;
|
|
50
51
|
}
|
|
51
|
-
return
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function randomConsonantDigits(length = 8): string {
|
|
55
|
-
const chars = "bcdfghjklmnpqrstvwxyz0123456789";
|
|
56
|
-
let result = "";
|
|
57
|
-
for (let i = 0; i < length; i++) {
|
|
58
|
-
result += chars[Math.floor(Math.random() * chars.length)];
|
|
59
|
-
}
|
|
60
|
-
return result;
|
|
52
|
+
return generateName(input.generateName, 5);
|
|
61
53
|
}
|
package/ts/apis/index.ts
CHANGED
|
@@ -332,6 +332,34 @@ export interface Scenario {
|
|
|
332
332
|
options?: undefined | ActionOptions
|
|
333
333
|
): Promise<Namespace>;
|
|
334
334
|
|
|
335
|
+
/**
|
|
336
|
+
* Generates a random, Kubernetes-friendly name with the given prefix.
|
|
337
|
+
*
|
|
338
|
+
* This is a pure helper (no kubectl calls). Useful when you need multiple
|
|
339
|
+
* unique names within a single scenario, especially when you need custom
|
|
340
|
+
* metadata (e.g. labels) and can't use {@link Scenario.newNamespace}.
|
|
341
|
+
*
|
|
342
|
+
* @example
|
|
343
|
+
* ```ts
|
|
344
|
+
* // Useful for cluster-scoped resources (names must be unique cluster-wide)
|
|
345
|
+
* const roleName = s.generateName("kest-e2e-role-");
|
|
346
|
+
*
|
|
347
|
+
* await s.create({
|
|
348
|
+
* apiVersion: "rbac.authorization.k8s.io/v1",
|
|
349
|
+
* kind: "ClusterRole",
|
|
350
|
+
* metadata: { name: roleName },
|
|
351
|
+
* rules: [
|
|
352
|
+
* {
|
|
353
|
+
* apiGroups: [""],
|
|
354
|
+
* resources: ["configmaps"],
|
|
355
|
+
* verbs: ["get", "list"],
|
|
356
|
+
* },
|
|
357
|
+
* ],
|
|
358
|
+
* });
|
|
359
|
+
* ```
|
|
360
|
+
*/
|
|
361
|
+
generateName(prefix: string): string;
|
|
362
|
+
|
|
335
363
|
// Shell command actions
|
|
336
364
|
|
|
337
365
|
/**
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
const consonantDigits = "bcdfghjklmnpqrstvwxyz0123456789";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Generates a random string consisting of consonants and digits.
|
|
5
|
+
*
|
|
6
|
+
* This is intended for Kubernetes resource names to avoid accidental words.
|
|
7
|
+
*/
|
|
8
|
+
export function randomConsonantDigits(length = 8): string {
|
|
9
|
+
let result = "";
|
|
10
|
+
for (let i = 0; i < length; i++) {
|
|
11
|
+
result += consonantDigits[Math.floor(Math.random() * consonantDigits.length)];
|
|
12
|
+
}
|
|
13
|
+
return result;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Generates a Kubernetes-friendly name by appending a random suffix.
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* ```ts
|
|
21
|
+
* generateName("foo-"); // "foo-d7kpn"
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
export function generateName(prefix: string, suffixLength = 5): string {
|
|
25
|
+
return `${prefix}${randomConsonantDigits(suffixLength)}`;
|
|
26
|
+
}
|
|
27
|
+
|
package/ts/scenario/index.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { apply } from "../actions/apply";
|
|
2
|
-
import {
|
|
3
|
-
type ApplyNamespaceInput,
|
|
4
|
-
applyNamespace,
|
|
5
|
-
} from "../actions/apply-namespace";
|
|
6
2
|
import { applyStatus } from "../actions/apply-status";
|
|
7
3
|
import { assert } from "../actions/assert";
|
|
8
4
|
import { assertAbsence } from "../actions/assert-absence";
|
|
9
5
|
import { assertList } from "../actions/assert-list";
|
|
10
6
|
import { create } from "../actions/create";
|
|
7
|
+
import {
|
|
8
|
+
type CreateNamespaceInput,
|
|
9
|
+
createNamespace,
|
|
10
|
+
} from "../actions/create-namespace";
|
|
11
11
|
import { deleteResource } from "../actions/delete";
|
|
12
12
|
import { exec } from "../actions/exec";
|
|
13
13
|
import { get } from "../actions/get";
|
|
@@ -22,6 +22,7 @@ import type {
|
|
|
22
22
|
} from "../apis";
|
|
23
23
|
import bdd from "../bdd";
|
|
24
24
|
import type { Kubectl } from "../kubectl";
|
|
25
|
+
import { generateName as generateRandomName } from "../naming";
|
|
25
26
|
import type { Recorder } from "../recording";
|
|
26
27
|
import type { Reporter } from "../reporter/interface";
|
|
27
28
|
import { retryUntil } from "../retry";
|
|
@@ -51,6 +52,7 @@ export function createScenario(deps: CreateScenarioOptions): InternalScenario {
|
|
|
51
52
|
then: bdd.then(deps),
|
|
52
53
|
and: bdd.and(deps),
|
|
53
54
|
but: bdd.but(deps),
|
|
55
|
+
generateName: (prefix: string) => generateRandomName(prefix),
|
|
54
56
|
newNamespace: createNewNamespaceFn(deps),
|
|
55
57
|
useCluster: createUseClusterFn(deps),
|
|
56
58
|
async cleanup() {
|
|
@@ -187,10 +189,10 @@ const createQueryFn =
|
|
|
187
189
|
const createNewNamespaceFn =
|
|
188
190
|
(scenarioDeps: CreateScenarioOptions) =>
|
|
189
191
|
async (
|
|
190
|
-
name?:
|
|
192
|
+
name?: CreateNamespaceInput,
|
|
191
193
|
options?: undefined | ActionOptions
|
|
192
194
|
): Promise<Namespace> => {
|
|
193
|
-
const namespaceName = await createMutateFn(scenarioDeps,
|
|
195
|
+
const namespaceName = await createMutateFn(scenarioDeps, createNamespace)(
|
|
194
196
|
name,
|
|
195
197
|
options
|
|
196
198
|
);
|