@appthrust/kest 0.2.0 → 0.3.1

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
@@ -71,6 +71,13 @@ const ns = await s.newNamespace();
71
71
  // All resources applied through `ns` are scoped to this namespace.
72
72
  ```
73
73
 
74
+ You can also specify a custom prefix for the generated namespace name using `generateName`:
75
+
76
+ ```ts
77
+ const ns = await s.newNamespace({ generateName: "foo-" });
78
+ // Namespace name will be like "foo-d7kpn"
79
+ ```
80
+
74
81
  ### Automatic Cleanup (Reverse-Order, Blocking)
75
82
 
76
83
  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.
@@ -112,6 +119,21 @@ await ns.assert(
112
119
  );
113
120
  ```
114
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
+
115
137
  ### Multiple Manifest Formats
116
138
 
117
139
  Apply resources using whichever format is most convenient:
@@ -197,6 +219,61 @@ await ns.assertList<ConfigMap>({
197
219
  });
198
220
  ```
199
221
 
222
+ ### Absence Assertions
223
+
224
+ Assert that a resource does not exist (e.g. after deletion or to verify a controller hasn't created something):
225
+
226
+ ```ts
227
+ await ns.assertAbsence({
228
+ apiVersion: "v1",
229
+ kind: "ConfigMap",
230
+ name: "deleted-config",
231
+ });
232
+ ```
233
+
234
+ With retry-based polling to wait for a resource to disappear:
235
+
236
+ ```ts
237
+ await ns.assertAbsence(
238
+ {
239
+ apiVersion: "apps/v1",
240
+ kind: "Deployment",
241
+ name: "my-app",
242
+ },
243
+ { timeout: "30s", interval: "1s" },
244
+ );
245
+ ```
246
+
247
+ ### Label Resources
248
+
249
+ Add, update, or remove labels on Kubernetes resources using `kubectl label`:
250
+
251
+ ```ts
252
+ await ns.label({
253
+ apiVersion: "v1",
254
+ kind: "ConfigMap",
255
+ name: "my-config",
256
+ labels: {
257
+ env: "production", // add a label
258
+ deprecated: null, // remove a label
259
+ },
260
+ });
261
+ ```
262
+
263
+ To overwrite an existing label, set `overwrite: true`:
264
+
265
+ ```ts
266
+ await ns.label({
267
+ apiVersion: "apps/v1",
268
+ kind: "Deployment",
269
+ name: "my-app",
270
+ labels: {
271
+ version: "v2",
272
+ },
273
+ overwrite: true,
274
+ });
275
+ ```
276
+
200
277
  ### Shell Command Execution
201
278
 
202
279
  Run arbitrary shell commands with optional revert handlers for cleanup:
@@ -333,22 +410,25 @@ Entry point for defining a test scenario. The callback receives a `Scenario` obj
333
410
 
334
411
  The top-level API surface available in every test callback.
335
412
 
336
- | Method | Description |
337
- | ----------------------------------------------------------------------- | ------------------------------------------------ |
338
- | `apply(manifest, options?)` | Apply a Kubernetes manifest and register cleanup |
339
- | `applyStatus(manifest, options?)` | Apply a status subresource (server-side apply) |
340
- | `delete(resource, options?)` | Delete a resource by API version, kind, and name |
341
- | `get(resource, options?)` | Fetch a resource by API version, kind, and name |
342
- | `assert(resource, options?)` | Fetch a resource and run assertions with retries |
343
- | `assertList(resource, options?)` | Fetch a list of resources and run assertions |
344
- | `newNamespace(name?, options?)` | Create an ephemeral namespace |
345
- | `exec(input, options?)` | Execute shell commands with optional revert |
346
- | `useCluster(ref)` | Create a cluster-bound API surface |
347
- | `given(desc)` / `when(desc)` / `then(desc)` / `and(desc)` / `but(desc)` | BDD annotations for reporting |
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 |
424
+ | `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 |
348
428
 
349
429
  ### Namespace / Cluster
350
430
 
351
- Returned by `newNamespace()` and `useCluster()` respectively. They expose the same core methods (`apply`, `applyStatus`, `delete`, `get`, `assert`, `assertList`) scoped to their namespace or cluster context. `Cluster` additionally supports `newNamespace`.
431
+ 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`.
352
432
 
353
433
  ### Action Options
354
434
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appthrust/kest",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
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,13 +1,26 @@
1
1
  import { apply } from "./apply";
2
2
  import type { MutateDef } from "./types";
3
3
 
4
+ /**
5
+ * Input for namespace creation.
6
+ *
7
+ * - `undefined` -- auto-generate a name like `kest-{random}`.
8
+ * - `string` -- use the exact name provided.
9
+ * - `{ generateName: string }` -- use the string as a prefix followed by
10
+ * random characters (e.g. `{ generateName: "foo-" }` → `"foo-d7kpn"`).
11
+ */
12
+ export type ApplyNamespaceInput =
13
+ | undefined
14
+ | string
15
+ | { readonly generateName: string };
16
+
4
17
  export const applyNamespace = {
5
18
  type: "mutate",
6
19
  name: "ApplyNamespace",
7
20
  mutate:
8
21
  ({ kubectl }) =>
9
- async (namespaceName) => {
10
- const name = namespaceName ?? `kest-${randomConsonantDigits(5)}`;
22
+ async (input) => {
23
+ const name = resolveNamespaceName(input);
11
24
  const { revert } = await apply.mutate({ kubectl })({
12
25
  apiVersion: "v1",
13
26
  kind: "Namespace",
@@ -17,7 +30,26 @@ export const applyNamespace = {
17
30
  });
18
31
  return { revert, output: name };
19
32
  },
20
- } satisfies MutateDef<undefined | string, string>;
33
+ describe: (input) => {
34
+ if (input === undefined) {
35
+ return "Apply `Namespace` with auto-generated name";
36
+ }
37
+ if (typeof input === "string") {
38
+ return `Apply \`Namespace\` "${input}"`;
39
+ }
40
+ return `Apply \`Namespace\` with prefix "${input.generateName}"`;
41
+ },
42
+ } satisfies MutateDef<ApplyNamespaceInput, string>;
43
+
44
+ function resolveNamespaceName(input: ApplyNamespaceInput): string {
45
+ if (input === undefined) {
46
+ return `kest-${randomConsonantDigits(5)}`;
47
+ }
48
+ if (typeof input === "string") {
49
+ return input;
50
+ }
51
+ return `${input.generateName}${randomConsonantDigits(5)}`;
52
+ }
21
53
 
22
54
  function randomConsonantDigits(length = 8): string {
23
55
  const chars = "bcdfghjklmnpqrstvwxyz0123456789";
@@ -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>;
@@ -0,0 +1,38 @@
1
+ import type { K8sResource, K8sResourceReference } from "../apis";
2
+ import { toKubectlType } from "./kubectl-type";
3
+ import type { Deps, QueryDef } from "./types";
4
+
5
+ export const assertAbsence = {
6
+ type: "query",
7
+ name: "AssertAbsence",
8
+ query:
9
+ ({ kubectl }: Deps) =>
10
+ async <T extends K8sResource>(
11
+ resource: K8sResourceReference<T>
12
+ ): Promise<void> => {
13
+ try {
14
+ await kubectl.get(toKubectlType(resource), resource.name);
15
+ } catch (error) {
16
+ if (isNotFoundError(error)) {
17
+ return;
18
+ }
19
+ throw error;
20
+ }
21
+ throw new Error(
22
+ `Expected ${resource.kind} "${resource.name}" to be absent, but it exists`
23
+ );
24
+ },
25
+ describe: (resource) =>
26
+ `Assert that \`${resource.kind}\` "${resource.name}" is absent`,
27
+ } satisfies QueryDef<K8sResourceReference, void>;
28
+
29
+ /**
30
+ * Checks whether a kubectl error is a "NotFound" error.
31
+ *
32
+ * kubectl outputs `Error from server (NotFound):` when the resource does not
33
+ * exist, and the {@link RealKubectl} wrapper embeds that message in the
34
+ * thrown `Error`.
35
+ */
36
+ function isNotFoundError(error: unknown): boolean {
37
+ return error instanceof Error && error.message.includes("(NotFound)");
38
+ }
@@ -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>(
@@ -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>;
@@ -1,4 +1,5 @@
1
1
  import type { K8sResource, K8sResourceReference } from "../apis";
2
+ import { toKubectlType } from "./kubectl-type";
2
3
  import type { OneWayMutateDef } from "./types";
3
4
 
4
5
  export const deleteResource = {
@@ -10,16 +11,7 @@ export const deleteResource = {
10
11
  await kubectl.delete(toKubectlType(resource), resource.name);
11
12
  return undefined;
12
13
  },
14
+ describe: (resource) => {
15
+ return `Delete \`${resource.kind}\` "${resource.name}"`;
16
+ },
13
17
  } satisfies OneWayMutateDef<K8sResourceReference, void>;
14
-
15
- function toKubectlType<T extends K8sResource>(
16
- resource: K8sResourceReference<T>
17
- ): string {
18
- const { kind, apiVersion } = resource;
19
- const [group, version] = apiVersion.split("/");
20
- if (version === undefined) {
21
- // core group cannot include version in the type
22
- return kind;
23
- }
24
- return [kind, version, group].filter(Boolean).join(".");
25
- }
@@ -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>(
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Converts an `apiVersion` + `kind` pair into the resource type string
3
+ * expected by kubectl subcommands (e.g. `get`, `delete`, `label`).
4
+ *
5
+ * - Core-group resources (`apiVersion: "v1"`) → `"ConfigMap"`
6
+ * - Non-core resources (`apiVersion: "apps/v1"`) → `"Deployment.v1.apps"`
7
+ */
8
+ export function toKubectlType(resource: {
9
+ readonly apiVersion: string;
10
+ readonly kind: string;
11
+ }): string {
12
+ const { kind, apiVersion } = resource;
13
+ const [group, version] = apiVersion.split("/");
14
+ if (version === undefined) {
15
+ // core group cannot include version in the type
16
+ return kind;
17
+ }
18
+ return [kind, version, group].filter(Boolean).join(".");
19
+ }
@@ -0,0 +1,23 @@
1
+ import type { K8sResource, LabelInput } from "../apis";
2
+ import { toKubectlType } from "./kubectl-type";
3
+ import type { OneWayMutateDef } from "./types";
4
+
5
+ export const label = {
6
+ type: "oneWayMutate",
7
+ name: "Label",
8
+ mutate:
9
+ ({ kubectl }) =>
10
+ async <T extends K8sResource>(input: LabelInput<T>) => {
11
+ const overrideContext = input.namespace
12
+ ? { namespace: input.namespace }
13
+ : undefined;
14
+ await kubectl.label(toKubectlType(input), input.name, input.labels, {
15
+ overwrite: input.overwrite,
16
+ context: overrideContext,
17
+ });
18
+ return undefined;
19
+ },
20
+ describe: (input) => {
21
+ return `Label \`${input.kind}\` "${input.name}"`;
22
+ },
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> = (