@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 CHANGED
@@ -254,8 +254,8 @@ await ns.label({
254
254
  kind: "ConfigMap",
255
255
  name: "my-config",
256
256
  labels: {
257
- env: "production", // add a label
258
- deprecated: null, // remove a label
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
- ```markdown
315
+ ````markdown
316
316
  # ConfigMap lifecycle
317
317
 
318
318
  ## Scenario Overview
319
319
 
320
- | # | Action | Resource | Status |
321
- | --- | ---------------- | ------------------- | ------ |
322
- | 1 | Create namespace | kest-9hdhj | ✅ |
323
- | 2 | Apply | ConfigMap/my-config | ✅ |
324
- | 3 | Assert | ConfigMap/my-config | ✅ |
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
- Create Namespace "kest-9hdhj"
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 | Resource | Status |
336
- | --- | ---------------- | ------------------- | ------ |
337
- | 1 | Delete | ConfigMap/my-config | ✅ |
338
- | 2 | Delete namespace | kest-9hdhj | ✅ |
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
- | `exec(input, options?)` | Execute shell commands with optional revert |
426
- | `useCluster(ref)` | Create a cluster-bound API surface |
427
- | `given(desc)` / `when(desc)` / `then(desc)` / `and(desc)` / `but(desc)` | BDD annotations for reporting |
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@appthrust/kest",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Kubernetes E2E testing framework designed for humans and AI alike",
5
5
  "type": "module",
6
6
  "module": "ts/index.ts",
@@ -1,5 +1,6 @@
1
- import { apply } from "./apply";
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 ApplyNamespaceInput =
13
+ export type CreateNamespaceInput =
13
14
  | undefined
14
15
  | string
15
16
  | { readonly generateName: string };
16
17
 
17
- export const applyNamespace = {
18
+ export const createNamespace = {
18
19
  type: "mutate",
19
- name: "ApplyNamespace",
20
+ name: "CreateNamespace",
20
21
  mutate:
21
22
  ({ kubectl }) =>
22
23
  async (input) => {
23
24
  const name = resolveNamespaceName(input);
24
- const { revert } = await apply.mutate({ kubectl })({
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 "Apply `Namespace` with auto-generated name";
36
+ return "Create `Namespace` with auto-generated name";
36
37
  }
37
38
  if (typeof input === "string") {
38
- return `Apply \`Namespace\` "${input}"`;
39
+ return `Create \`Namespace\` "${input}"`;
39
40
  }
40
- return `Apply \`Namespace\` with prefix "${input.generateName}"`;
41
+ return `Create \`Namespace\` with prefix "${input.generateName}"`;
41
42
  },
42
- } satisfies MutateDef<ApplyNamespaceInput, string>;
43
+ } satisfies MutateDef<CreateNamespaceInput, string>;
43
44
 
44
- function resolveNamespaceName(input: ApplyNamespaceInput): string {
45
+ function resolveNamespaceName(input: CreateNamespaceInput): string {
45
46
  if (input === undefined) {
46
- return `kest-${randomConsonantDigits(5)}`;
47
+ return generateName("kest-", 5);
47
48
  }
48
49
  if (typeof input === "string") {
49
50
  return input;
50
51
  }
51
- return `${input.generateName}${randomConsonantDigits(5)}`;
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
+
@@ -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?: ApplyNamespaceInput,
192
+ name?: CreateNamespaceInput,
191
193
  options?: undefined | ActionOptions
192
194
  ): Promise<Namespace> => {
193
- const namespaceName = await createMutateFn(scenarioDeps, applyNamespace)(
195
+ const namespaceName = await createMutateFn(scenarioDeps, createNamespace)(
194
196
  name,
195
197
  options
196
198
  );