@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.
@@ -0,0 +1,296 @@
1
+ import { codeToANSIForcedColors } from "../../shiki";
2
+ import type { MarkdownReporterOptions } from "../index";
3
+ import type { Action, Report } from "../model";
4
+
5
+ const markdownLang = "markdown";
6
+ const markdownTheme = "catppuccin-mocha";
7
+
8
+ type StdinReplacement = Readonly<{
9
+ placeholder: string;
10
+ stdin: string;
11
+ stdinLanguage: string;
12
+ }>;
13
+
14
+ function normalizeStdin(stdin: string): string {
15
+ // Match `ts/reporter/markdown.ts` behavior: keep content stable.
16
+ return stdin.replace(/^\n/, "").replace(/\s+$/, "");
17
+ }
18
+
19
+ function applyStdinReplacements(
20
+ highlightedMarkdown: string,
21
+ replacements: ReadonlyArray<StdinReplacement>
22
+ ): string {
23
+ if (replacements.length === 0) {
24
+ return highlightedMarkdown;
25
+ }
26
+
27
+ let current = highlightedMarkdown;
28
+ for (const r of replacements) {
29
+ const lines = current.split("\n");
30
+ const stdinLines = r.stdin.split("\n");
31
+ const out: Array<string> = [];
32
+ for (const line of lines) {
33
+ if (stripAnsi(line).includes(r.placeholder)) {
34
+ out.push(...stdinLines);
35
+ } else {
36
+ out.push(line);
37
+ }
38
+ }
39
+ current = out.join("\n");
40
+ }
41
+ return current;
42
+ }
43
+
44
+ async function highlightMarkdown(
45
+ markdown: string,
46
+ stdinReplacements: ReadonlyArray<StdinReplacement>
47
+ ): Promise<string> {
48
+ const stripped = stripAnsi(markdown);
49
+ try {
50
+ const highlightedMarkdown = await codeToANSIForcedColors(
51
+ stripped,
52
+ markdownLang,
53
+ markdownTheme
54
+ );
55
+
56
+ if (stdinReplacements.length === 0) {
57
+ // Keep output shape stable: always end with a single trailing newline.
58
+ return highlightedMarkdown.replace(/\n+$/, "\n");
59
+ }
60
+
61
+ const highlightedStdinList = await Promise.all(
62
+ stdinReplacements.map(async (r) => {
63
+ const highlightedStdin = await codeToANSIForcedColors(
64
+ r.stdin,
65
+ r.stdinLanguage,
66
+ markdownTheme
67
+ );
68
+ // Avoid inserting an extra blank line before `EOF`.
69
+ const trimmed = trimFinalNewline(
70
+ highlightedStdin.replace(/\n+$/, "\n")
71
+ );
72
+ return { ...r, stdin: trimmed } satisfies StdinReplacement;
73
+ })
74
+ );
75
+
76
+ const replaced = applyStdinReplacements(
77
+ highlightedMarkdown,
78
+ highlightedStdinList
79
+ );
80
+ return replaced.replace(/\n+$/, "\n");
81
+ } catch {
82
+ return stripped;
83
+ }
84
+ }
85
+
86
+ function stripAnsi(input: string): string {
87
+ // Prefer Bun's built-in ANSI stripper when available.
88
+ if (typeof Bun !== "undefined" && typeof Bun.stripANSI === "function") {
89
+ return Bun.stripANSI(input);
90
+ }
91
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: intended
92
+ return input.replace(/\u001b\[[0-9;]*m/g, "");
93
+ }
94
+
95
+ function trimFinalNewline(input: string): string {
96
+ return input.replace(/\n$/, "");
97
+ }
98
+
99
+ function toBddHeading(keyword: string): string {
100
+ if (keyword.length === 0) {
101
+ return keyword;
102
+ }
103
+ return keyword.charAt(0).toUpperCase() + keyword.slice(1);
104
+ }
105
+
106
+ const statusEmojiByStatus = {
107
+ pending: "⏳",
108
+ success: "✅",
109
+ failure: "❌",
110
+ } as const;
111
+
112
+ function statusEmoji(status: keyof typeof statusEmojiByStatus): string {
113
+ return statusEmojiByStatus[status];
114
+ }
115
+
116
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: rendering is intentionally linear and explicit
117
+ export function renderReport(
118
+ report: Report,
119
+ options: MarkdownReporterOptions
120
+ ): Promise<string> {
121
+ const enableANSI = options.enableANSI ?? false;
122
+
123
+ const renderedScenarios: Array<string> = [];
124
+ const stdinReplacements: Array<StdinReplacement> = [];
125
+ let stdinSeq = 0;
126
+
127
+ for (const scenario of report.scenarios) {
128
+ const isEmpty =
129
+ scenario.overview.length === 0 &&
130
+ scenario.details.length === 0 &&
131
+ scenario.cleanup.length === 0;
132
+ if (isEmpty) {
133
+ continue;
134
+ }
135
+
136
+ const overviewStatusByName = new Map<
137
+ string,
138
+ "pending" | "success" | "failure"
139
+ >(scenario.overview.map((o) => [o.name, o.status]));
140
+
141
+ const lines: Array<string> = [];
142
+
143
+ lines.push(`# ${stripAnsi(scenario.name)}`);
144
+ lines.push("");
145
+
146
+ // Overview
147
+ lines.push("## Scenario Overview");
148
+ lines.push("");
149
+ lines.push("| # | Action | Status |");
150
+ lines.push("|---|--------|--------|");
151
+ for (const [i, item] of scenario.overview.entries()) {
152
+ lines.push(
153
+ `| ${i + 1} | ${stripAnsi(item.name)} | ${statusEmoji(item.status)} |`
154
+ );
155
+ }
156
+ lines.push("");
157
+
158
+ // Details
159
+ lines.push("## Scenario Details");
160
+ lines.push("");
161
+
162
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: rendering is intentionally linear and explicit
163
+ const renderAction = (action: Action) => {
164
+ let status = overviewStatusByName.get(action.name);
165
+ if (!status) {
166
+ if (action.error) {
167
+ status = "failure";
168
+ } else if (action.command) {
169
+ status = "success";
170
+ } else {
171
+ status = "pending";
172
+ }
173
+ }
174
+ const emoji = statusEmoji(status);
175
+ const attemptsSuffix =
176
+ status === "failure" && typeof action.attempts === "number"
177
+ ? ` (Failed after ${action.attempts} attempts)`
178
+ : "";
179
+
180
+ lines.push(`**${emoji} ${stripAnsi(action.name)}**${attemptsSuffix}`);
181
+ lines.push("");
182
+
183
+ const cmd = action.command;
184
+ if (cmd) {
185
+ const base = [cmd.cmd, ...cmd.args].join(" ").trim();
186
+ const stdin = cmd.stdin?.text;
187
+ const stdinLanguage = cmd.stdin?.language ?? "text";
188
+
189
+ lines.push("```shell");
190
+ if (typeof stdin === "string") {
191
+ lines.push(`${base} <<EOF`);
192
+ if (enableANSI) {
193
+ const placeholder = `__KEST_STDIN_${stdinSeq++}__`;
194
+ stdinReplacements.push({
195
+ placeholder,
196
+ stdin: normalizeStdin(stripAnsi(stdin)),
197
+ stdinLanguage,
198
+ });
199
+ lines.push(placeholder);
200
+ } else {
201
+ lines.push(stripAnsi(stdin));
202
+ }
203
+ lines.push("EOF");
204
+ } else {
205
+ lines.push(base);
206
+ }
207
+ lines.push("```");
208
+ lines.push("");
209
+
210
+ const stdout = stripAnsi(cmd.stdout?.text ?? "");
211
+ if (stdout.trim().length > 0) {
212
+ const lang = cmd.stdout?.language ?? "text";
213
+ lines.push(`\`\`\`${lang} title="stdout"`);
214
+ lines.push(trimFinalNewline(stdout));
215
+ lines.push("```");
216
+ lines.push("");
217
+ }
218
+
219
+ const stderr = stripAnsi(cmd.stderr?.text ?? "");
220
+ if (stderr.trim().length > 0) {
221
+ const lang = cmd.stderr?.language ?? "text";
222
+ lines.push(`\`\`\`${lang} title="stderr"`);
223
+ lines.push(trimFinalNewline(stderr));
224
+ lines.push("```");
225
+ lines.push("");
226
+ }
227
+ }
228
+
229
+ if (status === "failure" && action.error?.message?.text) {
230
+ const messageText = stripAnsi(action.error.message.text);
231
+ const lang = action.error.message.language ?? "text";
232
+ lines.push("Error:");
233
+ lines.push("");
234
+ lines.push(`\`\`\`${lang}`);
235
+ lines.push(trimFinalNewline(messageText));
236
+ lines.push("```");
237
+ lines.push("");
238
+ }
239
+ };
240
+
241
+ for (const item of scenario.details) {
242
+ if (item.type === "BDDSection") {
243
+ lines.push(
244
+ `### ${toBddHeading(item.keyword)}: ${stripAnsi(item.description)}`
245
+ );
246
+ lines.push("");
247
+ for (const action of item.actions) {
248
+ renderAction(action);
249
+ }
250
+ }
251
+ if (item.type === "Action") {
252
+ renderAction(item);
253
+ }
254
+ }
255
+
256
+ // Cleanup
257
+ if (scenario.cleanup.length > 0) {
258
+ lines.push("### Cleanup");
259
+ lines.push("");
260
+ lines.push("| # | Action | Status |");
261
+ lines.push("|---|--------|--------|");
262
+ for (const [i, item] of scenario.cleanup.entries()) {
263
+ lines.push(
264
+ `| ${i + 1} | ${stripAnsi(item.action)} | ${item.status === "success" ? "✅" : "❌"} |`
265
+ );
266
+ }
267
+ lines.push("");
268
+
269
+ lines.push("```shellsession");
270
+ for (const [i, item] of scenario.cleanup.entries()) {
271
+ if (i > 0) {
272
+ lines.push("");
273
+ }
274
+ const base = [item.command.cmd, ...item.command.args].join(" ").trim();
275
+ lines.push(`$ ${stripAnsi(base)}`);
276
+ const output = stripAnsi(item.command.output);
277
+ if (output.trim().length > 0) {
278
+ lines.push(trimFinalNewline(output));
279
+ }
280
+ }
281
+ lines.push("```");
282
+ }
283
+
284
+ renderedScenarios.push(lines.join("\n"));
285
+ }
286
+
287
+ if (renderedScenarios.length === 0) {
288
+ return Promise.resolve("");
289
+ }
290
+ const rendered = renderedScenarios.join("\n\n");
291
+ const markdown = rendered.endsWith("\n") ? rendered : `${rendered}\n`;
292
+ if (!enableANSI) {
293
+ return Promise.resolve(markdown);
294
+ }
295
+ return highlightMarkdown(markdown, stdinReplacements);
296
+ }
@@ -0,0 +1,58 @@
1
+ export type CodeToANSI = (
2
+ code: string,
3
+ language: string,
4
+ theme: string
5
+ ) => Promise<string>;
6
+
7
+ let codeToANSIPromise: Promise<CodeToANSI> | undefined;
8
+
9
+ function loadCodeToANSI(): Promise<CodeToANSI> {
10
+ if (codeToANSIPromise) {
11
+ return codeToANSIPromise;
12
+ }
13
+
14
+ codeToANSIPromise = (async () => {
15
+ const env = typeof process !== "undefined" ? process.env : undefined;
16
+ const prevNoColor = env?.["NO_COLOR"];
17
+ const prevForceColor = env?.["FORCE_COLOR"];
18
+
19
+ // `@shikijs/cli` uses `ansis`, which disables colors when NO_COLOR is set.
20
+ // Force ANSI output when our callers explicitly request ANSI.
21
+ if (env) {
22
+ // biome-ignore lint/performance/noDelete: required to actually unset env vars
23
+ delete env["NO_COLOR"];
24
+ env["FORCE_COLOR"] = "3";
25
+ }
26
+
27
+ try {
28
+ const mod = await import("@shikijs/cli");
29
+ return mod.codeToANSI as unknown as CodeToANSI;
30
+ } finally {
31
+ if (env) {
32
+ if (prevNoColor === undefined) {
33
+ // biome-ignore lint/performance/noDelete: required to restore absence
34
+ delete env["NO_COLOR"];
35
+ } else {
36
+ env["NO_COLOR"] = prevNoColor;
37
+ }
38
+ if (prevForceColor === undefined) {
39
+ // biome-ignore lint/performance/noDelete: required to restore absence
40
+ delete env["FORCE_COLOR"];
41
+ } else {
42
+ env["FORCE_COLOR"] = prevForceColor;
43
+ }
44
+ }
45
+ }
46
+ })();
47
+
48
+ return codeToANSIPromise;
49
+ }
50
+
51
+ export async function codeToANSIForcedColors(
52
+ code: string,
53
+ language: string,
54
+ theme: string
55
+ ): Promise<string> {
56
+ const fn = await loadCodeToANSI();
57
+ return await fn(code, language, theme);
58
+ }
package/ts/retry.ts CHANGED
@@ -77,7 +77,6 @@ export async function retryUntil<T>(
77
77
  }
78
78
 
79
79
  retries += 1;
80
- recorder?.record("RetryAttempt", { attempt: retries });
81
80
 
82
81
  try {
83
82
  const value = await fn();
@@ -89,11 +88,6 @@ export async function retryUntil<T>(
89
88
  return value;
90
89
  } catch (err) {
91
90
  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
91
  }
98
92
  }
99
93
 
@@ -1,11 +1,17 @@
1
1
  import { apply } from "../actions/apply";
2
- import { applyNamespace } from "../actions/apply-namespace";
2
+ import {
3
+ type ApplyNamespaceInput,
4
+ applyNamespace,
5
+ } from "../actions/apply-namespace";
3
6
  import { applyStatus } from "../actions/apply-status";
4
7
  import { assert } from "../actions/assert";
8
+ import { assertAbsence } from "../actions/assert-absence";
5
9
  import { assertList } from "../actions/assert-list";
10
+ import { create } from "../actions/create";
6
11
  import { deleteResource } from "../actions/delete";
7
12
  import { exec } from "../actions/exec";
8
13
  import { get } from "../actions/get";
14
+ import { label } from "../actions/label";
9
15
  import type { MutateDef, OneWayMutateDef, QueryDef } from "../actions/types";
10
16
  import type {
11
17
  ActionOptions,
@@ -28,14 +34,16 @@ export interface InternalScenario extends Scenario {
28
34
 
29
35
  export function createScenario(deps: CreateScenarioOptions): InternalScenario {
30
36
  const { recorder, reporter, reverting } = deps;
31
- recorder.record("ScenarioStarted", { name: deps.name });
32
37
  return {
33
38
  apply: createMutateFn(deps, apply),
39
+ create: createMutateFn(deps, create),
34
40
  applyStatus: createOneWayMutateFn(deps, applyStatus),
35
41
  delete: createOneWayMutateFn(deps, deleteResource),
42
+ label: createOneWayMutateFn(deps, label),
36
43
  exec: createMutateFn(deps, exec),
37
44
  get: createQueryFn(deps, get),
38
45
  assert: createQueryFn(deps, assert),
46
+ assertAbsence: createQueryFn(deps, assertAbsence),
39
47
  assertList: createQueryFn(deps, assertList),
40
48
  given: bdd.given(deps),
41
49
  when: bdd.when(deps),
@@ -76,17 +84,25 @@ const createMutateFn =
76
84
  options?: undefined | ActionOptions
77
85
  ): Promise<Output> => {
78
86
  const { recorder, kubectl, reverting } = deps;
79
- const { type, name, mutate } = action;
80
- recorder.record("ActionStart", { action: name, phase: type });
87
+ const { mutate, describe } = action;
88
+ function recordActionStart() {
89
+ recorder.record("ActionStart", {
90
+ description: describe(input),
91
+ });
92
+ }
93
+ function recordActionEnd(error: undefined | Error) {
94
+ recorder.record("ActionEnd", { ok: error === undefined, error });
95
+ }
96
+ recordActionStart();
81
97
  const fn = mutate({ kubectl });
82
- let mutateErr: unknown;
98
+ let mutateErr: undefined | Error;
83
99
  try {
84
100
  const { revert, output } = await retryUntil(() => fn(input), {
85
101
  ...options,
86
102
  recorder,
87
103
  });
88
104
  reverting.add(async () => {
89
- recorder.record("ActionStart", { action: name, phase: "revert" });
105
+ recordActionStart(); // to record revert action start
90
106
  let revertErr: unknown;
91
107
  try {
92
108
  await revert();
@@ -94,25 +110,15 @@ const createMutateFn =
94
110
  revertErr = err;
95
111
  throw err;
96
112
  } finally {
97
- recorder.record("ActionEnd", {
98
- action: name,
99
- phase: "revert",
100
- ok: revertErr === undefined,
101
- error: revertErr as Error,
102
- });
113
+ recordActionEnd(revertErr as Error); // to record revert action end
103
114
  }
104
115
  });
105
116
  return output;
106
117
  } catch (error) {
107
- mutateErr = error;
118
+ mutateErr = error as Error;
108
119
  throw error;
109
120
  } finally {
110
- recorder.record("ActionEnd", {
111
- action: name,
112
- phase: type,
113
- ok: mutateErr === undefined,
114
- error: mutateErr as Error,
115
- });
121
+ recordActionEnd(mutateErr as Error);
116
122
  }
117
123
  };
118
124
 
@@ -130,8 +136,8 @@ const createOneWayMutateFn =
130
136
  options?: undefined | ActionOptions
131
137
  ): Promise<Output> => {
132
138
  const { recorder, kubectl } = deps;
133
- const { name, mutate } = action;
134
- recorder.record("ActionStart", { action: name, phase: "mutate" });
139
+ const { mutate, describe } = action;
140
+ recorder.record("ActionStart", { description: describe(input) });
135
141
  const fn = mutate({ kubectl });
136
142
  let mutateErr: unknown;
137
143
  try {
@@ -141,8 +147,6 @@ const createOneWayMutateFn =
141
147
  throw error;
142
148
  } finally {
143
149
  recorder.record("ActionEnd", {
144
- action: name,
145
- phase: "mutate",
146
150
  ok: mutateErr === undefined,
147
151
  error: mutateErr as Error,
148
152
  });
@@ -163,26 +167,27 @@ const createQueryFn =
163
167
  options?: undefined | ActionOptions
164
168
  ): Promise<Output> => {
165
169
  const { recorder, kubectl } = deps;
166
- const { type, name, query } = action;
167
- recorder.record("ActionStart", { action: name, phase: type });
170
+ const { query, describe } = action;
171
+ recorder.record("ActionStart", { description: describe(input) });
168
172
  const fn = query({ kubectl });
173
+ let queryErr: unknown;
169
174
  try {
170
175
  return await retryUntil(() => fn(input), { ...options, recorder });
171
176
  } catch (error) {
177
+ queryErr = error;
178
+ throw error;
179
+ } finally {
172
180
  recorder.record("ActionEnd", {
173
- action: name,
174
- phase: type,
175
- ok: false,
176
- error: error as Error,
181
+ ok: queryErr === undefined,
182
+ error: queryErr as Error,
177
183
  });
178
- throw error;
179
184
  }
180
185
  };
181
186
 
182
187
  const createNewNamespaceFn =
183
188
  (scenarioDeps: CreateScenarioOptions) =>
184
189
  async (
185
- name?: undefined | string,
190
+ name?: ApplyNamespaceInput,
186
191
  options?: undefined | ActionOptions
187
192
  ): Promise<Namespace> => {
188
193
  const namespaceName = await createMutateFn(scenarioDeps, applyNamespace)(
@@ -193,11 +198,15 @@ const createNewNamespaceFn =
193
198
  const namespacedKubectl = kubectl.extends({ namespace: namespaceName });
194
199
  const namespacedDeps = { ...scenarioDeps, kubectl: namespacedKubectl };
195
200
  return {
201
+ name: namespaceName,
196
202
  apply: createMutateFn(namespacedDeps, apply),
203
+ create: createMutateFn(namespacedDeps, create),
197
204
  applyStatus: createOneWayMutateFn(namespacedDeps, applyStatus),
198
205
  delete: createOneWayMutateFn(namespacedDeps, deleteResource),
206
+ label: createOneWayMutateFn(namespacedDeps, label),
199
207
  get: createQueryFn(namespacedDeps, get),
200
208
  assert: createQueryFn(namespacedDeps, assert),
209
+ assertAbsence: createQueryFn(namespacedDeps, assertAbsence),
201
210
  assertList: createQueryFn(namespacedDeps, assertList),
202
211
  };
203
212
  };
@@ -214,10 +223,13 @@ const createUseClusterFn =
214
223
  const clusterDeps = { ...scenarioDeps, kubectl: clusterKubectl };
215
224
  return {
216
225
  apply: createMutateFn(clusterDeps, apply),
226
+ create: createMutateFn(clusterDeps, create),
217
227
  applyStatus: createOneWayMutateFn(clusterDeps, applyStatus),
218
228
  delete: createOneWayMutateFn(clusterDeps, deleteResource),
229
+ label: createOneWayMutateFn(clusterDeps, label),
219
230
  get: createQueryFn(clusterDeps, get),
220
231
  assert: createQueryFn(clusterDeps, assert),
232
+ assertAbsence: createQueryFn(clusterDeps, assertAbsence),
221
233
  assertList: createQueryFn(clusterDeps, assertList),
222
234
  newNamespace: createNewNamespaceFn(clusterDeps),
223
235
  };
package/ts/test.ts CHANGED
@@ -59,7 +59,7 @@ function makeScenarioTest(runner: BunTestRunner): TestFunction {
59
59
  reverting,
60
60
  reporter,
61
61
  });
62
-
62
+ recorder.record("ScenarioStart", { name: label });
63
63
  let testErr: undefined | Error;
64
64
  try {
65
65
  await fn(scenario);
@@ -67,6 +67,7 @@ function makeScenarioTest(runner: BunTestRunner): TestFunction {
67
67
  testErr = error as Error;
68
68
  }
69
69
  await scenario.cleanup();
70
+ recorder.record("ScenarioEnd", {});
70
71
  await report(recorder, scenario, testErr);
71
72
  if (testErr) {
72
73
  throw testErr;
File without changes