@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/LICENSE +21 -0
- package/README.md +412 -0
- package/example/config-map.yaml +6 -0
- package/example/example-report.md +744 -0
- package/example/example.test.ts +225 -0
- package/example/hello-world-crd.yaml +145 -0
- package/package.json +62 -0
- package/ts/actions/apply-namespace.ts +29 -0
- package/ts/actions/apply-status.ts +23 -0
- package/ts/actions/apply.ts +25 -0
- package/ts/actions/assert-list.ts +63 -0
- package/ts/actions/assert.ts +34 -0
- package/ts/actions/exec.ts +21 -0
- package/ts/actions/get.ts +40 -0
- package/ts/actions/types.ts +48 -0
- package/ts/apis/index.ts +788 -0
- package/ts/bdd/index.ts +30 -0
- package/ts/duration/index.ts +171 -0
- package/ts/index.ts +3 -0
- package/ts/k8s-resource/index.ts +120 -0
- package/ts/kubectl/index.ts +351 -0
- package/ts/recording/index.ts +134 -0
- package/ts/reporter/index.ts +0 -0
- package/ts/reporter/interface.ts +5 -0
- package/ts/reporter/markdown.ts +962 -0
- package/ts/retry.ts +112 -0
- package/ts/reverting/index.ts +36 -0
- package/ts/scenario/index.ts +220 -0
- package/ts/test.ts +127 -0
- package/ts/workspace/find-up.ts +20 -0
- package/ts/workspace/index.ts +25 -0
- package/ts/yaml/index.ts +14 -0
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
|
+
}
|
package/ts/yaml/index.ts
ADDED
|
@@ -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
|
+
}
|