@appthrust/kest 0.3.0 → 0.3.2

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,12 +1,13 @@
1
1
  import { apply } from "../actions/apply";
2
- import {
3
- type ApplyNamespaceInput,
4
- applyNamespace,
5
- } from "../actions/apply-namespace";
6
2
  import { applyStatus } from "../actions/apply-status";
7
3
  import { assert } from "../actions/assert";
8
4
  import { assertAbsence } from "../actions/assert-absence";
9
5
  import { assertList } from "../actions/assert-list";
6
+ import { create } from "../actions/create";
7
+ import {
8
+ type CreateNamespaceInput,
9
+ createNamespace,
10
+ } from "../actions/create-namespace";
10
11
  import { deleteResource } from "../actions/delete";
11
12
  import { exec } from "../actions/exec";
12
13
  import { get } from "../actions/get";
@@ -33,9 +34,9 @@ export interface InternalScenario extends Scenario {
33
34
 
34
35
  export function createScenario(deps: CreateScenarioOptions): InternalScenario {
35
36
  const { recorder, reporter, reverting } = deps;
36
- recorder.record("ScenarioStarted", { name: deps.name });
37
37
  return {
38
38
  apply: createMutateFn(deps, apply),
39
+ create: createMutateFn(deps, create),
39
40
  applyStatus: createOneWayMutateFn(deps, applyStatus),
40
41
  delete: createOneWayMutateFn(deps, deleteResource),
41
42
  label: createOneWayMutateFn(deps, label),
@@ -83,17 +84,25 @@ const createMutateFn =
83
84
  options?: undefined | ActionOptions
84
85
  ): Promise<Output> => {
85
86
  const { recorder, kubectl, reverting } = deps;
86
- const { type, name, mutate } = action;
87
- 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();
88
97
  const fn = mutate({ kubectl });
89
- let mutateErr: unknown;
98
+ let mutateErr: undefined | Error;
90
99
  try {
91
100
  const { revert, output } = await retryUntil(() => fn(input), {
92
101
  ...options,
93
102
  recorder,
94
103
  });
95
104
  reverting.add(async () => {
96
- recorder.record("ActionStart", { action: name, phase: "revert" });
105
+ recordActionStart(); // to record revert action start
97
106
  let revertErr: unknown;
98
107
  try {
99
108
  await revert();
@@ -101,25 +110,15 @@ const createMutateFn =
101
110
  revertErr = err;
102
111
  throw err;
103
112
  } finally {
104
- recorder.record("ActionEnd", {
105
- action: name,
106
- phase: "revert",
107
- ok: revertErr === undefined,
108
- error: revertErr as Error,
109
- });
113
+ recordActionEnd(revertErr as Error); // to record revert action end
110
114
  }
111
115
  });
112
116
  return output;
113
117
  } catch (error) {
114
- mutateErr = error;
118
+ mutateErr = error as Error;
115
119
  throw error;
116
120
  } finally {
117
- recorder.record("ActionEnd", {
118
- action: name,
119
- phase: type,
120
- ok: mutateErr === undefined,
121
- error: mutateErr as Error,
122
- });
121
+ recordActionEnd(mutateErr as Error);
123
122
  }
124
123
  };
125
124
 
@@ -137,8 +136,8 @@ const createOneWayMutateFn =
137
136
  options?: undefined | ActionOptions
138
137
  ): Promise<Output> => {
139
138
  const { recorder, kubectl } = deps;
140
- const { name, mutate } = action;
141
- recorder.record("ActionStart", { action: name, phase: "mutate" });
139
+ const { mutate, describe } = action;
140
+ recorder.record("ActionStart", { description: describe(input) });
142
141
  const fn = mutate({ kubectl });
143
142
  let mutateErr: unknown;
144
143
  try {
@@ -148,8 +147,6 @@ const createOneWayMutateFn =
148
147
  throw error;
149
148
  } finally {
150
149
  recorder.record("ActionEnd", {
151
- action: name,
152
- phase: "mutate",
153
150
  ok: mutateErr === undefined,
154
151
  error: mutateErr as Error,
155
152
  });
@@ -170,29 +167,30 @@ const createQueryFn =
170
167
  options?: undefined | ActionOptions
171
168
  ): Promise<Output> => {
172
169
  const { recorder, kubectl } = deps;
173
- const { type, name, query } = action;
174
- recorder.record("ActionStart", { action: name, phase: type });
170
+ const { query, describe } = action;
171
+ recorder.record("ActionStart", { description: describe(input) });
175
172
  const fn = query({ kubectl });
173
+ let queryErr: unknown;
176
174
  try {
177
175
  return await retryUntil(() => fn(input), { ...options, recorder });
178
176
  } catch (error) {
177
+ queryErr = error;
178
+ throw error;
179
+ } finally {
179
180
  recorder.record("ActionEnd", {
180
- action: name,
181
- phase: type,
182
- ok: false,
183
- error: error as Error,
181
+ ok: queryErr === undefined,
182
+ error: queryErr as Error,
184
183
  });
185
- throw error;
186
184
  }
187
185
  };
188
186
 
189
187
  const createNewNamespaceFn =
190
188
  (scenarioDeps: CreateScenarioOptions) =>
191
189
  async (
192
- name?: ApplyNamespaceInput,
190
+ name?: CreateNamespaceInput,
193
191
  options?: undefined | ActionOptions
194
192
  ): Promise<Namespace> => {
195
- const namespaceName = await createMutateFn(scenarioDeps, applyNamespace)(
193
+ const namespaceName = await createMutateFn(scenarioDeps, createNamespace)(
196
194
  name,
197
195
  options
198
196
  );
@@ -202,6 +200,7 @@ const createNewNamespaceFn =
202
200
  return {
203
201
  name: namespaceName,
204
202
  apply: createMutateFn(namespacedDeps, apply),
203
+ create: createMutateFn(namespacedDeps, create),
205
204
  applyStatus: createOneWayMutateFn(namespacedDeps, applyStatus),
206
205
  delete: createOneWayMutateFn(namespacedDeps, deleteResource),
207
206
  label: createOneWayMutateFn(namespacedDeps, label),
@@ -224,6 +223,7 @@ const createUseClusterFn =
224
223
  const clusterDeps = { ...scenarioDeps, kubectl: clusterKubectl };
225
224
  return {
226
225
  apply: createMutateFn(clusterDeps, apply),
226
+ create: createMutateFn(clusterDeps, create),
227
227
  applyStatus: createOneWayMutateFn(clusterDeps, applyStatus),
228
228
  delete: createOneWayMutateFn(clusterDeps, deleteResource),
229
229
  label: createOneWayMutateFn(clusterDeps, label),
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