@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 +169 -0
- package/example/example.test.ts +28 -0
- package/package.json +1 -1
- package/ts/apis/index.ts +21 -1
- package/ts/recording/index.ts +1 -0
- package/ts/reporter/markdown/index.ts +1 -0
- package/ts/reporter/markdown/parser/index.ts +19 -2
- package/ts/reporter/markdown/renderer/index.ts +87 -36
- package/ts/reporter/markdown/strip-ansi.ts +8 -0
- 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/retry.ts +1 -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/example/example.test.ts
CHANGED
|
@@ -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
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;
|
package/ts/recording/index.ts
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
333
|
+
const resolved = await resolveTraceReplacements(
|
|
334
|
+
markdown,
|
|
335
|
+
traceReplacements,
|
|
336
|
+
options
|
|
337
|
+
);
|
|
338
|
+
return resolved;
|
|
293
339
|
}
|
|
294
|
-
return highlightMarkdown(
|
|
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
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 (
|