@appthrust/kest 0.6.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:
@@ -60,6 +60,34 @@ test("Example: applies ConfigMap using YAML, file import, and object literal", a
60
60
  // 4. Namespace
61
61
  });
62
62
 
63
+ test("Example: diff demo - ConfigMap data mismatch (expected to fail)", async (s) => {
64
+ s.given("a new namespace exists");
65
+ const ns = await s.newNamespace();
66
+
67
+ s.when("I apply a ConfigMap with actual data");
68
+ await ns.apply<ConfigMap>({
69
+ apiVersion: "v1",
70
+ kind: "ConfigMap",
71
+ metadata: { name: "diff-demo" },
72
+ data: { mode: "actual-value", env: "production" },
73
+ });
74
+
75
+ s.then("asserting with different expected data should produce a diff");
76
+ await ns.assert<ConfigMap>({
77
+ apiVersion: "v1",
78
+ kind: "ConfigMap",
79
+ name: "diff-demo",
80
+ test() {
81
+ expect(this).toMatchObject({
82
+ data: {
83
+ mode: "expected-value",
84
+ env: "staging",
85
+ },
86
+ });
87
+ },
88
+ });
89
+ });
90
+
63
91
  test("Example: asserts a non-existent ConfigMap (expected to fail)", async (s) => {
64
92
  s.given("a new namespace exists");
65
93
  const ns = await s.newNamespace();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appthrust/kest",
3
- "version": "0.6.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;
@@ -53,6 +53,7 @@ type CommandEvent =
53
53
 
54
54
  type RetryEvent =
55
55
  | BaseEvent<"RetryStart", Record<string, never>>
56
+ | BaseEvent<"RetryAttempt", { readonly attempt: number }>
56
57
  | BaseEvent<
57
58
  "RetryEnd",
58
59
  | {
@@ -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(
@@ -7,6 +7,7 @@ import type {
7
7
  Report,
8
8
  Scenario,
9
9
  } from "../model";
10
+ import { stripAnsi } from "../strip-ansi";
10
11
 
11
12
  const bddKeywordByKind = {
12
13
  BDDGiven: "given",
@@ -89,6 +90,9 @@ function handleNonBDDEvent(state: ParseState, event: Event): void {
89
90
  case "RetryEnd":
90
91
  handleRetryEnd(state, event);
91
92
  return;
93
+ case "RetryAttempt":
94
+ handleRetryAttempt(state);
95
+ return;
92
96
  case "RetryStart":
93
97
  return;
94
98
  default:
@@ -158,8 +162,11 @@ function applyRegularActionEnd(
158
162
  currentAction.error = {
159
163
  message: {
160
164
  text: event.data.error.message,
161
- language: isDiffLike(event.data.error.message) ? "diff" : "text",
165
+ language: isDiffLike(stripAnsi(event.data.error.message))
166
+ ? "diff"
167
+ : "text",
162
168
  },
169
+ stack: event.data.error.stack,
163
170
  };
164
171
  }
165
172
 
@@ -263,6 +270,16 @@ function handleCleanupActionEnd(
263
270
  return true;
264
271
  }
265
272
 
273
+ function handleRetryAttempt(state: ParseState): void {
274
+ if (state.inCleanup) {
275
+ return;
276
+ }
277
+ if (!state.currentAction) {
278
+ return;
279
+ }
280
+ state.currentAction.commands = [];
281
+ }
282
+
266
283
  function handleRetryEnd(
267
284
  state: ParseState,
268
285
  event: Extract<Event, { kind: "RetryEnd" }>
@@ -342,7 +359,7 @@ function bddFromEvent(event: Event): BDDSection | undefined {
342
359
  return { keyword, description: event.data.description, actions: [] };
343
360
  }
344
361
 
345
- function isDiffLike(message: string): boolean {
362
+ export function isDiffLike(message: string): boolean {
346
363
  const lines = message.split(/\r?\n/);
347
364
  let sawPlus = false;
348
365
  let sawMinus = false;
@@ -1,6 +1,8 @@
1
1
  import { codeToANSIForcedColors } from "../../shiki";
2
2
  import type { MarkdownReporterOptions } from "../index";
3
3
  import type { Action, Report } from "../model";
4
+ import { stripAnsi } from "../strip-ansi";
5
+ import { renderTrace } from "../trace/render";
4
6
 
5
7
  const markdownLang = "markdown";
6
8
  const markdownTheme = "catppuccin-mocha";
@@ -11,6 +13,11 @@ type StdinReplacement = Readonly<{
11
13
  stdinLanguage: string;
12
14
  }>;
13
15
 
16
+ type TraceReplacement = Readonly<{
17
+ placeholder: string;
18
+ rawStack: string;
19
+ }>;
20
+
14
21
  function normalizeStdin(stdin: string): string {
15
22
  // Match `ts/reporter/markdown.ts` behavior: keep content stable.
16
23
  return stdin.replace(/^\n/, "").replace(/\s+$/, "");
@@ -41,9 +48,40 @@ function applyStdinReplacements(
41
48
  return current;
42
49
  }
43
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
+
44
80
  async function highlightMarkdown(
45
81
  markdown: string,
46
- stdinReplacements: ReadonlyArray<StdinReplacement>
82
+ stdinReplacements: ReadonlyArray<StdinReplacement>,
83
+ traceReplacements: ReadonlyArray<TraceReplacement>,
84
+ options: MarkdownReporterOptions
47
85
  ): Promise<string> {
48
86
  const stripped = stripAnsi(markdown);
49
87
  try {
@@ -53,45 +91,34 @@ async function highlightMarkdown(
53
91
  markdownTheme
54
92
  );
55
93
 
56
- if (stdinReplacements.length === 0) {
57
- // Keep output shape stable: always end with a single trailing newline.
58
- 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);
59
112
  }
60
113
 
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
- );
114
+ result = await resolveTraceReplacements(result, traceReplacements, options);
75
115
 
76
- const replaced = applyStdinReplacements(
77
- highlightedMarkdown,
78
- highlightedStdinList
79
- );
80
- return replaced.replace(/\n+$/, "\n");
116
+ return result.replace(/\n+$/, "\n");
81
117
  } catch {
82
118
  return stripped;
83
119
  }
84
120
  }
85
121
 
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
122
  function trimFinalNewline(input: string): string {
96
123
  return input.replace(/\n$/, "");
97
124
  }
@@ -114,7 +141,7 @@ function statusEmoji(status: keyof typeof statusEmojiByStatus): string {
114
141
  }
115
142
 
116
143
  // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: rendering is intentionally linear and explicit
117
- export function renderReport(
144
+ export async function renderReport(
118
145
  report: Report,
119
146
  options: MarkdownReporterOptions
120
147
  ): Promise<string> {
@@ -122,7 +149,9 @@ export function renderReport(
122
149
 
123
150
  const renderedScenarios: Array<string> = [];
124
151
  const stdinReplacements: Array<StdinReplacement> = [];
152
+ const traceReplacements: Array<TraceReplacement> = [];
125
153
  let stdinSeq = 0;
154
+ let traceSeq = 0;
126
155
 
127
156
  for (const scenario of report.scenarios) {
128
157
  const isEmpty =
@@ -230,10 +259,22 @@ export function renderReport(
230
259
  const lang = action.error.message.language ?? "text";
231
260
  lines.push("Error:");
232
261
  lines.push("");
233
- lines.push(`\`\`\`${lang}`);
262
+ lines.push(`\`\`\`${lang} title="message"`);
234
263
  lines.push(trimFinalNewline(messageText));
235
264
  lines.push("```");
236
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
+ }
237
278
  }
238
279
  };
239
280
 
@@ -289,7 +330,17 @@ export function renderReport(
289
330
  const rendered = renderedScenarios.join("\n\n");
290
331
  const markdown = rendered.endsWith("\n") ? rendered : `${rendered}\n`;
291
332
  if (!enableANSI) {
292
- return Promise.resolve(markdown);
333
+ const resolved = await resolveTraceReplacements(
334
+ markdown,
335
+ traceReplacements,
336
+ options
337
+ );
338
+ return resolved;
293
339
  }
294
- return highlightMarkdown(markdown, stdinReplacements);
340
+ return highlightMarkdown(
341
+ markdown,
342
+ stdinReplacements,
343
+ traceReplacements,
344
+ options
345
+ );
295
346
  }
@@ -0,0 +1,8 @@
1
+ export function stripAnsi(input: string): string {
2
+ // Prefer Bun's built-in ANSI stripper when available.
3
+ if (typeof Bun !== "undefined" && typeof Bun.stripANSI === "function") {
4
+ return Bun.stripANSI(input);
5
+ }
6
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: intended
7
+ return input.replace(/\u001b\[[0-9;]*m/g, "");
8
+ }
@@ -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/retry.ts CHANGED
@@ -77,6 +77,7 @@ export async function retryUntil<T>(
77
77
  }
78
78
 
79
79
  retries += 1;
80
+ recorder?.record("RetryAttempt", { attempt: retries });
80
81
 
81
82
  try {
82
83
  const value = await fn();
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 (