@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.
package/ts/retry.ts ADDED
@@ -0,0 +1,112 @@
1
+ import { Duration, parseDuration } from "./duration";
2
+ import type { Recorder } from "./recording";
3
+
4
+ export interface RetryOptions {
5
+ /**
6
+ * Timeout duration (e.g. "5s", "200ms").
7
+ *
8
+ * @default "5s"
9
+ */
10
+ timeout?: undefined | string | Duration;
11
+ /**
12
+ * Polling interval duration (e.g. "200ms").
13
+ *
14
+ * @default "200ms"
15
+ */
16
+ interval?: undefined | string | Duration;
17
+
18
+ /**
19
+ * Optional recorder for retry events.
20
+ */
21
+ recorder?: undefined | Recorder;
22
+ }
23
+
24
+ function toDuration(
25
+ value: undefined | string | Duration,
26
+ fallback: Duration
27
+ ): Duration {
28
+ if (value === undefined) {
29
+ return fallback;
30
+ }
31
+ if (value instanceof Duration) {
32
+ return value;
33
+ }
34
+ return parseDuration(value);
35
+ }
36
+
37
+ /**
38
+ * Retries `fn` until it resolves, or times out.
39
+ *
40
+ * Useful for eventually-consistent systems (e.g. Kubernetes).
41
+ */
42
+ export async function retryUntil<T>(
43
+ fn: () => Promise<T>,
44
+ options?: undefined | RetryOptions
45
+ ): Promise<T> {
46
+ const timeout = toDuration(options?.timeout, new Duration(5000));
47
+ const interval = toDuration(options?.interval, new Duration(200));
48
+
49
+ const timeoutMs = timeout.toMilliseconds();
50
+ const intervalMs = interval.toMilliseconds();
51
+
52
+ const startedAtMs = Date.now();
53
+ const deadline = startedAtMs + timeoutMs;
54
+
55
+ let lastError: unknown;
56
+ // The first call is not a "retry". Retry events start from the second call.
57
+ try {
58
+ return await fn();
59
+ } catch (err) {
60
+ lastError = err;
61
+ }
62
+
63
+ let retries = 0;
64
+ const { recorder } = options ?? {};
65
+ recorder?.record("RetryStart", {});
66
+
67
+ while (Date.now() < deadline) {
68
+ const nowMs = Date.now();
69
+ const remainingMs = Math.max(0, deadline - nowMs);
70
+ const sleepMs = Math.min(intervalMs, Math.max(0, remainingMs));
71
+ if (sleepMs > 0) {
72
+ await Bun.sleep(sleepMs);
73
+ }
74
+
75
+ if (Date.now() >= deadline) {
76
+ break;
77
+ }
78
+
79
+ retries += 1;
80
+ recorder?.record("RetryAttempt", { attempt: retries });
81
+
82
+ try {
83
+ const value = await fn();
84
+ recorder?.record("RetryEnd", {
85
+ attempts: retries,
86
+ success: true,
87
+ reason: "success",
88
+ });
89
+ return value;
90
+ } catch (err) {
91
+ lastError = err;
92
+ const error = err as Error;
93
+ recorder?.record("RetryFailure", {
94
+ attempt: retries,
95
+ error: { name: error.name, message: error.message },
96
+ });
97
+ }
98
+ }
99
+
100
+ lastError = lastError ?? new Error(`Timed out after ${timeout.toString()}`);
101
+
102
+ if (retries > 0) {
103
+ recorder?.record("RetryEnd", {
104
+ attempts: retries,
105
+ error: lastError as Error,
106
+ success: false,
107
+ reason: "timeout",
108
+ });
109
+ }
110
+
111
+ throw lastError;
112
+ }
@@ -0,0 +1,36 @@
1
+ import type { Revert } from "../actions/types";
2
+ import type { Recorder } from "../recording";
3
+
4
+ export function createReverting(deps: Deps) {
5
+ const { recorder } = deps;
6
+ const revertFns: Array<Revert> = [];
7
+ return {
8
+ add(revert: Revert): void {
9
+ revertFns.push(revert);
10
+ },
11
+ async revert(): Promise<void> {
12
+ recorder.record("RevertingsStart", {});
13
+ let revertFn: undefined | Revert;
14
+ try {
15
+ for (;;) {
16
+ revertFn = revertFns.pop();
17
+ if (!revertFn) {
18
+ break;
19
+ }
20
+ await revertFn();
21
+ }
22
+ } finally {
23
+ if (revertFn) {
24
+ revertFns.push(revertFn);
25
+ }
26
+ recorder.record("RevertingsEnd", {});
27
+ }
28
+ },
29
+ };
30
+ }
31
+
32
+ export type Reverting = ReturnType<typeof createReverting>;
33
+
34
+ interface Deps {
35
+ readonly recorder: Recorder;
36
+ }
@@ -0,0 +1,220 @@
1
+ import { apply } from "../actions/apply";
2
+ import { applyNamespace } from "../actions/apply-namespace";
3
+ import { applyStatus } from "../actions/apply-status";
4
+ import { assert } from "../actions/assert";
5
+ import { assertList } from "../actions/assert-list";
6
+ import { exec } from "../actions/exec";
7
+ import { get } from "../actions/get";
8
+ import type { MutateDef, OneWayMutateDef, QueryDef } from "../actions/types";
9
+ import type {
10
+ ActionOptions,
11
+ Cluster,
12
+ ClusterReference,
13
+ Namespace,
14
+ Scenario,
15
+ } from "../apis";
16
+ import bdd from "../bdd";
17
+ import type { Kubectl } from "../kubectl";
18
+ import type { Recorder } from "../recording";
19
+ import type { Reporter } from "../reporter/interface";
20
+ import { retryUntil } from "../retry";
21
+ import type { Reverting } from "../reverting";
22
+
23
+ export interface InternalScenario extends Scenario {
24
+ cleanup(): Promise<void>;
25
+ getReport(): Promise<string>;
26
+ }
27
+
28
+ export function createScenario(deps: CreateScenarioOptions): InternalScenario {
29
+ const { recorder, reporter, reverting } = deps;
30
+ recorder.record("ScenarioStarted", { name: deps.name });
31
+ return {
32
+ apply: createMutateFn(deps, apply),
33
+ applyStatus: createOneWayMutateFn(deps, applyStatus),
34
+ exec: createMutateFn(deps, exec),
35
+ get: createQueryFn(deps, get),
36
+ assert: createQueryFn(deps, assert),
37
+ assertList: createQueryFn(deps, assertList),
38
+ given: bdd.given(deps),
39
+ when: bdd.when(deps),
40
+ // biome-ignore lint/suspicious/noThenProperty: BDD DSL uses `then()` method name
41
+ then: bdd.then(deps),
42
+ and: bdd.and(deps),
43
+ but: bdd.but(deps),
44
+ newNamespace: createNewNamespaceFn(deps),
45
+ useCluster: createUseClusterFn(deps),
46
+ async cleanup() {
47
+ await reverting.revert();
48
+ },
49
+ async getReport() {
50
+ return await reporter.report(recorder.getEvents());
51
+ },
52
+ };
53
+ }
54
+
55
+ interface CreateScenarioOptions {
56
+ readonly name: string;
57
+ readonly recorder: Recorder;
58
+ readonly kubectl: Kubectl;
59
+ readonly reverting: Reverting;
60
+ readonly reporter: Reporter;
61
+ }
62
+
63
+ const createMutateFn =
64
+ <
65
+ const Action extends MutateDef<Input, Output>,
66
+ Input = Action extends MutateDef<infer I, infer _> ? I : never,
67
+ Output = Action extends MutateDef<infer _, infer O> ? O : never,
68
+ >(
69
+ deps: CreateScenarioOptions,
70
+ action: Action
71
+ ) =>
72
+ async (
73
+ input: Input,
74
+ options?: undefined | ActionOptions
75
+ ): Promise<Output> => {
76
+ const { recorder, kubectl, reverting } = deps;
77
+ const { type, name, mutate } = action;
78
+ recorder.record("ActionStart", { action: name, phase: type });
79
+ const fn = mutate({ kubectl });
80
+ let mutateErr: unknown;
81
+ try {
82
+ const { revert, output } = await retryUntil(() => fn(input), {
83
+ ...options,
84
+ recorder,
85
+ });
86
+ reverting.add(async () => {
87
+ recorder.record("ActionStart", { action: name, phase: "revert" });
88
+ let revertErr: unknown;
89
+ try {
90
+ await revert();
91
+ } catch (err) {
92
+ revertErr = err;
93
+ throw err;
94
+ } finally {
95
+ recorder.record("ActionEnd", {
96
+ action: name,
97
+ phase: "revert",
98
+ ok: revertErr === undefined,
99
+ error: revertErr as Error,
100
+ });
101
+ }
102
+ });
103
+ return output;
104
+ } catch (error) {
105
+ mutateErr = error;
106
+ throw error;
107
+ } finally {
108
+ recorder.record("ActionEnd", {
109
+ action: name,
110
+ phase: type,
111
+ ok: mutateErr === undefined,
112
+ error: mutateErr as Error,
113
+ });
114
+ }
115
+ };
116
+
117
+ const createOneWayMutateFn =
118
+ <
119
+ const Action extends OneWayMutateDef<Input, Output>,
120
+ Input = Action extends OneWayMutateDef<infer I, infer _> ? I : never,
121
+ Output = Action extends OneWayMutateDef<infer _, infer O> ? O : never,
122
+ >(
123
+ deps: CreateScenarioOptions,
124
+ action: Action
125
+ ) =>
126
+ async (
127
+ input: Input,
128
+ options?: undefined | ActionOptions
129
+ ): Promise<Output> => {
130
+ const { recorder, kubectl } = deps;
131
+ const { name, mutate } = action;
132
+ recorder.record("ActionStart", { action: name, phase: "mutate" });
133
+ const fn = mutate({ kubectl });
134
+ let mutateErr: unknown;
135
+ try {
136
+ return await retryUntil(() => fn(input), { ...options, recorder });
137
+ } catch (error) {
138
+ mutateErr = error;
139
+ throw error;
140
+ } finally {
141
+ recorder.record("ActionEnd", {
142
+ action: name,
143
+ phase: "mutate",
144
+ ok: mutateErr === undefined,
145
+ error: mutateErr as Error,
146
+ });
147
+ }
148
+ };
149
+
150
+ const createQueryFn =
151
+ <
152
+ const Action extends QueryDef<Input, Output>,
153
+ Input = Action extends QueryDef<infer I, infer _> ? I : never,
154
+ Output = Action extends QueryDef<infer _, infer O> ? O : never,
155
+ >(
156
+ deps: CreateScenarioOptions,
157
+ action: Action
158
+ ) =>
159
+ async (
160
+ input: Input,
161
+ options?: undefined | ActionOptions
162
+ ): Promise<Output> => {
163
+ const { recorder, kubectl } = deps;
164
+ const { type, name, query } = action;
165
+ recorder.record("ActionStart", { action: name, phase: type });
166
+ const fn = query({ kubectl });
167
+ try {
168
+ return await retryUntil(() => fn(input), { ...options, recorder });
169
+ } catch (error) {
170
+ recorder.record("ActionEnd", {
171
+ action: name,
172
+ phase: type,
173
+ ok: false,
174
+ error: error as Error,
175
+ });
176
+ throw error;
177
+ }
178
+ };
179
+
180
+ const createNewNamespaceFn =
181
+ (scenarioDeps: CreateScenarioOptions) =>
182
+ async (
183
+ name?: undefined | string,
184
+ options?: undefined | ActionOptions
185
+ ): Promise<Namespace> => {
186
+ const namespaceName = await createMutateFn(scenarioDeps, applyNamespace)(
187
+ name,
188
+ options
189
+ );
190
+ const { kubectl } = scenarioDeps;
191
+ const namespacedKubectl = kubectl.extends({ namespace: namespaceName });
192
+ const namespacedDeps = { ...scenarioDeps, kubectl: namespacedKubectl };
193
+ return {
194
+ apply: createMutateFn(namespacedDeps, apply),
195
+ applyStatus: createOneWayMutateFn(namespacedDeps, applyStatus),
196
+ get: createQueryFn(namespacedDeps, get),
197
+ assert: createQueryFn(namespacedDeps, assert),
198
+ assertList: createQueryFn(namespacedDeps, assertList),
199
+ };
200
+ };
201
+
202
+ const createUseClusterFn =
203
+ (scenarioDeps: CreateScenarioOptions) =>
204
+ // biome-ignore lint/suspicious/useAwait: 将来的にクラスターの接続確認などを行うため、今から async を使用する
205
+ async (cluster: ClusterReference): Promise<Cluster> => {
206
+ const { kubectl } = scenarioDeps;
207
+ const clusterKubectl = kubectl.extends({
208
+ context: cluster.context,
209
+ kubeconfig: cluster.kubeconfig,
210
+ });
211
+ const clusterDeps = { ...scenarioDeps, kubectl: clusterKubectl };
212
+ return {
213
+ apply: createMutateFn(clusterDeps, apply),
214
+ applyStatus: createOneWayMutateFn(clusterDeps, applyStatus),
215
+ get: createQueryFn(clusterDeps, get),
216
+ assert: createQueryFn(clusterDeps, assert),
217
+ assertList: createQueryFn(clusterDeps, assertList),
218
+ newNamespace: createNewNamespaceFn(clusterDeps),
219
+ };
220
+ };
package/ts/test.ts ADDED
@@ -0,0 +1,127 @@
1
+ import {
2
+ type TestOptions as BunTestOptions,
3
+ test as bunTest,
4
+ setDefaultTimeout,
5
+ } from "bun:test";
6
+ import { YAML } from "bun";
7
+ import type { Scenario } from "./apis";
8
+ import { parseDuration } from "./duration";
9
+ import { createKubectl } from "./kubectl";
10
+ import { Recorder } from "./recording";
11
+ import { newMarkdownReporter } from "./reporter/markdown";
12
+ import { createReverting } from "./reverting";
13
+ import { createScenario, type InternalScenario } from "./scenario";
14
+ import { getWorkspaceRoot } from "./workspace";
15
+
16
+ interface TestOptions {
17
+ timeout?: string;
18
+ }
19
+
20
+ const defaultTimeout = 60_000;
21
+ setDefaultTimeout(defaultTimeout);
22
+
23
+ type Callback = (scenario: Scenario) => Promise<unknown>;
24
+
25
+ type TestFunction = (
26
+ label: string,
27
+ fn: Callback,
28
+ options?: TestOptions
29
+ ) => void;
30
+
31
+ type Test = TestFunction & {
32
+ only: TestFunction;
33
+ skip: TestFunction;
34
+ todo: TestFunction;
35
+ };
36
+
37
+ type BunTestRunner = (
38
+ label: string,
39
+ fn: () => Promise<unknown> | undefined,
40
+ options?: number | BunTestOptions
41
+ ) => unknown;
42
+
43
+ const workspaceRoot = await getWorkspaceRoot();
44
+ const showReport = process.env["KEST_SHOW_REPORT"] === "1";
45
+ const showEvents = process.env["KEST_SHOW_EVENTS"] === "1";
46
+
47
+ function makeScenarioTest(runner: BunTestRunner): TestFunction {
48
+ return (label, fn, options) => {
49
+ const bunTestOptions = convertTestOptions(options);
50
+ const testFn = async () => {
51
+ const recorder = new Recorder();
52
+ const kubectl = createKubectl({ recorder, cwd: workspaceRoot });
53
+ const reverting = createReverting({ recorder });
54
+ const reporter = newMarkdownReporter({ enableANSI: true });
55
+ const scenario = createScenario({
56
+ name: label,
57
+ recorder,
58
+ kubectl,
59
+ reverting,
60
+ reporter,
61
+ });
62
+
63
+ let testErr: undefined | Error;
64
+ try {
65
+ await fn(scenario);
66
+ } catch (error) {
67
+ testErr = error as Error;
68
+ }
69
+ await scenario.cleanup();
70
+ await report(recorder, scenario, testErr);
71
+ if (testErr) {
72
+ throw testErr;
73
+ }
74
+ };
75
+ const report = async (
76
+ recorder: Recorder,
77
+ scenario: InternalScenario,
78
+ testErr: undefined | Error
79
+ ) => {
80
+ const report: undefined | string =
81
+ testErr || showReport ? await scenario.getReport() : undefined;
82
+ if (report) {
83
+ console.log(report);
84
+ }
85
+ if (showEvents) {
86
+ console.log("---- debug events ----");
87
+ console.log(YAML.stringify(recorder.getEvents(), null, 2));
88
+ }
89
+ };
90
+ return runner(label, testFn, bunTestOptions);
91
+ };
92
+ }
93
+
94
+ function convertTestOptions(
95
+ options?: undefined | TestOptions
96
+ ): undefined | BunTestOptions {
97
+ const timeout = options?.timeout
98
+ ? parseDuration(options.timeout).toMilliseconds()
99
+ : defaultTimeout;
100
+ return {
101
+ ...options,
102
+ timeout,
103
+ };
104
+ }
105
+
106
+ const test: TestFunction = makeScenarioTest(bunTest);
107
+ Object.defineProperties(test, {
108
+ // `only` must be a getter (not a value) because Bun throws an error when
109
+ // accessing `bunTest.only` in CI environments (CI=true). Using a getter
110
+ // defers the access until `test.only` is actually used, allowing the error
111
+ // to surface at the appropriate time rather than at module initialization.
112
+ only: {
113
+ get() {
114
+ return makeScenarioTest(bunTest.only);
115
+ },
116
+ },
117
+ skip: {
118
+ value: makeScenarioTest(bunTest.skip),
119
+ },
120
+ todo: {
121
+ value: makeScenarioTest(bunTest.todo),
122
+ },
123
+ });
124
+
125
+ const test_ = test as Test;
126
+
127
+ export { test_ as it, test_ as test };
@@ -0,0 +1,20 @@
1
+ import { dirname, join } from "node:path";
2
+
3
+ /**
4
+ * Finds a file by walking up parent directories.
5
+ * Returns the path to the file if found, or undefined if not found.
6
+ */
7
+ export async function findUp(filename: string): Promise<string | undefined> {
8
+ let dir = process.cwd();
9
+ while (true) {
10
+ const filePath = join(dir, filename);
11
+ if (await Bun.file(filePath).exists()) {
12
+ return filePath;
13
+ }
14
+ const parent = dirname(dir);
15
+ if (parent === dir) {
16
+ return undefined;
17
+ }
18
+ dir = parent;
19
+ }
20
+ }
@@ -0,0 +1,25 @@
1
+ import { dirname } from "node:path";
2
+ import { findUp } from "./find-up.ts";
3
+
4
+ const CONFIG_FILE = "kest.config.ts";
5
+ const PACKAGE_JSON = "package.json";
6
+
7
+ /**
8
+ * Gets the workspace root directory.
9
+ *
10
+ * Uses find-up approach to locate `kest.config.ts`.
11
+ * If found, the directory containing the config file is the workspace root.
12
+ * If not found, tries to find `package.json` instead.
13
+ * If neither found, the current directory is used as the workspace root.
14
+ */
15
+ export async function getWorkspaceRoot(): Promise<string> {
16
+ const configPath = await findUp(CONFIG_FILE);
17
+ if (configPath) {
18
+ return dirname(configPath);
19
+ }
20
+ const packageJsonPath = await findUp(PACKAGE_JSON);
21
+ if (packageJsonPath) {
22
+ return dirname(packageJsonPath);
23
+ }
24
+ return process.cwd();
25
+ }
@@ -0,0 +1,14 @@
1
+ import { YAML } from "bun";
2
+
3
+ export function parseYaml(yaml: string): unknown {
4
+ // Check if the YAML contains stream separators (---)
5
+ // This includes multiple documents or single document with explicit start
6
+ if (/^---\s*$/m.test(yaml)) {
7
+ throw new Error("YAML stream is not supported");
8
+ }
9
+ return YAML.parse(yaml);
10
+ }
11
+
12
+ export function stringifyYaml(value: unknown): string {
13
+ return YAML.stringify(value, null, 2);
14
+ }