@appthrust/kest 0.7.0 → 0.8.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/README.md CHANGED
@@ -443,6 +443,23 @@ namespace "kest-9hdhj" deleted
443
443
  bun add -d @appthrust/kest
444
444
  ```
445
445
 
446
+ ### TypeScript Configuration
447
+
448
+ If your project doesn't have a `tsconfig.json` yet, install the Bun TypeScript preset and create one:
449
+
450
+ ```sh
451
+ bun add -D @types/bun @tsconfig/bun
452
+ ```
453
+
454
+ ```json
455
+ // tsconfig.json
456
+ {
457
+ "extends": "@tsconfig/bun"
458
+ }
459
+ ```
460
+
461
+ This enables proper type checking, autocompletion, and compatibility with Bun's APIs.
462
+
446
463
  ### Write Your First Test
447
464
 
448
465
  Create a test file, e.g. `my-operator.test.ts`:
@@ -802,6 +819,114 @@ await ns.apply(import("./fixtures/deployment-v1.ts"));
802
819
  - [ ] Is the diff between test steps visible as a spec-level change?
803
820
  - [ ] Can you reconstruct the applied manifest from the failure report alone?
804
821
 
822
+ ### Reuse manifests with mutation for update scenarios
823
+
824
+ The "keep manifests visible" principle works well when each `apply` is independent. But in **update scenarios** -- where a test applies the same resource twice with a small change -- duplicating the entire manifest obscures the intent. The reader has to mentally diff two large blocks to spot what actually changed:
825
+
826
+ ```ts
827
+ // ❌ Bad — the two manifests are nearly identical.
828
+ // The reader must scan every line to find the one difference.
829
+ test("scaling up increases available replicas", async (s) => {
830
+ const ns = await s.newNamespace();
831
+
832
+ s.when("I apply a Deployment with 1 replica");
833
+ await ns.apply({
834
+ apiVersion: "apps/v1",
835
+ kind: "Deployment",
836
+ metadata: { name: "my-app" },
837
+ spec: {
838
+ replicas: 1,
839
+ selector: { matchLabels: { app: "my-app" } },
840
+ template: {
841
+ metadata: { labels: { app: "my-app" } },
842
+ spec: { containers: [{ name: "app", image: "nginx" }] },
843
+ },
844
+ },
845
+ });
846
+
847
+ s.when("I scale to 3 replicas");
848
+ await ns.apply({
849
+ apiVersion: "apps/v1",
850
+ kind: "Deployment",
851
+ metadata: { name: "my-app" },
852
+ spec: {
853
+ replicas: 3, // <-- the only change, buried in a wall of duplication
854
+ selector: { matchLabels: { app: "my-app" } },
855
+ template: {
856
+ metadata: { labels: { app: "my-app" } },
857
+ spec: { containers: [{ name: "app", image: "nginx" }] },
858
+ },
859
+ },
860
+ });
861
+
862
+ s.then("the Deployment should have 3 available replicas");
863
+ await ns.assert({
864
+ apiVersion: "apps/v1",
865
+ kind: "Deployment",
866
+ name: "my-app",
867
+ test() {
868
+ expect(this.status?.availableReplicas).toBe(3);
869
+ },
870
+ });
871
+ });
872
+ ```
873
+
874
+ Instead, define the manifest once, then use `structuredClone` to create a copy and mutate only the fields the scenario cares about. The mutation lines **are** the test scenario -- they tell the reader exactly what changed:
875
+
876
+ ```ts
877
+ // ✅ Good — the base manifest is visible, and the mutation is the scenario.
878
+ test("scaling up increases available replicas", async (s) => {
879
+ const ns = await s.newNamespace();
880
+
881
+ s.when("I apply a Deployment with 1 replica");
882
+ const deployment = {
883
+ apiVersion: "apps/v1" as const,
884
+ kind: "Deployment" as const,
885
+ metadata: { name: "my-app" },
886
+ spec: {
887
+ replicas: 1,
888
+ selector: { matchLabels: { app: "my-app" } },
889
+ template: {
890
+ metadata: { labels: { app: "my-app" } },
891
+ spec: { containers: [{ name: "app", image: "nginx" }] },
892
+ },
893
+ },
894
+ };
895
+ await ns.apply(deployment);
896
+
897
+ s.when("I scale to 3 replicas");
898
+ const scaled = structuredClone(deployment);
899
+ scaled.spec.replicas = 3;
900
+ await ns.apply(scaled);
901
+
902
+ s.then("the Deployment should have 3 available replicas");
903
+ await ns.assert({
904
+ apiVersion: "apps/v1",
905
+ kind: "Deployment",
906
+ name: "my-app",
907
+ test() {
908
+ expect(this.status?.availableReplicas).toBe(3);
909
+ },
910
+ });
911
+ });
912
+ ```
913
+
914
+ `structuredClone` is a standard API (no dependencies) that creates a true deep copy, so mutations never affect the original. The base manifest stays fully visible in the test, and the mutation lines make the intent unmistakable.
915
+
916
+ **When to use which approach:**
917
+
918
+ | Scenario | Approach |
919
+ | --- | --- |
920
+ | Each `apply` is a different resource or independent input | Inline each manifest separately (keep manifests visible) |
921
+ | The same resource is applied twice with a small change | `structuredClone` + targeted mutation |
922
+ | The manifest is too large to inline comfortably | Static fixture files (`import("./fixtures/...")`) |
923
+
924
+ **Mutation-readability checklist:**
925
+
926
+ - [ ] Is the base manifest defined once and fully visible in the test?
927
+ - [ ] Are only the fields relevant to the scenario mutated?
928
+ - [ ] Can a reader understand the update by reading just the mutation lines?
929
+
805
930
  ### Avoiding naming collisions between tests
806
931
 
807
932
  When tests run in parallel, hard-coded resource names can collide. In most cases, `newNamespace()` is all you need -- each test gets its own namespace, so names like `"my-config"` or `"my-app"` are already isolated:
@@ -929,6 +1054,50 @@ await s.create({
929
1054
  - [ ] Is `generateName` reserved for cluster-scoped resources or multi-instance cases?
930
1055
  - [ ] Can you identify every resource in the failure report without decoding random suffixes?
931
1056
 
1057
+ ### Use `toMatchObject` for assertions without type parameters
1058
+
1059
+ When you omit the type parameter from assertion actions (`assert`, `assertList`,
1060
+ `assertOne`, etc.), `this` is typed as the generic `K8sResource`. Custom fields
1061
+ like `spec` become `unknown`, forcing type casts on every access:
1062
+
1063
+ ```ts
1064
+ // ❌ Without a type parameter, every field access needs a cast
1065
+ await ns.assert({
1066
+ apiVersion: "example.com/v1",
1067
+ kind: "MyResource",
1068
+ name: "my-instance",
1069
+ test() {
1070
+ expect((this.spec as any).replicas).toBe(3);
1071
+ expect((this.spec as any).image).toBe("my-app:latest");
1072
+ },
1073
+ });
1074
+ ```
1075
+
1076
+ Use `toMatchObject` instead — it accepts a plain object and checks that the
1077
+ resource contains the expected subset, with no type narrowing required:
1078
+
1079
+ ```ts
1080
+ // ✅ toMatchObject — no casts, no type parameter needed
1081
+ await ns.assert({
1082
+ apiVersion: "example.com/v1",
1083
+ kind: "MyResource",
1084
+ name: "my-instance",
1085
+ test() {
1086
+ expect(this).toMatchObject({
1087
+ spec: {
1088
+ replicas: 3,
1089
+ image: "my-app:latest",
1090
+ },
1091
+ });
1092
+ },
1093
+ });
1094
+ ```
1095
+
1096
+ **When to use which:**
1097
+
1098
+ - **With a type parameter** (e.g. `assert<MyResource>`) — either style works; `this` is fully typed, so it's a matter of preference.
1099
+ - **Without a type parameter** — always use `toMatchObject`. `this.spec` is `unknown`, and `toMatchObject` scales naturally as you add more assertions.
1100
+
932
1101
  ## Type Safety
933
1102
 
934
1103
  Define TypeScript interfaces for your Kubernetes resources to get full type checking in manifests and assertions:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appthrust/kest",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Kubernetes E2E testing framework designed for humans and AI alike",
5
5
  "type": "module",
6
6
  "module": "ts/index.ts",
package/ts/apis/index.ts CHANGED
@@ -1487,7 +1487,27 @@ export interface K8sResource {
1487
1487
  kind: string;
1488
1488
  metadata: {
1489
1489
  name: string;
1490
- namespace?: string;
1490
+ namespace?: string | undefined;
1491
+ labels?: Record<string, string> | undefined;
1492
+ annotations?: Record<string, string> | undefined;
1493
+ resourceVersion?: string | undefined;
1494
+ uid?: string | undefined;
1495
+ creationTimestamp?: string | undefined;
1496
+ generation?: number | undefined;
1497
+ finalizers?: ReadonlyArray<string> | undefined;
1498
+ ownerReferences?:
1499
+ | ReadonlyArray<{
1500
+ apiVersion: string;
1501
+ kind: string;
1502
+ name: string;
1503
+ uid: string;
1504
+ controller?: boolean | undefined;
1505
+ blockOwnerDeletion?: boolean | undefined;
1506
+ }>
1507
+ | undefined;
1508
+ deletionTimestamp?: string | undefined;
1509
+ deletionGracePeriodSeconds?: number | undefined;
1510
+ generateName?: string | undefined;
1491
1511
  [key: string]: unknown;
1492
1512
  };
1493
1513
  [key: string]: unknown;
@@ -9,6 +9,7 @@ export interface MarkdownReporterOptions {
9
9
  * If false (default), remove ANSI escape codes.
10
10
  */
11
11
  enableANSI?: undefined | boolean;
12
+ workspaceRoot?: undefined | string;
12
13
  }
13
14
 
14
15
  export function newMarkdownReporter(
@@ -166,6 +166,7 @@ function applyRegularActionEnd(
166
166
  ? "diff"
167
167
  : "text",
168
168
  },
169
+ stack: event.data.error.stack,
169
170
  };
170
171
  }
171
172
 
@@ -2,6 +2,7 @@ import { codeToANSIForcedColors } from "../../shiki";
2
2
  import type { MarkdownReporterOptions } from "../index";
3
3
  import type { Action, Report } from "../model";
4
4
  import { stripAnsi } from "../strip-ansi";
5
+ import { renderTrace } from "../trace/render";
5
6
 
6
7
  const markdownLang = "markdown";
7
8
  const markdownTheme = "catppuccin-mocha";
@@ -12,6 +13,11 @@ type StdinReplacement = Readonly<{
12
13
  stdinLanguage: string;
13
14
  }>;
14
15
 
16
+ type TraceReplacement = Readonly<{
17
+ placeholder: string;
18
+ rawStack: string;
19
+ }>;
20
+
15
21
  function normalizeStdin(stdin: string): string {
16
22
  // Match `ts/reporter/markdown.ts` behavior: keep content stable.
17
23
  return stdin.replace(/^\n/, "").replace(/\s+$/, "");
@@ -42,9 +48,40 @@ function applyStdinReplacements(
42
48
  return current;
43
49
  }
44
50
 
51
+ async function resolveTraceReplacements(
52
+ markdown: string,
53
+ replacements: ReadonlyArray<TraceReplacement>,
54
+ options: MarkdownReporterOptions
55
+ ): Promise<string> {
56
+ if (replacements.length === 0) {
57
+ return markdown;
58
+ }
59
+
60
+ let current = markdown;
61
+ for (const r of replacements) {
62
+ const rendered = await renderTrace(r.rawStack, {
63
+ workspaceRoot: options.workspaceRoot,
64
+ enableANSI: options.enableANSI,
65
+ });
66
+
67
+ if (rendered) {
68
+ current = current.replace(r.placeholder, rendered);
69
+ } else {
70
+ // Remove the entire trace code block (fences + placeholder + blank line)
71
+ current = current.replace(
72
+ `\`\`\`ts title="trace"\n${r.placeholder}\n\`\`\`\n\n`,
73
+ ""
74
+ );
75
+ }
76
+ }
77
+ return current;
78
+ }
79
+
45
80
  async function highlightMarkdown(
46
81
  markdown: string,
47
- stdinReplacements: ReadonlyArray<StdinReplacement>
82
+ stdinReplacements: ReadonlyArray<StdinReplacement>,
83
+ traceReplacements: ReadonlyArray<TraceReplacement>,
84
+ options: MarkdownReporterOptions
48
85
  ): Promise<string> {
49
86
  const stripped = stripAnsi(markdown);
50
87
  try {
@@ -54,31 +91,29 @@ async function highlightMarkdown(
54
91
  markdownTheme
55
92
  );
56
93
 
57
- if (stdinReplacements.length === 0) {
58
- // Keep output shape stable: always end with a single trailing newline.
59
- return highlightedMarkdown.replace(/\n+$/, "\n");
94
+ let result = highlightedMarkdown;
95
+
96
+ if (stdinReplacements.length > 0) {
97
+ const highlightedStdinList = await Promise.all(
98
+ stdinReplacements.map(async (r) => {
99
+ const highlightedStdin = await codeToANSIForcedColors(
100
+ r.stdin,
101
+ r.stdinLanguage,
102
+ markdownTheme
103
+ );
104
+ // Avoid inserting an extra blank line before `EOF`.
105
+ const trimmed = trimFinalNewline(
106
+ highlightedStdin.replace(/\n+$/, "\n")
107
+ );
108
+ return { ...r, stdin: trimmed } satisfies StdinReplacement;
109
+ })
110
+ );
111
+ result = applyStdinReplacements(result, highlightedStdinList);
60
112
  }
61
113
 
62
- const highlightedStdinList = await Promise.all(
63
- stdinReplacements.map(async (r) => {
64
- const highlightedStdin = await codeToANSIForcedColors(
65
- r.stdin,
66
- r.stdinLanguage,
67
- markdownTheme
68
- );
69
- // Avoid inserting an extra blank line before `EOF`.
70
- const trimmed = trimFinalNewline(
71
- highlightedStdin.replace(/\n+$/, "\n")
72
- );
73
- return { ...r, stdin: trimmed } satisfies StdinReplacement;
74
- })
75
- );
114
+ result = await resolveTraceReplacements(result, traceReplacements, options);
76
115
 
77
- const replaced = applyStdinReplacements(
78
- highlightedMarkdown,
79
- highlightedStdinList
80
- );
81
- return replaced.replace(/\n+$/, "\n");
116
+ return result.replace(/\n+$/, "\n");
82
117
  } catch {
83
118
  return stripped;
84
119
  }
@@ -106,7 +141,7 @@ function statusEmoji(status: keyof typeof statusEmojiByStatus): string {
106
141
  }
107
142
 
108
143
  // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: rendering is intentionally linear and explicit
109
- export function renderReport(
144
+ export async function renderReport(
110
145
  report: Report,
111
146
  options: MarkdownReporterOptions
112
147
  ): Promise<string> {
@@ -114,7 +149,9 @@ export function renderReport(
114
149
 
115
150
  const renderedScenarios: Array<string> = [];
116
151
  const stdinReplacements: Array<StdinReplacement> = [];
152
+ const traceReplacements: Array<TraceReplacement> = [];
117
153
  let stdinSeq = 0;
154
+ let traceSeq = 0;
118
155
 
119
156
  for (const scenario of report.scenarios) {
120
157
  const isEmpty =
@@ -222,10 +259,22 @@ export function renderReport(
222
259
  const lang = action.error.message.language ?? "text";
223
260
  lines.push("Error:");
224
261
  lines.push("");
225
- lines.push(`\`\`\`${lang}`);
262
+ lines.push(`\`\`\`${lang} title="message"`);
226
263
  lines.push(trimFinalNewline(messageText));
227
264
  lines.push("```");
228
265
  lines.push("");
266
+
267
+ if (action.error?.stack) {
268
+ const placeholder = `__KEST_TRACE_${traceSeq++}__`;
269
+ traceReplacements.push({
270
+ placeholder,
271
+ rawStack: action.error.stack,
272
+ });
273
+ lines.push('```ts title="trace"');
274
+ lines.push(placeholder);
275
+ lines.push("```");
276
+ lines.push("");
277
+ }
229
278
  }
230
279
  };
231
280
 
@@ -281,7 +330,17 @@ export function renderReport(
281
330
  const rendered = renderedScenarios.join("\n\n");
282
331
  const markdown = rendered.endsWith("\n") ? rendered : `${rendered}\n`;
283
332
  if (!enableANSI) {
284
- return Promise.resolve(markdown);
333
+ const resolved = await resolveTraceReplacements(
334
+ markdown,
335
+ traceReplacements,
336
+ options
337
+ );
338
+ return resolved;
285
339
  }
286
- return highlightMarkdown(markdown, stdinReplacements);
340
+ return highlightMarkdown(
341
+ markdown,
342
+ stdinReplacements,
343
+ traceReplacements,
344
+ options
345
+ );
287
346
  }
@@ -0,0 +1,38 @@
1
+ import type { StackFrame } from "./parse-stack";
2
+
3
+ export function findUserFrame(
4
+ frames: Array<StackFrame>,
5
+ workspaceRoot: string
6
+ ): StackFrame | undefined {
7
+ const root = workspaceRoot.endsWith("/")
8
+ ? workspaceRoot
9
+ : `${workspaceRoot}/`;
10
+
11
+ for (const frame of frames) {
12
+ const { filePath } = frame;
13
+
14
+ if (filePath.includes("unknown")) {
15
+ continue;
16
+ }
17
+ if (filePath.startsWith("<")) {
18
+ continue;
19
+ }
20
+ if (filePath.includes("/node_modules/")) {
21
+ continue;
22
+ }
23
+ if (filePath.startsWith("native:")) {
24
+ continue;
25
+ }
26
+
27
+ if (filePath.startsWith(root)) {
28
+ const relative = filePath.slice(root.length);
29
+ if (relative.startsWith("ts/")) {
30
+ continue;
31
+ }
32
+ }
33
+
34
+ return frame;
35
+ }
36
+
37
+ return undefined;
38
+ }
@@ -0,0 +1,65 @@
1
+ export interface StackFrame {
2
+ funcName?: string;
3
+ filePath: string;
4
+ line: number;
5
+ col: number;
6
+ }
7
+
8
+ // Pattern: at funcName (filePath:line:col)
9
+ const withFuncAndParens =
10
+ /^\s*at\s+(?:async\s+)?(.+?)\s+\((.+):(\d+):(\d+)\)\s*$/;
11
+
12
+ // Pattern: at (filePath:line:col)
13
+ const withParensNoFunc = /^\s*at\s+(?:async\s+)?\((.+):(\d+):(\d+)\)\s*$/;
14
+
15
+ // Pattern: at filePath:line:col
16
+ const bareLocation = /^\s*at\s+(?:async\s+)?(.+):(\d+):(\d+)\s*$/;
17
+
18
+ export function parseStack(rawStack: string): Array<StackFrame> {
19
+ if (!rawStack) {
20
+ return [];
21
+ }
22
+
23
+ const lines = rawStack.split("\n");
24
+ const frames: Array<StackFrame> = [];
25
+
26
+ for (const line of lines) {
27
+ if (!/^\s*at\s/.test(line)) {
28
+ continue;
29
+ }
30
+
31
+ let match: RegExpMatchArray | null;
32
+
33
+ match = line.match(withParensNoFunc);
34
+ if (match) {
35
+ frames.push({
36
+ filePath: match[1] as string,
37
+ line: Number(match[2]),
38
+ col: Number(match[3]),
39
+ });
40
+ continue;
41
+ }
42
+
43
+ match = line.match(withFuncAndParens);
44
+ if (match) {
45
+ frames.push({
46
+ funcName: match[1] as string,
47
+ filePath: match[2] as string,
48
+ line: Number(match[3]),
49
+ col: Number(match[4]),
50
+ });
51
+ continue;
52
+ }
53
+
54
+ match = line.match(bareLocation);
55
+ if (match) {
56
+ frames.push({
57
+ filePath: match[1] as string,
58
+ line: Number(match[2]),
59
+ col: Number(match[3]),
60
+ });
61
+ }
62
+ }
63
+
64
+ return frames;
65
+ }
@@ -0,0 +1,33 @@
1
+ export interface Snippet {
2
+ lines: Array<{ lineNumber: number; code: string }>;
3
+ caretCol: number;
4
+ }
5
+
6
+ export async function readSnippet(
7
+ filePath: string,
8
+ line: number,
9
+ col: number,
10
+ contextLines = 5
11
+ ): Promise<Snippet | undefined> {
12
+ let content: string;
13
+ try {
14
+ content = await Bun.file(filePath).text();
15
+ } catch {
16
+ return undefined;
17
+ }
18
+
19
+ const allLines = content.split("\n");
20
+
21
+ if (line < 1 || line > allLines.length) {
22
+ return undefined;
23
+ }
24
+
25
+ const start = Math.max(1, line - contextLines);
26
+ const extracted: Array<{ lineNumber: number; code: string }> = [];
27
+
28
+ for (let i = start; i <= line; i++) {
29
+ extracted.push({ lineNumber: i, code: allLines[i - 1] as string });
30
+ }
31
+
32
+ return { lines: extracted, caretCol: col };
33
+ }
@@ -0,0 +1,223 @@
1
+ import { codeToANSIForcedColors } from "../../shiki";
2
+ import { findUserFrame } from "./find-user-frame";
3
+ import type { StackFrame } from "./parse-stack";
4
+ import { parseStack } from "./parse-stack";
5
+ import type { Snippet } from "./read-snippet";
6
+ import { readSnippet } from "./read-snippet";
7
+
8
+ export interface RenderTraceOptions {
9
+ workspaceRoot?: undefined | string;
10
+ enableANSI?: undefined | boolean;
11
+ }
12
+
13
+ // Catppuccin Mocha palette
14
+ const overlay0 = "#6c7086";
15
+ const subtext0 = "#a6adc8";
16
+ const textColor = "#cdd6f4";
17
+ const peach = "#fab387";
18
+ const flamingo = "#f2cdcd";
19
+ const red = "#f38ba8";
20
+ const maroon = "#eba0ac";
21
+
22
+ function hexToRgb(hex: string): [number, number, number] {
23
+ const h = hex.replace("#", "");
24
+ return [
25
+ Number.parseInt(h.slice(0, 2), 16),
26
+ Number.parseInt(h.slice(2, 4), 16),
27
+ Number.parseInt(h.slice(4, 6), 16),
28
+ ];
29
+ }
30
+
31
+ function fg(hex: string, s: string): string {
32
+ const [r, g, b] = hexToRgb(hex);
33
+ return `\x1b[38;2;${r};${g};${b}m${s}\x1b[0m`;
34
+ }
35
+
36
+ function fgBold(hex: string, s: string): string {
37
+ const [r, g, b] = hexToRgb(hex);
38
+ return `\x1b[1;38;2;${r};${g};${b}m${s}\x1b[0m`;
39
+ }
40
+
41
+ function formatSnippetPlain(
42
+ snippet: Snippet,
43
+ gutterWidth: number
44
+ ): Array<string> {
45
+ const parts: Array<string> = [];
46
+ for (const { lineNumber, code } of snippet.lines) {
47
+ const num = String(lineNumber).padStart(gutterWidth);
48
+ parts.push(`${num} | ${code}`);
49
+ }
50
+ const caretOffset = gutterWidth + " | ".length + snippet.caretCol - 1;
51
+ parts.push(`${" ".repeat(caretOffset)}^`);
52
+ return parts;
53
+ }
54
+
55
+ async function formatSnippetANSI(
56
+ snippet: Snippet,
57
+ gutterWidth: number
58
+ ): Promise<Array<string>> {
59
+ // Build the snippet text with line numbers for Shiki highlighting
60
+ const snippetText = snippet.lines
61
+ .map(({ lineNumber, code }) => {
62
+ const num = String(lineNumber).padStart(gutterWidth);
63
+ return `${num} | ${code}`;
64
+ })
65
+ .join("\n");
66
+
67
+ const highlighted = await codeToANSIForcedColors(
68
+ snippetText,
69
+ "typescript",
70
+ "catppuccin-mocha"
71
+ );
72
+
73
+ const lines = highlighted.replace(/\n$/, "").split("\n");
74
+
75
+ // Add bold Maroon caret line
76
+ const caretOffset = gutterWidth + " | ".length + snippet.caretCol - 1;
77
+ lines.push(fgBold(maroon, `${" ".repeat(caretOffset)}^`));
78
+
79
+ return lines;
80
+ }
81
+
82
+ function formatFramePlain(frame: StackFrame): string {
83
+ if (frame.funcName) {
84
+ return `at ${frame.funcName} (${frame.filePath}:${frame.line}:${frame.col})`;
85
+ }
86
+ return `at ${frame.filePath}:${frame.line}:${frame.col}`;
87
+ }
88
+
89
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: rendering is intentionally linear and explicit
90
+ function formatFrameANSI(
91
+ frame: StackFrame,
92
+ workspaceRoot: string | undefined,
93
+ snippetFrame: StackFrame | undefined
94
+ ): string {
95
+ let root: string | undefined;
96
+ if (workspaceRoot) {
97
+ root = workspaceRoot.endsWith("/") ? workspaceRoot : `${workspaceRoot}/`;
98
+ }
99
+
100
+ // Determine if this frame matches the snippet source (both file AND line)
101
+ let isSnippetSource = false;
102
+ if (snippetFrame && root) {
103
+ const frameRel = frame.filePath.startsWith(root)
104
+ ? frame.filePath.slice(root.length)
105
+ : frame.filePath;
106
+ const snippetRel = snippetFrame.filePath.startsWith(root)
107
+ ? snippetFrame.filePath.slice(root.length)
108
+ : snippetFrame.filePath;
109
+ isSnippetSource =
110
+ frameRel === snippetRel && frame.line === snippetFrame.line;
111
+ }
112
+
113
+ const parts: Array<string> = [];
114
+
115
+ // "at"
116
+ parts.push(fg(overlay0, "at"));
117
+
118
+ // function name (if present)
119
+ if (frame.funcName) {
120
+ parts.push(" ");
121
+ parts.push(fg(textColor, frame.funcName));
122
+ parts.push(" ");
123
+ parts.push(fg(overlay0, "("));
124
+ } else {
125
+ parts.push(" ");
126
+ }
127
+
128
+ // Split workspace root vs relative path
129
+ let wsRoot = "";
130
+ let relPath = frame.filePath;
131
+ if (root && frame.filePath.startsWith(root)) {
132
+ wsRoot = root;
133
+ relPath = frame.filePath.slice(root.length);
134
+ }
135
+
136
+ // workspace root part
137
+ if (wsRoot) {
138
+ parts.push(fg(subtext0, wsRoot));
139
+ }
140
+
141
+ // file name part
142
+ if (isSnippetSource) {
143
+ parts.push(fgBold(red, relPath));
144
+ } else {
145
+ parts.push(fg(peach, relPath));
146
+ }
147
+
148
+ // colon
149
+ parts.push(fg(overlay0, ":"));
150
+
151
+ // line number
152
+ if (isSnippetSource) {
153
+ parts.push(fgBold(maroon, String(frame.line)));
154
+ } else {
155
+ parts.push(fg(flamingo, String(frame.line)));
156
+ }
157
+
158
+ // colon
159
+ parts.push(fg(overlay0, ":"));
160
+
161
+ // column number
162
+ parts.push(fg(overlay0, String(frame.col)));
163
+
164
+ if (frame.funcName) {
165
+ parts.push(fg(overlay0, ")"));
166
+ }
167
+
168
+ return parts.join("");
169
+ }
170
+
171
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: rendering is intentionally linear and explicit
172
+ export async function renderTrace(
173
+ rawStack: string,
174
+ options: RenderTraceOptions
175
+ ): Promise<string | undefined> {
176
+ const frames = parseStack(rawStack);
177
+ if (frames.length === 0) {
178
+ return undefined;
179
+ }
180
+
181
+ const enableANSI = options.enableANSI ?? false;
182
+ const parts: Array<string> = [];
183
+ let snippetFrame: StackFrame | undefined;
184
+
185
+ // Try to get snippet if workspaceRoot is provided
186
+ if (options.workspaceRoot) {
187
+ const userFrame = findUserFrame(frames, options.workspaceRoot);
188
+ if (userFrame) {
189
+ const snippet = await readSnippet(
190
+ userFrame.filePath,
191
+ userFrame.line,
192
+ userFrame.col
193
+ );
194
+ if (snippet) {
195
+ snippetFrame = userFrame;
196
+ const maxLineNum = Math.max(...snippet.lines.map((l) => l.lineNumber));
197
+ const gutterWidth = String(maxLineNum).length;
198
+
199
+ if (enableANSI) {
200
+ parts.push(...(await formatSnippetANSI(snippet, gutterWidth)));
201
+ } else {
202
+ parts.push(...formatSnippetPlain(snippet, gutterWidth));
203
+ }
204
+
205
+ // Blank line separator between snippet and frames
206
+ parts.push("");
207
+ }
208
+ }
209
+ }
210
+
211
+ // Format stack frames
212
+ if (enableANSI) {
213
+ for (const frame of frames) {
214
+ parts.push(formatFrameANSI(frame, options.workspaceRoot, snippetFrame));
215
+ }
216
+ } else {
217
+ for (const frame of frames) {
218
+ parts.push(formatFramePlain(frame));
219
+ }
220
+ }
221
+
222
+ return parts.join("\n");
223
+ }
package/ts/test.ts CHANGED
@@ -51,7 +51,7 @@ function makeScenarioTest(runner: BunTestRunner): TestFunction {
51
51
  const recorder = new Recorder();
52
52
  const kubectl = createKubectl({ recorder, cwd: workspaceRoot });
53
53
  const reverting = createReverting({ recorder });
54
- const reporter = newMarkdownReporter({ enableANSI: true });
54
+ const reporter = newMarkdownReporter({ enableANSI: true, workspaceRoot });
55
55
  const scenario = createScenario({
56
56
  name: label,
57
57
  recorder,
@@ -70,7 +70,7 @@ function makeScenarioTest(runner: BunTestRunner): TestFunction {
70
70
  recorder.record("ScenarioEnd", {});
71
71
  await report(recorder, scenario, testErr);
72
72
  if (testErr) {
73
- throw testErr;
73
+ throw new Error(`Scenario failed: ${label}`);
74
74
  }
75
75
  };
76
76
  const report = async (