@appthrust/kest 0.3.0 → 0.3.2

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
@@ -119,6 +119,21 @@ await ns.assert(
119
119
  );
120
120
  ```
121
121
 
122
+ ### Create Resources
123
+
124
+ Use `kubectl create` instead of `kubectl apply` when you need to ensure a resource is freshly created (e.g. the resource must not already exist, or you want to use `generateName`):
125
+
126
+ ```ts
127
+ await ns.create({
128
+ apiVersion: "v1",
129
+ kind: "ConfigMap",
130
+ metadata: { name: "my-config" },
131
+ data: { mode: "demo" },
132
+ });
133
+ ```
134
+
135
+ Like `apply`, `create` registers a cleanup handler that deletes the resource when the test ends. The key difference is that `kubectl create` fails if the resource already exists, whereas `kubectl apply` performs an upsert.
136
+
122
137
  ### Multiple Manifest Formats
123
138
 
124
139
  Apply resources using whichever format is most convenient:
@@ -239,8 +254,8 @@ await ns.label({
239
254
  kind: "ConfigMap",
240
255
  name: "my-config",
241
256
  labels: {
242
- env: "production", // add a label
243
- deprecated: null, // remove a label
257
+ env: "production", // add a label
258
+ deprecated: null, // remove a label
244
259
  },
245
260
  });
246
261
  ```
@@ -295,33 +310,51 @@ test("ConfigMap lifecycle", async (s) => {
295
310
 
296
311
  ### Markdown Test Reports
297
312
 
298
- 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.
299
314
 
300
- ```markdown
315
+ ````markdown
301
316
  # ConfigMap lifecycle
302
317
 
303
318
  ## Scenario Overview
304
319
 
305
- | # | Action | Resource | Status |
306
- | --- | ---------------- | ------------------- | ------ |
307
- | 1 | Create namespace | kest-9hdhj | ✅ |
308
- | 2 | Apply | ConfigMap/my-config | ✅ |
309
- | 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" | ✅ |
310
325
 
311
326
  ## Scenario Details
312
327
 
313
328
  ### Given: a namespace exists
314
329
 
315
- 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
+
316
341
  ...
317
342
 
318
343
  ### Cleanup
319
344
 
320
- | # | Action | Resource | Status |
321
- | --- | ---------------- | ------------------- | ------ |
322
- | 1 | Delete | ConfigMap/my-config | ✅ |
323
- | 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
324
356
  ```
357
+ ````
325
358
 
326
359
  ## Getting Started
327
360
 
@@ -395,24 +428,25 @@ Entry point for defining a test scenario. The callback receives a `Scenario` obj
395
428
 
396
429
  The top-level API surface available in every test callback.
397
430
 
398
- | Method | Description |
399
- | ----------------------------------------------------------------------- | ------------------------------------------------- |
400
- | `apply(manifest, options?)` | Apply a Kubernetes manifest and register cleanup |
401
- | `applyStatus(manifest, options?)` | Apply a status subresource (server-side apply) |
402
- | `delete(resource, options?)` | Delete a resource by API version, kind, and name |
403
- | `label(input, options?)` | Add, update, or remove labels on a resource |
404
- | `get(resource, options?)` | Fetch a resource by API version, kind, and name |
405
- | `assert(resource, options?)` | Fetch a resource and run assertions with retries |
406
- | `assertAbsence(resource, options?)` | Assert that a resource does not exist |
407
- | `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 |
408
442
  | `newNamespace(name?, options?)` | Create an ephemeral namespace (supports `{ generateName }`) |
409
- | `exec(input, options?)` | Execute shell commands with optional revert |
410
- | `useCluster(ref)` | Create a cluster-bound API surface |
411
- | `given(desc)` / `when(desc)` / `then(desc)` / `and(desc)` / `but(desc)` | BDD annotations for reporting |
443
+ | `exec(input, options?)` | Execute shell commands with optional revert |
444
+ | `useCluster(ref)` | Create a cluster-bound API surface |
445
+ | `given(desc)` / `when(desc)` / `then(desc)` / `and(desc)` / `but(desc)` | BDD annotations for reporting |
412
446
 
413
447
  ### Namespace / Cluster
414
448
 
415
- Returned by `newNamespace()` and `useCluster()` respectively. They expose the same core methods (`apply`, `applyStatus`, `delete`, `label`, `get`, `assert`, `assertAbsence`, `assertList`) scoped to their namespace or cluster context. `Cluster` additionally supports `newNamespace`.
449
+ 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`.
416
450
 
417
451
  ### Action Options
418
452
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appthrust/kest",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Kubernetes E2E testing framework designed for humans and AI alike",
5
5
  "type": "module",
6
6
  "module": "ts/index.ts",
@@ -52,6 +52,7 @@
52
52
  "devDependencies": {
53
53
  "@biomejs/biome": "^2.3.13",
54
54
  "@suin/biome.json": "^0.1.0",
55
+ "@taml/encoder": "^1.0.0",
55
56
  "@tsconfig/bun": "^1.0.10",
56
57
  "@tsconfig/strictest": "^2.0.8",
57
58
  "@types/bun": "^1.3.7",
@@ -1,5 +1,5 @@
1
1
  import type { ApplyingManifest } from "../apis";
2
- import { parseK8sResourceAny } from "../k8s-resource";
2
+ import { getResourceMeta, parseK8sResourceAny } from "../k8s-resource";
3
3
  import type { OneWayMutateDef } from "./types";
4
4
 
5
5
  export const applyStatus = {
@@ -20,4 +20,11 @@ export const applyStatus = {
20
20
  await kubectl.applyStatus(result.value);
21
21
  return undefined;
22
22
  },
23
+ describe: (manifest) => {
24
+ const meta = getResourceMeta(manifest);
25
+ if (meta === undefined) {
26
+ return "Apply status of a resource";
27
+ }
28
+ return `Apply status of \`${meta.kind}\` "${meta.name}"`;
29
+ },
23
30
  } satisfies OneWayMutateDef<ApplyingManifest, void>;
@@ -1,5 +1,5 @@
1
1
  import type { ApplyingManifest } from "../apis";
2
- import { parseK8sResourceAny } from "../k8s-resource";
2
+ import { getResourceMeta, parseK8sResourceAny } from "../k8s-resource";
3
3
  import type { MutateDef } from "./types";
4
4
 
5
5
  export const apply = {
@@ -24,4 +24,11 @@ export const apply = {
24
24
  output: undefined,
25
25
  };
26
26
  },
27
+ describe: (manifest) => {
28
+ const meta = getResourceMeta(manifest);
29
+ if (meta === undefined) {
30
+ return "Apply a resource";
31
+ }
32
+ return `Apply \`${meta.kind}\` "${meta.name}"`;
33
+ },
27
34
  } satisfies MutateDef<ApplyingManifest, void>;
@@ -22,6 +22,8 @@ export const assertAbsence = {
22
22
  `Expected ${resource.kind} "${resource.name}" to be absent, but it exists`
23
23
  );
24
24
  },
25
+ describe: (resource) =>
26
+ `Assert that \`${resource.kind}\` "${resource.name}" is absent`,
25
27
  } satisfies QueryDef<K8sResourceReference, void>;
26
28
 
27
29
  /**
@@ -28,6 +28,9 @@ export const assertList = {
28
28
  await condition.test.call(typed, typed);
29
29
  return typed;
30
30
  },
31
+ describe: <T extends K8sResource>(condition: ResourceListTest<T>): string => {
32
+ return `Assert a list of \`${condition.kind}\` resources`;
33
+ },
31
34
  } satisfies QueryDef<ResourceListTest, Array<K8sResource>>;
32
35
 
33
36
  function isSameGVK<T extends K8sResource>(
@@ -19,6 +19,9 @@ export const assert = {
19
19
  await condition.test.call(fetched, fetched);
20
20
  return fetched;
21
21
  },
22
+ describe: <T extends K8sResource>(condition: ResourceTest<T>): string => {
23
+ return `Assert \`${condition.kind}\` "${condition.name}"`;
24
+ },
22
25
  } satisfies QueryDef<ResourceTest, K8sResource>;
23
26
 
24
27
  function toKubectlType<T extends K8sResource>(
@@ -1,4 +1,4 @@
1
- import { apply } from "./apply";
1
+ import { create } from "./create";
2
2
  import type { MutateDef } from "./types";
3
3
 
4
4
  /**
@@ -9,19 +9,19 @@ import type { MutateDef } from "./types";
9
9
  * - `{ generateName: string }` -- use the string as a prefix followed by
10
10
  * random characters (e.g. `{ generateName: "foo-" }` → `"foo-d7kpn"`).
11
11
  */
12
- export type ApplyNamespaceInput =
12
+ export type CreateNamespaceInput =
13
13
  | undefined
14
14
  | string
15
15
  | { readonly generateName: string };
16
16
 
17
- export const applyNamespace = {
17
+ export const createNamespace = {
18
18
  type: "mutate",
19
- name: "ApplyNamespace",
19
+ name: "CreateNamespace",
20
20
  mutate:
21
21
  ({ kubectl }) =>
22
22
  async (input) => {
23
23
  const name = resolveNamespaceName(input);
24
- const { revert } = await apply.mutate({ kubectl })({
24
+ const { revert } = await create.mutate({ kubectl })({
25
25
  apiVersion: "v1",
26
26
  kind: "Namespace",
27
27
  metadata: {
@@ -30,9 +30,18 @@ export const applyNamespace = {
30
30
  });
31
31
  return { revert, output: name };
32
32
  },
33
- } satisfies MutateDef<ApplyNamespaceInput, string>;
33
+ describe: (input) => {
34
+ if (input === undefined) {
35
+ return "Create `Namespace` with auto-generated name";
36
+ }
37
+ if (typeof input === "string") {
38
+ return `Create \`Namespace\` "${input}"`;
39
+ }
40
+ return `Create \`Namespace\` with prefix "${input.generateName}"`;
41
+ },
42
+ } satisfies MutateDef<CreateNamespaceInput, string>;
34
43
 
35
- function resolveNamespaceName(input: ApplyNamespaceInput): string {
44
+ function resolveNamespaceName(input: CreateNamespaceInput): string {
36
45
  if (input === undefined) {
37
46
  return `kest-${randomConsonantDigits(5)}`;
38
47
  }
@@ -0,0 +1,34 @@
1
+ import type { ApplyingManifest } from "../apis";
2
+ import { getResourceMeta, parseK8sResourceAny } from "../k8s-resource";
3
+ import type { MutateDef } from "./types";
4
+
5
+ export const create = {
6
+ type: "mutate",
7
+ name: "Create",
8
+ mutate:
9
+ ({ kubectl }) =>
10
+ async (manifest) => {
11
+ const result = await parseK8sResourceAny(manifest);
12
+ if (!result.ok) {
13
+ throw new Error(
14
+ `Invalid Kubernetes resource: ${result.violations.join(", ")}`
15
+ );
16
+ }
17
+ await kubectl.create(result.value);
18
+ return {
19
+ async revert() {
20
+ await kubectl.delete(result.value.kind, result.value.metadata.name, {
21
+ ignoreNotFound: true,
22
+ });
23
+ },
24
+ output: undefined,
25
+ };
26
+ },
27
+ describe: (manifest) => {
28
+ const meta = getResourceMeta(manifest);
29
+ if (meta === undefined) {
30
+ return "Create a resource";
31
+ }
32
+ return `Create \`${meta.kind}\` "${meta.name}"`;
33
+ },
34
+ } satisfies MutateDef<ApplyingManifest, void>;
@@ -11,4 +11,7 @@ export const deleteResource = {
11
11
  await kubectl.delete(toKubectlType(resource), resource.name);
12
12
  return undefined;
13
13
  },
14
+ describe: (resource) => {
15
+ return `Delete \`${resource.kind}\` "${resource.name}"`;
16
+ },
14
17
  } satisfies OneWayMutateDef<K8sResourceReference, void>;
@@ -17,5 +17,8 @@ export const exec = {
17
17
  revert: revert ? () => revert(context) : noopRevert,
18
18
  };
19
19
  },
20
+ describe: () => {
21
+ return "Execute arbitrary processing";
22
+ },
20
23
  // biome-ignore lint/suspicious/noExplicitAny: 本当はunknownにしたいが、createMutateFnとの噛み合せが難しいためanyにしている
21
24
  } satisfies MutateDef<ExecInput<any>, unknown>;
package/ts/actions/get.ts CHANGED
@@ -15,6 +15,9 @@ export const get = {
15
15
  ...finding,
16
16
  test: (fetched) => assertSameGVK<T>(finding, fetched),
17
17
  }),
18
+ describe: (finding) => {
19
+ return `Get \`${finding.kind}\` "${finding.name}"`;
20
+ },
18
21
  } satisfies QueryDef<K8sResourceReference, K8sResource>;
19
22
 
20
23
  function isSameGVK<T extends K8sResource>(
@@ -17,4 +17,7 @@ export const label = {
17
17
  });
18
18
  return undefined;
19
19
  },
20
+ describe: (input) => {
21
+ return `Label \`${input.kind}\` "${input.name}"`;
22
+ },
20
23
  } satisfies OneWayMutateDef<LabelInput, void>;
@@ -6,6 +6,7 @@ export interface MutateDef<Input, Output> {
6
6
  readonly type: "mutate";
7
7
  readonly name: string;
8
8
  readonly mutate: Mutate<Input, Output>;
9
+ readonly describe: (input: Input) => string;
9
10
  }
10
11
 
11
12
  export type Mutate<Input, Output> = (
@@ -27,6 +28,7 @@ export interface OneWayMutateDef<Input, Output> {
27
28
  readonly type: "oneWayMutate";
28
29
  readonly name: string;
29
30
  readonly mutate: OneWayMutate<Input, Output>;
31
+ readonly describe: (input: Input) => string;
30
32
  }
31
33
 
32
34
  export type OneWayMutate<Input, Output> = (
@@ -37,6 +39,7 @@ export interface QueryDef<Input, Output> {
37
39
  readonly type: "query";
38
40
  readonly name: string;
39
41
  readonly query: Query<Input, Output>;
42
+ readonly describe: (input: Input) => string;
40
43
  }
41
44
 
42
45
  export type Query<Input, Output> = (
package/ts/apis/index.ts CHANGED
@@ -54,6 +54,39 @@ export interface Scenario {
54
54
  options?: undefined | ActionOptions
55
55
  ): Promise<void>;
56
56
 
57
+ /**
58
+ * Creates a Kubernetes resource with `kubectl create`.
59
+ *
60
+ * The manifest is validated and then created. When the action succeeds, Kest
61
+ * registers a cleanup handler that deletes the resource using
62
+ * `kubectl delete <kind>/<metadata.name>` during scenario cleanup.
63
+ *
64
+ * Unlike {@link Scenario.apply}, this action uses `kubectl create` which
65
+ * fails if the resource already exists. Use this when you need to ensure the
66
+ * resource is freshly created (e.g. for resources that use `generateName` or
67
+ * when you want to guarantee no prior state).
68
+ *
69
+ * This action is retried when it throws.
70
+ *
71
+ * @template T - The expected Kubernetes resource shape.
72
+ * @param manifest - YAML string, resource object, or imported YAML module.
73
+ * @param options - Retry options such as timeout and polling interval.
74
+ *
75
+ * @example
76
+ * ```ts
77
+ * await s.create({
78
+ * apiVersion: "v1",
79
+ * kind: "ConfigMap",
80
+ * metadata: { name: "my-config" },
81
+ * data: { mode: "demo" },
82
+ * });
83
+ * ```
84
+ */
85
+ create<T extends K8sResource>(
86
+ manifest: ApplyingManifest<T>,
87
+ options?: undefined | ActionOptions
88
+ ): Promise<void>;
89
+
57
90
  /**
58
91
  * Applies the `status` subresource using server-side apply.
59
92
  *
@@ -442,6 +475,30 @@ export interface Cluster {
442
475
  options?: undefined | ActionOptions
443
476
  ): Promise<void>;
444
477
 
478
+ /**
479
+ * Creates a Kubernetes resource with `kubectl create` and registers cleanup.
480
+ *
481
+ * Unlike {@link Cluster.apply}, this uses `kubectl create` which fails if the
482
+ * resource already exists.
483
+ *
484
+ * @template T - The expected Kubernetes resource shape.
485
+ * @param manifest - YAML string, resource object, or imported YAML module.
486
+ * @param options - Retry options such as timeout and polling interval.
487
+ *
488
+ * @example
489
+ * ```ts
490
+ * await cluster.create({
491
+ * apiVersion: "v1",
492
+ * kind: "Namespace",
493
+ * metadata: { name: "my-team" },
494
+ * });
495
+ * ```
496
+ */
497
+ create<T extends K8sResource>(
498
+ manifest: ApplyingManifest<T>,
499
+ options?: undefined | ActionOptions
500
+ ): Promise<void>;
501
+
445
502
  /**
446
503
  * Applies the `status` subresource using server-side apply.
447
504
  *
@@ -683,6 +740,37 @@ export interface Namespace {
683
740
  options?: undefined | ActionOptions
684
741
  ): Promise<void>;
685
742
 
743
+ /**
744
+ * Creates a Kubernetes resource in this namespace with `kubectl create` and
745
+ * registers cleanup.
746
+ *
747
+ * The target namespace is controlled by this {@link Namespace} instance.
748
+ * Prefer omitting `manifest.metadata.namespace`; if it is set, it must match
749
+ * this namespace (otherwise `kubectl` fails).
750
+ *
751
+ * Unlike {@link Namespace.apply}, this uses `kubectl create` which fails if
752
+ * the resource already exists.
753
+ *
754
+ * @template T - The expected Kubernetes resource shape.
755
+ * @param manifest - YAML string, resource object, or imported YAML module.
756
+ * @param options - Retry options such as timeout and polling interval.
757
+ *
758
+ * @example
759
+ * ```ts
760
+ * const ns = await s.newNamespace("my-ns");
761
+ * await ns.create({
762
+ * apiVersion: "v1",
763
+ * kind: "ConfigMap",
764
+ * metadata: { name: "my-config" },
765
+ * data: { mode: "demo" },
766
+ * });
767
+ * ```
768
+ */
769
+ create<T extends K8sResource>(
770
+ manifest: ApplyingManifest<T>,
771
+ options?: undefined | ActionOptions
772
+ ): Promise<void>;
773
+
686
774
  /**
687
775
  * Applies the `status` subresource in this namespace using server-side apply.
688
776
  *
@@ -118,3 +118,25 @@ export interface ESM {
118
118
  function isESM(value: unknown): value is ESM {
119
119
  return typeof value === "object" && value !== null && "default" in value;
120
120
  }
121
+
122
+ export function getResourceMeta(
123
+ value: unknown
124
+ ): undefined | { kind: string; name: string } {
125
+ if (typeof value === "string") {
126
+ const result = parseK8sResourceYaml(value);
127
+ if (result.ok) {
128
+ return { kind: result.value.kind, name: result.value.metadata.name };
129
+ }
130
+ }
131
+ if (isESM(value)) {
132
+ const result = parseK8sResourceFromESM(value);
133
+ if (result.ok) {
134
+ return { kind: result.value.kind, name: result.value.metadata.name };
135
+ }
136
+ }
137
+ const result = parseK8sResource(value);
138
+ if (result.ok) {
139
+ return { kind: result.value.kind, name: result.value.metadata.name };
140
+ }
141
+ return undefined;
142
+ }