@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.
@@ -1,962 +0,0 @@
1
- import { codeToANSI } from "@shikijs/cli";
2
- import shellEscapeArg from "@suin/shell-escape-arg";
3
- import { parseK8sResourceYaml } from "../k8s-resource";
4
- import type { Event } from "../recording";
5
- import type { Reporter } from "./interface";
6
-
7
- export interface MarkdownReporterOptions {
8
- /**
9
- * If true, keep ANSI escape codes (colors) in error messages.
10
- * If false (default), remove ANSI escape codes.
11
- */
12
- enableANSI?: boolean;
13
- }
14
-
15
- type BddKeyword = "Given" | "When" | "Then" | "And" | "But";
16
- type ActionInput = Readonly<{
17
- kind?: string | undefined;
18
- name?: string | undefined;
19
- namespace?: string | undefined;
20
- }>;
21
-
22
- const markdownLang = "markdown";
23
- const markdownTheme = "catppuccin-mocha";
24
- const codeToANSICompat = codeToANSI as unknown as (
25
- code: string,
26
- language: string,
27
- theme: string
28
- ) => Promise<string>;
29
-
30
- async function highlightCode(
31
- source: string,
32
- language: string
33
- ): Promise<string> {
34
- try {
35
- return await codeToANSICompat(source, language, markdownTheme);
36
- } catch {
37
- return source;
38
- }
39
- }
40
-
41
- function normalizeStdin(stdin: string): string {
42
- return stdin.replace(/^\n/, "").replace(/\s+$/, "");
43
- }
44
-
45
- function mergeActionInput(
46
- primary: ActionInput,
47
- fallback: ActionInput
48
- ): ActionInput {
49
- return {
50
- kind: primary.kind ?? fallback.kind,
51
- name: primary.name ?? fallback.name,
52
- namespace: primary.namespace ?? fallback.namespace,
53
- };
54
- }
55
-
56
- function normalizeActionName(actionName: string): string {
57
- switch (actionName) {
58
- case "CreateNamespaceAction":
59
- return "ApplyNamespace";
60
- case "ApplyK8sResourceAction":
61
- return "Apply";
62
- case "AssertK8sResourceAction":
63
- return "Assert";
64
- case "GetResourceAction":
65
- return "Get";
66
- default:
67
- return actionName;
68
- }
69
- }
70
-
71
- function normalizeKind(kind: string): string {
72
- const trimmed = kind.trim();
73
- if (!trimmed) {
74
- return kind;
75
- }
76
- const [base] = trimmed.split(".");
77
- return base ?? trimmed;
78
- }
79
-
80
- function extractNamespace(args: ReadonlyArray<string>): string | undefined {
81
- const idx = args.findIndex((arg) => arg === "-n" || arg === "--namespace");
82
- if (idx === -1) {
83
- return undefined;
84
- }
85
- const value = args[idx + 1];
86
- if (!value || value.startsWith("-")) {
87
- return undefined;
88
- }
89
- return value;
90
- }
91
-
92
- function parseResourceRef(ref: string): ActionInput {
93
- const [rawKind, rawName] = ref.split("/", 2);
94
- if (!rawKind) {
95
- return {};
96
- }
97
- const kind = normalizeKind(rawKind);
98
- const name = rawName && rawName.length > 0 ? rawName : undefined;
99
- return { kind, name };
100
- }
101
-
102
- function inferActionInputFromArgs(args: ReadonlyArray<string>): ActionInput {
103
- const namespace = extractNamespace(args);
104
- const base: ActionInput = namespace ? { namespace } : {};
105
- const subcommand = args[0];
106
- if (subcommand !== "get" && subcommand !== "delete") {
107
- return base;
108
- }
109
- const ref = args[1];
110
- if (!ref || ref.startsWith("-")) {
111
- return base;
112
- }
113
- if (ref.includes("/")) {
114
- return mergeActionInput(parseResourceRef(ref), base);
115
- }
116
- const kind = normalizeKind(ref);
117
- const maybeName = args[2];
118
- const name = maybeName && !maybeName.startsWith("-") ? maybeName : undefined;
119
- return mergeActionInput({ kind, name }, base);
120
- }
121
-
122
- function inferActionInputFromStdin(stdin: string): ActionInput {
123
- try {
124
- const parsed = parseK8sResourceYaml(stdin);
125
- if (parsed.ok) {
126
- return {
127
- kind: parsed.value.kind,
128
- name: parsed.value.metadata?.name,
129
- namespace: parsed.value.metadata?.namespace,
130
- };
131
- }
132
- } catch {
133
- // Ignore stdin parse errors and fall back to args.
134
- }
135
- return {};
136
- }
137
-
138
- function inferActionInputFromCommand(
139
- run: Extract<Event, { kind: "CommandRun" }>
140
- ): ActionInput {
141
- const fromArgs = inferActionInputFromArgs(run.data.args);
142
- const fromStdin = run.data.stdin
143
- ? inferActionInputFromStdin(run.data.stdin)
144
- : {};
145
- return mergeActionInput(fromStdin, fromArgs);
146
- }
147
-
148
- function isNamespaceKind(kind?: string): boolean {
149
- return typeof kind === "string" && kind.toLowerCase() === "namespace";
150
- }
151
-
152
- function toActionInputRecord(
153
- input: ActionInput
154
- ): Readonly<Record<string, unknown>> | undefined {
155
- const record: Record<string, unknown> = {};
156
- if (input.kind) {
157
- record["kind"] = input.kind;
158
- }
159
- if (input.name) {
160
- record["name"] = input.name;
161
- }
162
- if (input.namespace) {
163
- record["namespace"] = input.namespace;
164
- }
165
- return Object.keys(record).length > 0 ? record : undefined;
166
- }
167
-
168
- type StdinReplacement = Readonly<{
169
- placeholder: string;
170
- stdin: string;
171
- stdinLanguage: string;
172
- }>;
173
-
174
- function applyStdinReplacements(
175
- highlightedMarkdown: string,
176
- replacements: ReadonlyArray<StdinReplacement>
177
- ): string {
178
- if (replacements.length === 0) {
179
- return highlightedMarkdown;
180
- }
181
-
182
- let current = highlightedMarkdown;
183
- for (const r of replacements) {
184
- const lines = current.split("\n");
185
- const stdinLines = r.stdin.split("\n");
186
- const out: Array<string> = [];
187
- for (const line of lines) {
188
- if (stripAnsi(line).includes(r.placeholder)) {
189
- out.push(...stdinLines);
190
- } else {
191
- out.push(line);
192
- }
193
- }
194
- current = out.join("\n");
195
- }
196
- return current;
197
- }
198
-
199
- type BddContext = Readonly<{
200
- keyword: BddKeyword;
201
- description: string;
202
- }>;
203
-
204
- interface CommandPair {
205
- run: Extract<Event, { kind: "CommandRun" }>;
206
- result?: Extract<Event, { kind: "CommandResult" }>;
207
- }
208
-
209
- interface ActionContext {
210
- bdd?: BddContext;
211
- start: Extract<Event, { kind: "ActionStart" }>;
212
- end?: Extract<Event, { kind: "ActionEnd" }>;
213
- error?: ActionErrorModel;
214
- commandPairs: Array<CommandPair>;
215
- sawCommandBeforeRetryStart: boolean;
216
- retryStarted: boolean;
217
- retryEnd?: Extract<Event, { kind: "RetryEnd" }>;
218
- derivedInput?: ActionInput;
219
- }
220
-
221
- type ActionEndError = Extract<Event, { kind: "ActionEnd" }>["data"]["error"];
222
-
223
- type ActionErrorModel = Readonly<{
224
- name?: string | undefined;
225
- message: string;
226
- isDiff: boolean;
227
- trace?: string | undefined;
228
- }>;
229
-
230
- function extractStack(errorLike: unknown): string | undefined {
231
- if (!errorLike || typeof errorLike !== "object") {
232
- return undefined;
233
- }
234
- const stack = (errorLike as { stack?: unknown }).stack;
235
- return typeof stack === "string" ? stack : undefined;
236
- }
237
-
238
- function toTrace(stack?: string): string | undefined {
239
- if (!stack) {
240
- return undefined;
241
- }
242
- const stripped = stripAnsi(stack);
243
- const lines = stripped.split(/\r?\n/);
244
- if (lines.length === 0) {
245
- return undefined;
246
- }
247
- // Drop the header line like "Error: message" to keep the trace concise.
248
- if (lines[0] && !/^\s*at\b/.test(lines[0])) {
249
- lines.shift();
250
- }
251
- while (lines.length > 0 && lines[0]?.trim() === "") {
252
- lines.shift();
253
- }
254
- // Preserve leading indentation (e.g. " at ...") but remove trailing whitespace.
255
- const trace = lines.join("\n").replace(/\s+$/, "");
256
- return trace.length > 0 ? trace : undefined;
257
- }
258
-
259
- function unwrapRetryTimeoutCauseMessage(
260
- error: ActionEndError
261
- ):
262
- | { name?: string | undefined; message: string; stack?: string | undefined }
263
- | undefined {
264
- if (!error?.message) {
265
- return undefined;
266
- }
267
- // `retryUntil()` throws a timeout error whose `cause` is the last failure.
268
- // Prefer the cause message so the report shows the underlying assertion diff.
269
- const raw = stripAnsi(error.message).trim();
270
- if (!raw.startsWith("Timed out after ")) {
271
- return undefined;
272
- }
273
- const cause = (error as unknown as { cause?: unknown }).cause;
274
- if (!cause || typeof cause !== "object") {
275
- return undefined;
276
- }
277
- const maybeCause = cause as { name?: unknown; message?: unknown };
278
- if (
279
- typeof maybeCause.message !== "string" ||
280
- maybeCause.message.length === 0
281
- ) {
282
- return undefined;
283
- }
284
- return {
285
- name: typeof maybeCause.name === "string" ? maybeCause.name : undefined,
286
- message: maybeCause.message,
287
- stack: extractStack(cause),
288
- };
289
- }
290
-
291
- function stripAnsi(input: string): string {
292
- // Prefer Bun's built-in ANSI stripper when available.
293
- if (typeof Bun !== "undefined" && typeof Bun.stripANSI === "function") {
294
- return Bun.stripANSI(input);
295
- }
296
- // biome-ignore lint/suspicious/noControlCharactersInRegex: intended
297
- return input.replace(/\u001b\[[0-9;]*m/g, "");
298
- }
299
-
300
- function trimFinalNewline(s: string): string {
301
- return s.replace(/\n$/, "");
302
- }
303
-
304
- // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: diff detection relies on a small set of explicit heuristics
305
- function isDiffLikeMessage(message: string): boolean {
306
- const stripped = stripAnsi(message).trim();
307
- if (!stripped) {
308
- return false;
309
- }
310
-
311
- const lines = stripped.split(/\r?\n/);
312
-
313
- // Strong indicators (avoid YAML doc '---' by requiring both headers).
314
- if (lines.some((l) => /^diff --git\b/.test(l))) {
315
- return true;
316
- }
317
- if (lines.some((l) => l.startsWith("@@ "))) {
318
- return true;
319
- }
320
- const hasUnifiedHeaders =
321
- lines.some((l) => l.startsWith("--- ")) &&
322
- lines.some((l) => l.startsWith("+++ "));
323
- if (hasUnifiedHeaders) {
324
- return true;
325
- }
326
-
327
- // Bun/Jest-style summaries.
328
- const hasExpected = lines.some((l) => l.startsWith("- Expected"));
329
- const hasReceived = lines.some((l) => l.startsWith("+ Received"));
330
- if (hasExpected && hasReceived) {
331
- return true;
332
- }
333
-
334
- // Added/removed lines (ignore unified diff file headers).
335
- let sawAdded = false;
336
- let sawRemoved = false;
337
- for (const l of lines) {
338
- if (!sawAdded && l.startsWith("+") && !l.startsWith("+++")) {
339
- if (l.slice(1).trim().length > 0) {
340
- sawAdded = true;
341
- }
342
- continue;
343
- }
344
- if (!sawRemoved && l.startsWith("-") && !l.startsWith("---")) {
345
- if (l.slice(1).trim().length > 0) {
346
- sawRemoved = true;
347
- }
348
- continue;
349
- }
350
- if (sawAdded && sawRemoved) {
351
- return true;
352
- }
353
- }
354
- return sawAdded && sawRemoved;
355
- }
356
-
357
- function toActionErrorModel(
358
- error: ActionEndError
359
- ): ActionErrorModel | undefined {
360
- if (!error) {
361
- return undefined;
362
- }
363
- const unwrapped = unwrapRetryTimeoutCauseMessage(error);
364
- const message = unwrapped?.message ?? error.message;
365
- const stack = unwrapped?.stack ?? extractStack(error);
366
- const trace = toTrace(stack);
367
- return {
368
- name: unwrapped?.name ?? error.name,
369
- message,
370
- isDiff: isDiffLikeMessage(message),
371
- ...(trace ? { trace } : {}),
372
- };
373
- }
374
-
375
- function bddFromEvent(e: Event): BddContext | undefined {
376
- switch (e.kind) {
377
- case "BDDGiven":
378
- return { keyword: "Given", description: e.data.description };
379
- case "BDDWhen":
380
- return { keyword: "When", description: e.data.description };
381
- case "BDDThen":
382
- return { keyword: "Then", description: e.data.description };
383
- case "BDDAnd":
384
- return { keyword: "And", description: e.data.description };
385
- case "BDBut":
386
- return { keyword: "But", description: e.data.description };
387
- default:
388
- return undefined;
389
- }
390
- }
391
-
392
- function formatShellCommand(
393
- run: Extract<Event, { kind: "CommandRun" }>
394
- ): string {
395
- const base = [run.data.cmd, ...run.data.args]
396
- .map(shellEscapeArg)
397
- .join(" ")
398
- .trim();
399
- if (!run.data.stdin) {
400
- return base;
401
- }
402
-
403
- // Match preview.md style:
404
- // kubectl apply -f - <<EOF
405
- // <stdin>
406
- // EOF
407
- return `${base} <<EOF\n${run.data.stdin}\nEOF`;
408
- }
409
-
410
- function formatShellCommandWithPlaceholder(
411
- run: Extract<Event, { kind: "CommandRun" }>,
412
- placeholder: string
413
- ): string {
414
- const base = [run.data.cmd, ...run.data.args]
415
- .map(shellEscapeArg)
416
- .join(" ")
417
- .trim();
418
- if (!run.data.stdin) {
419
- return base;
420
- }
421
- return `${base} <<EOF\n${placeholder}\nEOF`;
422
- }
423
-
424
- function languageOrDefault(lang?: string): string {
425
- if (!lang) {
426
- return "text";
427
- }
428
- return lang;
429
- }
430
-
431
- function getActionInput(
432
- action: Extract<Event, { kind: "ActionStart" }>
433
- ): Readonly<Record<string, unknown>> {
434
- return (action.data.input ?? {}) as Readonly<Record<string, unknown>>;
435
- }
436
-
437
- function getInputString(
438
- input: Readonly<Record<string, unknown>>,
439
- key: string
440
- ): string | undefined {
441
- const v = input[key];
442
- return typeof v === "string" ? v : undefined;
443
- }
444
-
445
- function overviewActionLabel(actionName: string, phase: string): string {
446
- const normalized = normalizeActionName(actionName);
447
- if (normalized === "ApplyNamespace") {
448
- return phase === "revert" ? "Delete namespace" : "Create namespace";
449
- }
450
- if (normalized === "Apply") {
451
- return phase === "revert" ? "Delete" : "Apply";
452
- }
453
- if (normalized === "Assert") {
454
- return "Assert";
455
- }
456
- if (normalized === "Get") {
457
- return "Get";
458
- }
459
- if (normalized === "Exec") {
460
- return "Exec";
461
- }
462
- return normalized;
463
- }
464
-
465
- function detailsActionLabel(actionName: string, phase: string): string {
466
- const normalized = normalizeActionName(actionName);
467
- if (normalized === "ApplyNamespace") {
468
- return phase === "revert" ? "Delete Namespace" : "Create Namespace";
469
- }
470
- if (normalized === "Apply") {
471
- return phase === "revert" ? "Delete" : "Apply";
472
- }
473
- if (normalized === "Assert") {
474
- return "Assert";
475
- }
476
- if (normalized === "Get") {
477
- return "Get";
478
- }
479
- if (normalized === "Exec") {
480
- return "Exec";
481
- }
482
- return normalized;
483
- }
484
-
485
- function resolveActionInput(ctx: ActionContext): ActionInput {
486
- const input = getActionInput(ctx.start);
487
- const fromStart: ActionInput = {
488
- kind: getInputString(input, "kind"),
489
- name: getInputString(input, "name"),
490
- namespace: getInputString(input, "namespace"),
491
- };
492
- const firstPair = ctx.commandPairs.at(0);
493
- const fallback =
494
- ctx.derivedInput ??
495
- (firstPair ? inferActionInputFromCommand(firstPair.run) : {});
496
- return mergeActionInput(fromStart, fallback);
497
- }
498
-
499
- function resourceForAction(ctx: ActionContext): string {
500
- const input = resolveActionInput(ctx);
501
- const action = normalizeActionName(ctx.start.data.action);
502
-
503
- if (action === "ApplyNamespace") {
504
- return input.name ?? "N/A";
505
- }
506
-
507
- const kind = input.kind;
508
- const name = input.name;
509
- if (kind && name) {
510
- return `${kind}/${name}`;
511
- }
512
- return name ?? kind ?? "N/A";
513
- }
514
-
515
- function detailsTitleForAction(ctx: ActionContext): string {
516
- const action = normalizeActionName(ctx.start.data.action);
517
- const phase = ctx.start.data.phase;
518
- const input = resolveActionInput(ctx);
519
-
520
- const label = detailsActionLabel(action, phase);
521
- if (action === "ApplyNamespace") {
522
- const name = input.name ?? "";
523
- return `${label} "${name}"`;
524
- }
525
-
526
- const kind = input.kind;
527
- const name = input.name;
528
- const ns = input.namespace;
529
- if (ns) {
530
- const base = [kind, name ? `"${name}"` : ""].filter(Boolean).join(" ");
531
- if (base) {
532
- return `${label} ${base} in namespace "${ns}"`;
533
- }
534
- return `${label} in namespace "${ns}"`;
535
- }
536
- const base = [kind, name ? `"${name}"` : ""].filter(Boolean).join(" ");
537
- if (base) {
538
- return `${label} ${base}`;
539
- }
540
- return label;
541
- }
542
-
543
- function actionOk(ctx: ActionContext): boolean {
544
- if (ctx.end) {
545
- return ctx.end.data.ok;
546
- }
547
- const lastResult = ctx.commandPairs.at(-1)?.result;
548
- if (lastResult) {
549
- return lastResult.data.exitCode === 0;
550
- }
551
- if (ctx.retryEnd && !ctx.retryEnd.data.success) {
552
- return false;
553
- }
554
- return true;
555
- }
556
-
557
- function computeFailedAttemptsSuffix(ctx: ActionContext): string {
558
- if (!ctx.end || ctx.end.data.ok) {
559
- return "";
560
- }
561
- if (!ctx.retryEnd || ctx.retryEnd.data.success) {
562
- return "";
563
- }
564
-
565
- const base = ctx.retryEnd.data.attempts;
566
- const total = ctx.sawCommandBeforeRetryStart ? base + 1 : base;
567
- return ` (Failed after ${total} attempts)`;
568
- }
569
-
570
- type ScenarioReportModel = Readonly<{
571
- scenarioName: string;
572
- overviewActions: Array<ActionContext>;
573
- cleanupActions: Array<ActionContext>;
574
- bddOrder: Array<BddContext>;
575
- }>;
576
-
577
- const bddKey = (b: BddContext) => `${b.keyword}\u0000${b.description}`;
578
-
579
- function buildBddOrder(
580
- overviewActions: ReadonlyArray<ActionContext>
581
- ): Array<BddContext> {
582
- const bddOrder: Array<BddContext> = [];
583
- const bddKeyToIndex = new Map<string, number>();
584
- for (const a of overviewActions) {
585
- const b = a.bdd;
586
- if (!b) {
587
- continue;
588
- }
589
- const key = bddKey(b);
590
- if (!bddKeyToIndex.has(key)) {
591
- bddKeyToIndex.set(key, bddOrder.length);
592
- bddOrder.push(b);
593
- }
594
- }
595
- return bddOrder;
596
- }
597
-
598
- // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: event parsing is intentionally linear but verbose
599
- function parseScenarioReportModel(
600
- events: ReadonlyArray<Event>
601
- ): ScenarioReportModel {
602
- let scenarioName = "Scenario";
603
- const actions: Array<ActionContext> = [];
604
- let currentBdd: BddContext | undefined;
605
- let currentAction: ActionContext | undefined;
606
- let inCleanup = false;
607
- let hasExplicitCleanupActions = false;
608
- let pendingCleanupCommand: CommandPair | undefined;
609
-
610
- const finalizeCurrentAction = () => {
611
- if (currentAction) {
612
- actions.push(currentAction);
613
- currentAction = undefined;
614
- }
615
- };
616
-
617
- const pushImplicitCleanupAction = (pair: CommandPair) => {
618
- const inferredInput = inferActionInputFromCommand(pair.run);
619
- const actionName = isNamespaceKind(inferredInput.kind)
620
- ? "ApplyNamespace"
621
- : "Apply";
622
- const inputRecord = toActionInputRecord(inferredInput);
623
- const start: Extract<Event, { kind: "ActionStart" }> = {
624
- kind: "ActionStart",
625
- data: {
626
- action: actionName,
627
- phase: "revert",
628
- ...(inputRecord ? { input: inputRecord } : {}),
629
- },
630
- };
631
- actions.push({
632
- start,
633
- commandPairs: [pair],
634
- sawCommandBeforeRetryStart: true,
635
- retryStarted: false,
636
- derivedInput: inferredInput,
637
- });
638
- };
639
-
640
- for (const e of events) {
641
- if (e.kind === "ScenarioStarted") {
642
- scenarioName = e.data.name;
643
- continue;
644
- }
645
-
646
- if (e.kind === "RevertingsStart") {
647
- finalizeCurrentAction();
648
- inCleanup = true;
649
- hasExplicitCleanupActions = false;
650
- pendingCleanupCommand = undefined;
651
- continue;
652
- }
653
-
654
- if (e.kind === "RevertingsEnd") {
655
- finalizeCurrentAction();
656
- if (inCleanup && !hasExplicitCleanupActions && pendingCleanupCommand) {
657
- pushImplicitCleanupAction(pendingCleanupCommand);
658
- pendingCleanupCommand = undefined;
659
- }
660
- inCleanup = false;
661
- continue;
662
- }
663
-
664
- const bdd = bddFromEvent(e);
665
- if (bdd) {
666
- finalizeCurrentAction();
667
- currentBdd = bdd;
668
- continue;
669
- }
670
-
671
- if (e.kind === "ActionStart") {
672
- finalizeCurrentAction();
673
- currentAction = {
674
- ...(currentBdd ? { bdd: currentBdd } : {}),
675
- start: e,
676
- commandPairs: [],
677
- sawCommandBeforeRetryStart: false,
678
- retryStarted: false,
679
- };
680
- if (inCleanup && e.data.phase === "revert") {
681
- hasExplicitCleanupActions = true;
682
- }
683
- continue;
684
- }
685
-
686
- if (e.kind === "CommandRun") {
687
- if (inCleanup && !hasExplicitCleanupActions && !currentAction) {
688
- pendingCleanupCommand = { run: e };
689
- continue;
690
- }
691
- if (currentAction) {
692
- currentAction.commandPairs.push({ run: e });
693
- currentAction.derivedInput = mergeActionInput(
694
- currentAction.derivedInput ?? {},
695
- inferActionInputFromCommand(e)
696
- );
697
- if (!currentAction.retryStarted) {
698
- currentAction.sawCommandBeforeRetryStart = true;
699
- }
700
- }
701
- continue;
702
- }
703
-
704
- if (e.kind === "CommandResult") {
705
- if (inCleanup && !hasExplicitCleanupActions && pendingCleanupCommand) {
706
- pendingCleanupCommand.result = e;
707
- pushImplicitCleanupAction(pendingCleanupCommand);
708
- pendingCleanupCommand = undefined;
709
- continue;
710
- }
711
- if (currentAction) {
712
- const last = currentAction.commandPairs.at(-1);
713
- if (last && !last.result) {
714
- last.result = e;
715
- }
716
- }
717
- continue;
718
- }
719
-
720
- if (e.kind === "RetryStart") {
721
- if (currentAction) {
722
- currentAction.retryStarted = true;
723
- }
724
- continue;
725
- }
726
-
727
- if (e.kind === "RetryEnd") {
728
- if (currentAction) {
729
- currentAction.retryEnd = e;
730
- }
731
- continue;
732
- }
733
-
734
- if (e.kind === "ActionEnd" && currentAction) {
735
- currentAction.end = e;
736
- const error = toActionErrorModel(e.data.error);
737
- if (error) {
738
- currentAction.error = error;
739
- }
740
- actions.push(currentAction);
741
- currentAction = undefined;
742
- }
743
- }
744
-
745
- finalizeCurrentAction();
746
- if (inCleanup && !hasExplicitCleanupActions && pendingCleanupCommand) {
747
- pushImplicitCleanupAction(pendingCleanupCommand);
748
- pendingCleanupCommand = undefined;
749
- }
750
-
751
- const overviewActions = actions.filter(
752
- (a) => a.start.data.phase !== "revert"
753
- );
754
- const cleanupActions = actions.filter((a) => a.start.data.phase === "revert");
755
- const bddOrder = buildBddOrder(overviewActions);
756
-
757
- return { scenarioName, overviewActions, cleanupActions, bddOrder };
758
- }
759
-
760
- // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: markdown report generation is intentionally linear but verbose
761
- async function renderScenarioReport(
762
- model: ScenarioReportModel,
763
- options: { enableANSI: boolean }
764
- ): Promise<string> {
765
- const { scenarioName, overviewActions, cleanupActions, bddOrder } = model;
766
- const enableANSI = options.enableANSI;
767
- const lines: Array<string> = [];
768
- const stdinReplacements: Array<StdinReplacement> = [];
769
- let stdinSeq = 0;
770
-
771
- lines.push(`# ${scenarioName}`);
772
- lines.push("");
773
-
774
- // Overview
775
- lines.push("## Scenario Overview");
776
- lines.push("");
777
- lines.push("| # | Action | Resource | Status |");
778
- lines.push("|---|--------|----------|--------|");
779
- for (const [i, a] of overviewActions.entries()) {
780
- const ok = actionOk(a);
781
- const actionLabel = overviewActionLabel(
782
- a.start.data.action,
783
- a.start.data.phase
784
- );
785
- const resource = resourceForAction(a);
786
- lines.push(
787
- `| ${i + 1} | ${actionLabel} | ${resource} | ${ok ? "✅" : "❌"} |`
788
- );
789
- }
790
- lines.push("");
791
-
792
- // Details (group by BDD in order)
793
- lines.push("## Scenario Details");
794
- lines.push("");
795
-
796
- for (const bdd of bddOrder) {
797
- lines.push(`### ${bdd.keyword}: ${bdd.description}`);
798
- lines.push("");
799
-
800
- for (const a of overviewActions.filter(
801
- (x) => x.bdd && bddKey(x.bdd) === bddKey(bdd)
802
- )) {
803
- const ok = actionOk(a);
804
- const emoji = ok ? "✅" : "❌";
805
- const title = detailsTitleForAction(a);
806
- const failureSuffix = computeFailedAttemptsSuffix(a);
807
-
808
- lines.push(`**${emoji} ${title}**${failureSuffix}`);
809
- lines.push("");
810
-
811
- const chosen = a.commandPairs.at(-1);
812
- if (chosen) {
813
- lines.push("```shell");
814
- if (enableANSI && chosen.run.data.stdin) {
815
- const placeholder = `__KEST_STDIN_${stdinSeq++}__`;
816
- stdinReplacements.push({
817
- placeholder,
818
- stdin: normalizeStdin(chosen.run.data.stdin),
819
- stdinLanguage: chosen.run.data.stdinLanguage ?? "text",
820
- });
821
- lines.push(
822
- formatShellCommandWithPlaceholder(chosen.run, placeholder)
823
- );
824
- } else {
825
- lines.push(formatShellCommand(chosen.run));
826
- }
827
- lines.push("```");
828
- lines.push("");
829
-
830
- const r = chosen.result;
831
- if (r?.data.stdout && r.data.stdout.trim().length > 0) {
832
- const lang = languageOrDefault(r.data.stdoutLanguage);
833
- lines.push(`\`\`\`${lang} title="stdout"`);
834
- lines.push(trimFinalNewline(r.data.stdout));
835
- lines.push("```");
836
- lines.push("");
837
- }
838
- if (r?.data.stderr && r.data.stderr.trim().length > 0) {
839
- const lang = languageOrDefault(r.data.stderrLanguage);
840
- lines.push(`\`\`\`${lang} title="stderr"`);
841
- lines.push(trimFinalNewline(r.data.stderr));
842
- lines.push("```");
843
- lines.push("");
844
- }
845
- }
846
-
847
- if (!ok) {
848
- const error = a.error;
849
- if (error?.message && error.message.trim().length > 0) {
850
- lines.push("Error:");
851
- lines.push("");
852
- lines.push(error.isDiff ? "```diff" : "```text");
853
- const message = trimFinalNewline(stripAnsi(error.message));
854
- const trace =
855
- error.trace && error.trace.trim().length > 0
856
- ? trimFinalNewline(stripAnsi(error.trace))
857
- : undefined;
858
- if (trace) {
859
- lines.push(`${message}\n\nTrace:\n${trace}`);
860
- } else {
861
- lines.push(message);
862
- }
863
- lines.push("```");
864
- lines.push("");
865
- }
866
- }
867
- }
868
- }
869
-
870
- // Cleanup
871
- if (cleanupActions.length > 0) {
872
- lines.push("### Cleanup");
873
- lines.push("");
874
- lines.push("| # | Action | Resource | Status |");
875
- lines.push("|---|--------|----------|--------|");
876
- for (const [i, a] of cleanupActions.entries()) {
877
- const ok = actionOk(a);
878
- const actionLabel = overviewActionLabel(
879
- a.start.data.action,
880
- a.start.data.phase
881
- );
882
- const resource = resourceForAction(a);
883
- lines.push(
884
- `| ${i + 1} | ${actionLabel} | ${resource} | ${ok ? "✅" : "❌"} |`
885
- );
886
- }
887
- lines.push("");
888
-
889
- const sessionLines: Array<string> = [];
890
- for (const a of cleanupActions) {
891
- const chosen = a.commandPairs.at(-1);
892
- if (!chosen) {
893
- continue;
894
- }
895
- if (sessionLines.length > 0) {
896
- sessionLines.push("");
897
- }
898
- sessionLines.push(
899
- `$ ${[chosen.run.data.cmd, ...chosen.run.data.args]
900
- .map(shellEscapeArg)
901
- .join(" ")
902
- .trim()}`
903
- );
904
- const r = chosen.result;
905
- if (r?.data.stdout && r.data.stdout.trim().length > 0) {
906
- sessionLines.push(trimFinalNewline(r.data.stdout));
907
- }
908
- if (r?.data.stderr && r.data.stderr.trim().length > 0) {
909
- sessionLines.push(trimFinalNewline(r.data.stderr));
910
- }
911
- }
912
-
913
- // If there were no commands in cleanup actions, avoid emitting an empty
914
- // shellsession block (e.g. Exec revert without kubectl calls).
915
- if (sessionLines.length > 0) {
916
- lines.push("```shellsession");
917
- lines.push(...sessionLines);
918
- lines.push("```");
919
- lines.push("");
920
- }
921
- }
922
-
923
- // Match preview.md: end with two trailing newlines.
924
- if (lines.at(-1) !== "") {
925
- lines.push("");
926
- }
927
- if (lines.at(-2) !== "") {
928
- lines.push("");
929
- }
930
- const markdown = lines.join("\n");
931
- if (!enableANSI) {
932
- return markdown;
933
- }
934
-
935
- // 1) Highlight markdown itself (including code fences)
936
- // 2) Highlight heredoc stdin separately by its language and splice it in.
937
- const highlightedMarkdown = await highlightCode(markdown, markdownLang);
938
- const highlightedStdinList = await Promise.all(
939
- stdinReplacements.map(async (r) => {
940
- return {
941
- placeholder: r.placeholder,
942
- stdin: await highlightCode(r.stdin, r.stdinLanguage),
943
- stdinLanguage: r.stdinLanguage,
944
- } satisfies StdinReplacement;
945
- })
946
- );
947
-
948
- return applyStdinReplacements(highlightedMarkdown, highlightedStdinList);
949
- }
950
-
951
- export function newMarkdownReporter(
952
- options: MarkdownReporterOptions = {}
953
- ): Reporter {
954
- return {
955
- async report(events: ReadonlyArray<Event>): Promise<string> {
956
- const model = parseScenarioReportModel(events);
957
- return await renderScenarioReport(model, {
958
- enableANSI: options.enableANSI ?? false,
959
- });
960
- },
961
- };
962
- }