@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 +169 -0
- package/package.json +1 -1
- package/ts/apis/index.ts +21 -1
- package/ts/reporter/markdown/index.ts +1 -0
- package/ts/reporter/markdown/parser/index.ts +1 -0
- package/ts/reporter/markdown/renderer/index.ts +86 -27
- package/ts/reporter/markdown/trace/find-user-frame.ts +38 -0
- package/ts/reporter/markdown/trace/parse-stack.ts +65 -0
- package/ts/reporter/markdown/trace/read-snippet.ts +33 -0
- package/ts/reporter/markdown/trace/render.ts +223 -0
- package/ts/test.ts +2 -2
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
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;
|
|
@@ -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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
333
|
+
const resolved = await resolveTraceReplacements(
|
|
334
|
+
markdown,
|
|
335
|
+
traceReplacements,
|
|
336
|
+
options
|
|
337
|
+
);
|
|
338
|
+
return resolved;
|
|
285
339
|
}
|
|
286
|
-
return highlightMarkdown(
|
|
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
|
|
73
|
+
throw new Error(`Scenario failed: ${label}`);
|
|
74
74
|
}
|
|
75
75
|
};
|
|
76
76
|
const report = async (
|