@appthrust/kest 0.1.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.
@@ -0,0 +1,225 @@
1
+ /** biome-ignore-all lint/style/noDoneCallback: that's not the done callback */
2
+ import { expect } from "bun:test";
3
+ import { type K8sResource, test } from "@appthrust/kest";
4
+
5
+ interface ConfigMap extends K8sResource {
6
+ apiVersion: "v1";
7
+ kind: "ConfigMap";
8
+ metadata: {
9
+ name: string;
10
+ };
11
+ data: {
12
+ [key: string]: string;
13
+ };
14
+ }
15
+
16
+ test("Example: applies ConfigMap using YAML, file import, and object literal", async (s) => {
17
+ s.given("a new namespace exists");
18
+ // A random namespace is created
19
+ const ns = await s.newNamespace();
20
+
21
+ s.when("I apply ConfigMaps using different formats");
22
+ // Example of applying YAML
23
+ await ns.apply(`
24
+ apiVersion: v1
25
+ kind: ConfigMap
26
+ metadata:
27
+ name: my-config-1
28
+ data:
29
+ mode: demo-1
30
+ `);
31
+ // Example of applying from a file
32
+ await ns.apply(import("./config-map.yaml"));
33
+ // Example of applying with an object literal
34
+ await ns.apply<ConfigMap>({
35
+ apiVersion: "v1",
36
+ kind: "ConfigMap",
37
+ metadata: { name: "my-config-3" },
38
+ data: { mode: "demo-3" },
39
+ });
40
+
41
+ s.then("the ConfigMap should have the expected data");
42
+ await ns.assert<ConfigMap>({
43
+ apiVersion: "v1",
44
+ kind: "ConfigMap",
45
+ name: "my-config-1",
46
+ // Retries until the following condition is satisfied
47
+ test() {
48
+ expect(this).toMatchObject({
49
+ data: {
50
+ mode: "demo-1",
51
+ },
52
+ });
53
+ },
54
+ });
55
+
56
+ // Resources created during the test are deleted in reverse order of creation.
57
+ // 1. ConfigMap/my-config-3
58
+ // 2. ConfigMap/my-config-2
59
+ // 3. ConfigMap/my-config-1
60
+ // 4. Namespace
61
+ });
62
+
63
+ test("Example: asserts a non-existent ConfigMap (expected to fail)", async (s) => {
64
+ s.given("a new namespace exists");
65
+ const ns = await s.newNamespace();
66
+
67
+ s.then("asserting a non-existent ConfigMap should fail");
68
+ // This will fail because no ConfigMap named "non-existent-config" has been created
69
+ await ns.assert<ConfigMap>({
70
+ apiVersion: "v1",
71
+ kind: "ConfigMap",
72
+ name: "non-existent-config",
73
+ test() {
74
+ expect(this).toMatchObject({
75
+ data: {
76
+ mode: "should-not-exist",
77
+ },
78
+ });
79
+ },
80
+ });
81
+ });
82
+
83
+ test("Example: manages resources across multiple clusters", async (s) => {
84
+ s.given("two clusters are configured");
85
+ // Use cluster 1
86
+ const cluster1 = await s.useCluster({
87
+ kubeconfig: ".kubeconfig.yaml", // optional
88
+ context: "kind-kest-test-cluster-1", // optional
89
+ });
90
+ // Use cluster 2
91
+ const cluster2 = await s.useCluster({
92
+ context: "kind-kest-test-cluster-2",
93
+ });
94
+
95
+ s.when("I apply ConfigMaps to each cluster");
96
+ // Apply ConfigMap using cluster 1
97
+ await cluster1.apply<ConfigMap>({
98
+ apiVersion: "v1",
99
+ kind: "ConfigMap",
100
+ metadata: { name: "my-config-1" },
101
+ data: { mode: "demo-1" },
102
+ });
103
+ // Apply ConfigMap using cluster 2
104
+ await cluster2.apply<ConfigMap>({
105
+ apiVersion: "v1",
106
+ kind: "ConfigMap",
107
+ metadata: { name: "my-config-2" },
108
+ data: { mode: "demo-2" },
109
+ });
110
+
111
+ s.then("each cluster should have its ConfigMap");
112
+ // Verify ConfigMap using cluster 1
113
+ await cluster1.assert<ConfigMap>({
114
+ apiVersion: "v1",
115
+ kind: "ConfigMap",
116
+ name: "my-config-1",
117
+ test() {
118
+ expect(this.data["mode"]).toBe("demo-1");
119
+ },
120
+ });
121
+ // Verify ConfigMap using cluster 2
122
+ await cluster2.assert<ConfigMap>({
123
+ apiVersion: "v1",
124
+ kind: "ConfigMap",
125
+ name: "my-config-2",
126
+ test() {
127
+ expect(this.data["mode"]).toBe("demo-2");
128
+ },
129
+ });
130
+ });
131
+
132
+ test("Example: executes shell commands with revert cleanup", async (s) => {
133
+ const name = await s.exec({
134
+ do: async ({ $ }) => {
135
+ const name = "my-secret";
136
+ await $`kubectl create secret generic ${name} --from-literal=username=admin --from-literal=password=123456`.quiet();
137
+ return name;
138
+ },
139
+ // revert is optional. If specified, it runs during revert. Useful for cleaning up resources created by do.
140
+ revert: async ({ $ }) => {
141
+ await $`kubectl delete secret my-secret`.quiet();
142
+ },
143
+ });
144
+ expect(name).toBe("my-secret");
145
+ });
146
+
147
+ test("Example: asserts resource presence and absence in a list", async (s) => {
148
+ s.given("a new namespace exists");
149
+ const ns = await s.newNamespace();
150
+
151
+ s.when("I apply a single ConfigMap");
152
+ await ns.apply<ConfigMap>({
153
+ apiVersion: "v1",
154
+ kind: "ConfigMap",
155
+ metadata: { name: "my-config-1" },
156
+ data: { mode: "demo-1" },
157
+ });
158
+
159
+ s.then("the list should contain only the applied ConfigMap");
160
+ await ns.assertList<ConfigMap>({
161
+ apiVersion: "v1",
162
+ kind: "ConfigMap",
163
+ test() {
164
+ // Verify existence
165
+ expect(this.some((c) => c.metadata.name === "my-config-1")).toBe(true);
166
+ // Verify non-existence
167
+ expect(this.some((c) => c.metadata.name === "my-config-2")).toBe(false);
168
+ },
169
+ });
170
+ });
171
+
172
+ test("Example: applies status subresource to custom resource", async (s) => {
173
+ s.given("a HelloWorld custom resource definition exists");
174
+ await s.apply(import("./hello-world-crd.yaml"));
175
+
176
+ s.given("a new namespace exists");
177
+ const ns = await s.newNamespace();
178
+
179
+ s.given("a HelloWorld custom resource is created");
180
+ await ns.apply({
181
+ apiVersion: "example.com/v2",
182
+ kind: "HelloWorld",
183
+ metadata: { name: "my-hello-world" },
184
+ });
185
+
186
+ s.when("I apply a status with Ready condition");
187
+ await ns.applyStatus({
188
+ apiVersion: "example.com/v2",
189
+ kind: "HelloWorld",
190
+ metadata: { name: "my-hello-world" },
191
+ status: {
192
+ conditions: [
193
+ {
194
+ type: "Ready",
195
+ status: "True",
196
+ lastTransitionTime: "2026-02-05T00:00:00Z",
197
+ reason: "ManuallySet",
198
+ message: "Ready condition set to True via server-side apply.",
199
+ },
200
+ ],
201
+ },
202
+ });
203
+
204
+ s.then("the HelloWorld should have the Ready status");
205
+ await ns.assert({
206
+ apiVersion: "example.com/v2",
207
+ kind: "HelloWorld",
208
+ name: "my-hello-world",
209
+ test() {
210
+ expect(this).toMatchObject({
211
+ status: {
212
+ conditions: [
213
+ {
214
+ type: "Ready",
215
+ status: "True",
216
+ lastTransitionTime: "2026-02-05T00:00:00Z",
217
+ reason: "ManuallySet",
218
+ message: "Ready condition set to True via server-side apply.",
219
+ },
220
+ ],
221
+ },
222
+ });
223
+ },
224
+ });
225
+ });
@@ -0,0 +1,145 @@
1
+ apiVersion: apiextensions.k8s.io/v1
2
+ kind: CustomResourceDefinition
3
+ metadata:
4
+ name: helloworlds.example.com
5
+ spec:
6
+ group: example.com
7
+ names:
8
+ kind: HelloWorld
9
+ listKind: HelloWorldList
10
+ plural: helloworlds
11
+ singular: helloworld
12
+ scope: Namespaced
13
+ versions:
14
+ - name: v1
15
+ served: true
16
+ storage: true
17
+ subresources:
18
+ status: {}
19
+ schema:
20
+ openAPIV3Schema:
21
+ type: object
22
+ properties:
23
+ apiVersion:
24
+ type: string
25
+ kind:
26
+ type: string
27
+ metadata:
28
+ type: object
29
+ status:
30
+ description: HelloWorldStatus defines the observed state of HelloWorld.
31
+ type: object
32
+ properties:
33
+ conditions:
34
+ description: Conditions represent the latest available observations of an object's state.
35
+ type: array
36
+ x-kubernetes-list-type: map
37
+ x-kubernetes-list-map-keys:
38
+ - type
39
+ items:
40
+ description: Condition contains details for one aspect of the current state of this API Resource.
41
+ type: object
42
+ required:
43
+ - type
44
+ - status
45
+ - lastTransitionTime
46
+ - reason
47
+ - message
48
+ properties:
49
+ type:
50
+ description: Type of condition in CamelCase or in foo.example.com/CamelCase.
51
+ type: string
52
+ maxLength: 316
53
+ pattern: ^[A-Za-z0-9]([A-Za-z0-9_.-]*[A-Za-z0-9])?$
54
+ status:
55
+ description: Status of the condition, one of True, False, Unknown.
56
+ type: string
57
+ enum:
58
+ - "True"
59
+ - "False"
60
+ - "Unknown"
61
+ observedGeneration:
62
+ description: observedGeneration represents the .metadata.generation that the condition was set based upon.
63
+ type: integer
64
+ format: int64
65
+ minimum: 0
66
+ lastTransitionTime:
67
+ description: lastTransitionTime is the last time the condition transitioned from one status to another.
68
+ type: string
69
+ format: date-time
70
+ reason:
71
+ description: reason contains a programmatic identifier indicating the reason for the condition's last transition.
72
+ type: string
73
+ minLength: 1
74
+ maxLength: 1024
75
+ pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
76
+ message:
77
+ description: message is a human readable message indicating details about the transition.
78
+ type: string
79
+ maxLength: 32768
80
+ - name: v2
81
+ served: true
82
+ storage: false
83
+ subresources:
84
+ status: {}
85
+ schema:
86
+ openAPIV3Schema:
87
+ type: object
88
+ properties:
89
+ apiVersion:
90
+ type: string
91
+ kind:
92
+ type: string
93
+ metadata:
94
+ type: object
95
+ status:
96
+ description: HelloWorldStatus defines the observed state of HelloWorld.
97
+ type: object
98
+ properties:
99
+ conditions:
100
+ description: Conditions represent the latest available observations of an object's state.
101
+ type: array
102
+ x-kubernetes-list-type: map
103
+ x-kubernetes-list-map-keys:
104
+ - type
105
+ items:
106
+ description: Condition contains details for one aspect of the current state of this API Resource.
107
+ type: object
108
+ required:
109
+ - type
110
+ - status
111
+ - lastTransitionTime
112
+ - reason
113
+ - message
114
+ properties:
115
+ type:
116
+ description: Type of condition in CamelCase or in foo.example.com/CamelCase.
117
+ type: string
118
+ maxLength: 316
119
+ pattern: ^[A-Za-z0-9]([A-Za-z0-9_.-]*[A-Za-z0-9])?$
120
+ status:
121
+ description: Status of the condition, one of True, False, Unknown.
122
+ type: string
123
+ enum:
124
+ - "True"
125
+ - "False"
126
+ - "Unknown"
127
+ observedGeneration:
128
+ description: observedGeneration represents the .metadata.generation that the condition was set based upon.
129
+ type: integer
130
+ format: int64
131
+ minimum: 0
132
+ lastTransitionTime:
133
+ description: lastTransitionTime is the last time the condition transitioned from one status to another.
134
+ type: string
135
+ format: date-time
136
+ reason:
137
+ description: reason contains a programmatic identifier indicating the reason for the condition's last transition.
138
+ type: string
139
+ minLength: 1
140
+ maxLength: 1024
141
+ pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
142
+ message:
143
+ description: message is a human readable message indicating details about the transition.
144
+ type: string
145
+ maxLength: 32768
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@appthrust/kest",
3
+ "version": "0.1.0",
4
+ "description": "TypeScript-first E2E testing framework for Kubernetes controllers, operators, and webhooks",
5
+ "type": "module",
6
+ "module": "ts/index.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./ts/index.ts",
10
+ "default": "./ts/index.ts"
11
+ }
12
+ },
13
+ "files": [
14
+ "ts/**/*.ts",
15
+ "!ts/**/*.test.ts",
16
+ "example/",
17
+ "LICENSE",
18
+ "README.md"
19
+ ],
20
+ "keywords": [
21
+ "kubernetes",
22
+ "k8s",
23
+ "e2e",
24
+ "testing",
25
+ "bun",
26
+ "kubectl",
27
+ "operator",
28
+ "controller",
29
+ "webhook",
30
+ "typescript"
31
+ ],
32
+ "author": "appthrust",
33
+ "license": "MIT",
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "git+https://github.com/appthrust/kest.git"
37
+ },
38
+ "homepage": "https://github.com/appthrust/kest#readme",
39
+ "bugs": {
40
+ "url": "https://github.com/appthrust/kest/issues"
41
+ },
42
+ "engines": {
43
+ "bun": ">=1.3.8"
44
+ },
45
+ "peerDependencies": {
46
+ "typescript": "^5"
47
+ },
48
+ "dependencies": {
49
+ "@shikijs/cli": "^3.21.0",
50
+ "@suin/shell-escape-arg": "^0.1.2"
51
+ },
52
+ "devDependencies": {
53
+ "@biomejs/biome": "^2.3.13",
54
+ "@suin/biome.json": "^0.1.0",
55
+ "@tsconfig/bun": "^1.0.10",
56
+ "@tsconfig/strictest": "^2.0.8",
57
+ "@types/bun": "^1.3.7",
58
+ "bun": "^1.3.8",
59
+ "outdent": "^0.8.0",
60
+ "oxfmt": "^0.28.0"
61
+ }
62
+ }
@@ -0,0 +1,29 @@
1
+ import { apply } from "./apply";
2
+ import type { MutateDef } from "./types";
3
+
4
+ export const applyNamespace = {
5
+ type: "mutate",
6
+ name: "ApplyNamespace",
7
+ mutate:
8
+ ({ kubectl }) =>
9
+ async (namespaceName) => {
10
+ const name = namespaceName ?? `kest-${randomConsonantDigits(5)}`;
11
+ const { revert } = await apply.mutate({ kubectl })({
12
+ apiVersion: "v1",
13
+ kind: "Namespace",
14
+ metadata: {
15
+ name,
16
+ },
17
+ });
18
+ return { revert, output: name };
19
+ },
20
+ } satisfies MutateDef<undefined | string, string>;
21
+
22
+ function randomConsonantDigits(length = 8): string {
23
+ const chars = "bcdfghjklmnpqrstvwxyz0123456789";
24
+ let result = "";
25
+ for (let i = 0; i < length; i++) {
26
+ result += chars[Math.floor(Math.random() * chars.length)];
27
+ }
28
+ return result;
29
+ }
@@ -0,0 +1,23 @@
1
+ import type { ApplyingManifest } from "../apis";
2
+ import { parseK8sResourceAny } from "../k8s-resource";
3
+ import type { OneWayMutateDef } from "./types";
4
+
5
+ export const applyStatus = {
6
+ type: "oneWayMutate",
7
+ name: "ApplyStatus",
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
+ if (result.value["status"] === undefined) {
18
+ throw new Error("Invalid Kubernetes resource: status is required");
19
+ }
20
+ await kubectl.applyStatus(result.value);
21
+ return undefined;
22
+ },
23
+ } satisfies OneWayMutateDef<ApplyingManifest, void>;
@@ -0,0 +1,25 @@
1
+ import type { ApplyingManifest } from "../apis";
2
+ import { parseK8sResourceAny } from "../k8s-resource";
3
+ import type { MutateDef } from "./types";
4
+
5
+ export const apply = {
6
+ type: "mutate",
7
+ name: "Apply",
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.apply(result.value);
18
+ return {
19
+ async revert() {
20
+ await kubectl.delete(result.value.kind, result.value.metadata.name);
21
+ },
22
+ output: undefined,
23
+ };
24
+ },
25
+ } satisfies MutateDef<ApplyingManifest, void>;
@@ -0,0 +1,63 @@
1
+ import { YAML } from "bun";
2
+ import type { K8sResource, ResourceListTest } from "../apis";
3
+ import { parseK8sResourceListYaml } from "../k8s-resource";
4
+ import type { Deps, QueryDef } from "./types";
5
+
6
+ export const assertList = {
7
+ type: "query",
8
+ name: "AssertList",
9
+ query:
10
+ ({ kubectl }: Deps) =>
11
+ async <T extends K8sResource>(
12
+ condition: ResourceListTest<T>
13
+ ): Promise<Array<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
+ await condition.test.call(typed, typed);
29
+ return typed;
30
+ },
31
+ } satisfies QueryDef<ResourceListTest, Array<K8sResource>>;
32
+
33
+ function isSameGVK<T extends K8sResource>(
34
+ finding: Pick<ResourceListTest<T>, "apiVersion" | "kind">,
35
+ fetched: K8sResource
36
+ ): fetched is T {
37
+ return (
38
+ finding.apiVersion === fetched.apiVersion && finding.kind === fetched.kind
39
+ );
40
+ }
41
+
42
+ function assertSameGVK<T extends K8sResource>(
43
+ finding: Pick<ResourceListTest<T>, "apiVersion" | "kind">,
44
+ fetched: K8sResource
45
+ ): void {
46
+ if (!isSameGVK(finding, fetched)) {
47
+ throw new Error(
48
+ `Fetched Kubernetes resource: ${YAML.stringify(fetched)} is not expected: ${YAML.stringify(finding)}`
49
+ );
50
+ }
51
+ }
52
+
53
+ function toKubectlType<T extends K8sResource>(
54
+ condition: Pick<ResourceListTest<T>, "apiVersion" | "kind">
55
+ ): string {
56
+ const { kind, apiVersion } = condition;
57
+ const [group, version] = apiVersion.split("/");
58
+ if (version === undefined) {
59
+ // core group cannot include version in the type
60
+ return kind;
61
+ }
62
+ return [kind, version, group].filter(Boolean).join(".");
63
+ }
@@ -0,0 +1,34 @@
1
+ import type { K8sResource, ResourceTest } from "../apis";
2
+ import { parseK8sResourceYaml } from "../k8s-resource";
3
+ import type { Deps, QueryDef } from "./types";
4
+
5
+ export const assert = {
6
+ type: "query",
7
+ name: "Assert",
8
+ query:
9
+ ({ kubectl }: Deps) =>
10
+ async <T extends K8sResource>(condition: ResourceTest<T>): Promise<T> => {
11
+ const yaml = await kubectl.get(toKubectlType(condition), condition.name);
12
+ const result = parseK8sResourceYaml(yaml);
13
+ if (!result.ok) {
14
+ throw new Error(
15
+ `Invalid Kubernetes resource: ${result.violations.join(", ")}`
16
+ );
17
+ }
18
+ const fetched = result.value as T;
19
+ await condition.test.call(fetched, fetched);
20
+ return fetched;
21
+ },
22
+ } satisfies QueryDef<ResourceTest, K8sResource>;
23
+
24
+ function toKubectlType<T extends K8sResource>(
25
+ condition: ResourceTest<T>
26
+ ): string {
27
+ const { kind, apiVersion } = condition;
28
+ const [group, version] = apiVersion.split("/");
29
+ if (version === undefined) {
30
+ // core group cannot include version in the type
31
+ return kind;
32
+ }
33
+ return [kind, version, group].filter(Boolean).join(".");
34
+ }
@@ -0,0 +1,21 @@
1
+ import { $ as bunShell } from "bun";
2
+ import type { ExecContext, ExecInput } from "../apis";
3
+ import type { MutateDef } from "./types";
4
+
5
+ const noopRevert = (): Promise<void> => Promise.resolve();
6
+
7
+ /** Executes arbitrary processing and registers optional revert. */
8
+ export const exec = {
9
+ type: "mutate",
10
+ name: "Exec",
11
+ mutate: () => async (input) => {
12
+ const context: ExecContext = { $: bunShell };
13
+ const output = await input.do(context);
14
+ const revert = input.revert;
15
+ return {
16
+ output,
17
+ revert: revert ? () => revert(context) : noopRevert,
18
+ };
19
+ },
20
+ // biome-ignore lint/suspicious/noExplicitAny: 本当はunknownにしたいが、createMutateFnとの噛み合せが難しいためanyにしている
21
+ } satisfies MutateDef<ExecInput<any>, unknown>;
@@ -0,0 +1,40 @@
1
+ import { YAML } from "bun";
2
+ import type { K8sResource, K8sResourceReference } from "../apis";
3
+ import { assert } from "./assert";
4
+ import type { QueryDef } from "./types";
5
+
6
+ export const get = {
7
+ type: "query",
8
+ name: "Get",
9
+ query:
10
+ ({ kubectl }) =>
11
+ async <T extends K8sResource>(
12
+ finding: K8sResourceReference<T>
13
+ ): Promise<T> =>
14
+ assert.query({ kubectl })({
15
+ ...finding,
16
+ test: (fetched) => assertSameGVK<T>(finding, fetched),
17
+ }),
18
+ } satisfies QueryDef<K8sResourceReference, K8sResource>;
19
+
20
+ function isSameGVK<T extends K8sResource>(
21
+ finding: K8sResourceReference<T>,
22
+ fetched: K8sResource
23
+ ): fetched is T {
24
+ return (
25
+ finding.apiVersion === fetched.apiVersion &&
26
+ finding.kind === fetched.kind &&
27
+ finding.name === fetched.metadata.name
28
+ );
29
+ }
30
+
31
+ function assertSameGVK<T extends K8sResource>(
32
+ finding: K8sResourceReference<T>,
33
+ fetched: K8sResource
34
+ ): void {
35
+ if (!isSameGVK<T>(finding, fetched)) {
36
+ throw new Error(
37
+ `Fetched Kubernetes resource: ${YAML.stringify(fetched)} is not expected: ${YAML.stringify(finding)}`
38
+ );
39
+ }
40
+ }