@nwire/studio 0.12.1 → 0.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -3
- package/src/App.vue +62 -56
- package/src/components/BcCard.stories.ts +47 -0
- package/src/components/BcCard.vue +152 -0
- package/src/components/DurationBar.stories.ts +55 -0
- package/src/components/DurationBar.vue +72 -0
- package/src/components/ErrorCard.stories.ts +133 -0
- package/src/components/ErrorCard.vue +153 -0
- package/src/components/GraphCanvas.stories.ts +48 -0
- package/src/components/GraphCanvas.vue +88 -0
- package/src/components/KpiTile.stories.ts +32 -0
- package/src/components/KpiTile.vue +39 -0
- package/src/components/LiveTable.stories.ts +78 -0
- package/src/components/LiveTable.vue +186 -0
- package/src/components/MetadataInspector.stories.ts +53 -0
- package/src/components/MetadataInspector.vue +105 -0
- package/src/components/NodeCard.stories.ts +44 -0
- package/src/components/NodeCard.vue +150 -0
- package/src/components/RcaPanel.stories.ts +95 -0
- package/src/components/RcaPanel.vue +223 -0
- package/src/components/ServiceNode.vue +134 -0
- package/src/components/SourceDrawer.vue +6 -4
- package/src/components/SourcePill.vue +10 -3
- package/src/components/StatusBadge.stories.ts +33 -0
- package/src/components/StatusBadge.vue +54 -0
- package/src/components/Waterfall.stories.ts +85 -0
- package/src/components/Waterfall.vue +53 -0
- package/src/components/WaterfallRow.vue +74 -0
- package/src/components/__tests__/BcCard.test.ts +53 -0
- package/src/components/__tests__/DurationBar.test.ts +31 -0
- package/src/components/__tests__/ErrorCard.test.ts +71 -0
- package/src/components/__tests__/KpiTile.test.ts +23 -0
- package/src/components/__tests__/LiveTable.test.ts +100 -0
- package/src/components/__tests__/MetadataInspector.test.ts +38 -0
- package/src/components/__tests__/NodeCard.test.ts +116 -0
- package/src/components/__tests__/RcaPanel.test.ts +81 -0
- package/src/components/__tests__/StatusBadge.test.ts +23 -0
- package/src/components/__tests__/Waterfall.test.ts +54 -0
- package/src/components/index.ts +13 -0
- package/src/composables/__tests__/composables-context.test.ts +107 -0
- package/src/composables/__tests__/useTelemetry.test.ts +104 -0
- package/src/composables/__tests__/useTelemetryRuns.test.ts +282 -0
- package/src/composables/useDiscovery.ts +73 -0
- package/src/composables/useEndpoints.ts +94 -0
- package/src/composables/useLogTail.ts +51 -0
- package/src/composables/useManifest.ts +43 -0
- package/src/composables/useProcesses.ts +114 -0
- package/src/composables/useProject.ts +34 -0
- package/src/composables/useTelemetry.ts +270 -0
- package/src/lib/__tests__/bc-graph.test.ts +218 -0
- package/src/lib/__tests__/dispatch-form.test.ts +113 -0
- package/src/lib/__tests__/error-friendly.test.ts +198 -0
- package/src/lib/__tests__/home.test.ts +231 -0
- package/src/lib/__tests__/inspect.test.ts +160 -0
- package/src/lib/__tests__/kind-colors.test.ts +59 -0
- package/src/lib/__tests__/live-table.test.ts +194 -0
- package/src/lib/__tests__/manifest-health.test.ts +120 -0
- package/src/lib/__tests__/manifest.test.ts +87 -0
- package/src/lib/__tests__/metadata.test.ts +47 -0
- package/src/lib/__tests__/node-metrics.test.ts +144 -0
- package/src/lib/__tests__/operate.test.ts +97 -0
- package/src/lib/__tests__/pipeline-flow.test.ts +79 -0
- package/src/lib/__tests__/rca.test.ts +124 -0
- package/src/lib/__tests__/telemetry.test.ts +91 -0
- package/src/lib/__tests__/topology-graph.test.ts +331 -0
- package/src/lib/__tests__/topology-view.test.ts +154 -0
- package/src/lib/__tests__/waterfall.test.ts +165 -0
- package/src/lib/bc-graph.ts +298 -0
- package/src/lib/dispatch-form.ts +160 -0
- package/src/lib/error-friendly.ts +288 -0
- package/src/lib/home.ts +191 -0
- package/src/lib/inspect.ts +226 -0
- package/src/lib/kind-colors.ts +132 -0
- package/src/lib/live-table.ts +204 -0
- package/src/lib/manifest-health.ts +71 -0
- package/src/lib/manifest.ts +139 -0
- package/src/lib/metadata.ts +52 -0
- package/src/lib/node-metrics.ts +242 -0
- package/src/lib/operate.ts +114 -0
- package/src/lib/pipeline-flow.ts +120 -0
- package/src/lib/rca.ts +193 -0
- package/src/lib/telemetry.ts +155 -0
- package/src/lib/topology-graph.ts +551 -0
- package/src/lib/topology-view.ts +185 -0
- package/src/lib/waterfall.ts +148 -0
- package/src/main.ts +63 -29
- package/src/pages/Errors.vue +272 -0
- package/src/pages/Home.stories.ts +7 -8
- package/src/pages/Home.vue +255 -540
- package/src/pages/Hooks.stories.ts +44 -0
- package/src/pages/Hooks.vue +165 -164
- package/src/pages/Inspect.vue +240 -0
- package/src/pages/Map.vue +187 -0
- package/src/pages/Operate.vue +74 -0
- package/src/pages/Plugins.stories.ts +1 -1
- package/src/pages/Plugins.vue +174 -238
- package/src/pages/Projects.vue +62 -60
- package/src/pages/Streams.vue +344 -0
- package/src/pages/Topology.vue +318 -136
- package/src/pages/Trace.vue +174 -412
- package/src/pages/__tests__/Home.test.ts +109 -54
- package/src/pages/__tests__/Hooks.test.ts +5 -5
- package/src/pages/__tests__/Inspect.test.ts +111 -0
- package/src/pages/__tests__/Plugins.test.ts +85 -35
- package/src/pages/__tests__/Trace.test.ts +117 -0
- package/src/pages/operate/CommandsPanel.vue +186 -0
- package/src/pages/{Dispatch.vue → operate/DispatchPanel.vue} +100 -203
- package/src/pages/operate/EndpointPicker.vue +56 -0
- package/src/pages/operate/RunPanel.vue +316 -0
- package/src/server/__tests__/nwire-read.test.ts +80 -0
- package/src/server/nwire-read.ts +63 -0
- package/vite.config.ts +220 -2
- package/src/lib/__tests__/normalize-cache.test.ts +0 -105
- package/src/lib/cache.ts +0 -312
- package/src/lib/normalize-cache.ts +0 -92
- package/src/pages/Actions.vue +0 -171
- package/src/pages/Apps.vue +0 -177
- package/src/pages/Commands.vue +0 -262
- package/src/pages/Events.vue +0 -210
- package/src/pages/Live.vue +0 -249
- package/src/pages/Overview.vue +0 -161
- package/src/pages/Projections.vue +0 -148
- package/src/pages/Queries.vue +0 -148
- package/src/pages/Run.vue +0 -618
- package/src/pages/Sinks.vue +0 -124
- package/src/pages/TraceNode.vue +0 -164
- package/src/pages/Workflows.vue +0 -184
- package/src/pages/__tests__/Actions.test.ts +0 -98
- package/src/pages/__tests__/Projections.test.ts +0 -90
- package/src/pages/__tests__/Queries.test.ts +0 -86
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { buildBcGraph, nodeDetail, toInvariants, layoutBcGraph, type BcGraph } from "../bc-graph";
|
|
3
|
+
import type { Manifest } from "../manifest";
|
|
4
|
+
|
|
5
|
+
/** A flat (older) manifest — per-kind arrays + `graph.events`, no `model`. */
|
|
6
|
+
const flat = {
|
|
7
|
+
apps: [{ name: "orders" }, { name: "billing" }],
|
|
8
|
+
actions: [
|
|
9
|
+
{ name: "orders.place", app: "orders", public: true },
|
|
10
|
+
{ name: "billing.charge", app: "billing" },
|
|
11
|
+
],
|
|
12
|
+
events: [
|
|
13
|
+
{ name: "orders.placed", app: "orders", public: true },
|
|
14
|
+
{ name: "billing.charged", app: "billing" },
|
|
15
|
+
],
|
|
16
|
+
queries: [{ name: "orders.list", app: "orders" }],
|
|
17
|
+
actors: [],
|
|
18
|
+
workflows: [{ name: "orders.fulfil", app: "orders" }],
|
|
19
|
+
projections: [{ name: "orders.dashboard", app: "orders" }],
|
|
20
|
+
graph: {
|
|
21
|
+
events: [
|
|
22
|
+
// orders.placed (orders) → billing.charge (billing): cross-BC.
|
|
23
|
+
{ from: "orders.placed", to: "billing.charge", via: "delivers" },
|
|
24
|
+
// intra-BC — must not produce an edge.
|
|
25
|
+
{ from: "orders.place", to: "orders.placed", via: "emits" },
|
|
26
|
+
],
|
|
27
|
+
},
|
|
28
|
+
} as unknown as Manifest;
|
|
29
|
+
|
|
30
|
+
/** A deep manifest — carries `model.edges` over `${kind}:${name}` ids. */
|
|
31
|
+
const deep = {
|
|
32
|
+
apps: [{ name: "orders" }, { name: "billing" }],
|
|
33
|
+
actions: [
|
|
34
|
+
{ name: "orders.place", app: "orders" },
|
|
35
|
+
{ name: "billing.charge", app: "billing" },
|
|
36
|
+
],
|
|
37
|
+
events: [{ name: "orders.placed", app: "orders" }],
|
|
38
|
+
queries: [],
|
|
39
|
+
actors: [],
|
|
40
|
+
workflows: [],
|
|
41
|
+
projections: [],
|
|
42
|
+
model: {
|
|
43
|
+
nodes: [],
|
|
44
|
+
edges: [{ from: "event:orders.placed", to: "action:billing.charge", type: "delivers" }],
|
|
45
|
+
},
|
|
46
|
+
// a flat graph that DISAGREES — proves `model.edges` wins when present.
|
|
47
|
+
graph: { events: [{ from: "orders.placed", to: "orders.place", via: "emits" }] },
|
|
48
|
+
} as unknown as Manifest;
|
|
49
|
+
|
|
50
|
+
describe("buildBcGraph", () => {
|
|
51
|
+
it("returns empty graph for null/undefined", () => {
|
|
52
|
+
expect(buildBcGraph(null)).toEqual({ bcs: [], edges: [] });
|
|
53
|
+
expect(buildBcGraph(undefined)).toEqual({ bcs: [], edges: [] });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("groups members into BC cards by app, sorted", () => {
|
|
57
|
+
const g = buildBcGraph(flat);
|
|
58
|
+
expect(g.bcs.map((b) => b.name)).toEqual(["billing", "orders"]);
|
|
59
|
+
const orders = g.bcs.find((b) => b.name === "orders")!;
|
|
60
|
+
// 1 action + 1 event + 1 query + 1 workflow + 1 projection
|
|
61
|
+
expect(orders.rows).toHaveLength(5);
|
|
62
|
+
expect(orders.rows.map((r) => r.kind).sort()).toEqual([
|
|
63
|
+
"action",
|
|
64
|
+
"event",
|
|
65
|
+
"projection",
|
|
66
|
+
"query",
|
|
67
|
+
"workflow",
|
|
68
|
+
]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("carries public flag and stable ids onto rows", () => {
|
|
72
|
+
const g = buildBcGraph(flat);
|
|
73
|
+
const place = g.bcs.flatMap((b) => b.rows).find((r) => r.name === "orders.place")!;
|
|
74
|
+
expect(place.id).toBe("action:orders.place");
|
|
75
|
+
expect(place.public).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("derives cross-BC edges from graph.events (flat path), skipping intra-BC", () => {
|
|
79
|
+
const g = buildBcGraph(flat);
|
|
80
|
+
expect(g.edges).toEqual([{ from: "orders", to: "billing", label: "delivers" }]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("prefers model.edges over graph.events when a deep model is present", () => {
|
|
84
|
+
const g = buildBcGraph(deep);
|
|
85
|
+
expect(g.edges).toEqual([{ from: "orders", to: "billing", label: "delivers" }]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("buckets members with no app into (unscoped)", () => {
|
|
89
|
+
const g = buildBcGraph({
|
|
90
|
+
apps: [],
|
|
91
|
+
actions: [{ name: "loose.thing" }],
|
|
92
|
+
} as unknown as Manifest);
|
|
93
|
+
expect(g.bcs.map((b) => b.name)).toContain("(unscoped)");
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("nodeDetail", () => {
|
|
98
|
+
const m = {
|
|
99
|
+
actions: [
|
|
100
|
+
{
|
|
101
|
+
name: "orders.place",
|
|
102
|
+
app: "orders",
|
|
103
|
+
description: "Place an order.",
|
|
104
|
+
public: true,
|
|
105
|
+
inputSchema: { type: "object" },
|
|
106
|
+
emits: ["orders.placed"],
|
|
107
|
+
retry: 3,
|
|
108
|
+
source: { file: "/app/actions.ts", line: 10, column: 2 },
|
|
109
|
+
},
|
|
110
|
+
],
|
|
111
|
+
} as unknown as Manifest;
|
|
112
|
+
|
|
113
|
+
it("resolves a row id to full detail", () => {
|
|
114
|
+
const d = nodeDetail(m, "action:orders.place")!;
|
|
115
|
+
expect(d.name).toBe("orders.place");
|
|
116
|
+
expect(d.app).toBe("orders");
|
|
117
|
+
expect(d.description).toBe("Place an order.");
|
|
118
|
+
expect(d.public).toBe(true);
|
|
119
|
+
expect(d.schema).toEqual({ type: "object" });
|
|
120
|
+
expect(d.emits).toEqual(["orders.placed"]);
|
|
121
|
+
expect(d.source).toEqual({ file: "/app/actions.ts", line: 10, column: 2 });
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("puts unknown fields into extra, not the typed surface", () => {
|
|
125
|
+
const d = nodeDetail(m, "action:orders.place")!;
|
|
126
|
+
expect(d.extra.retry).toBe(3);
|
|
127
|
+
expect(d.extra.name).toBeUndefined();
|
|
128
|
+
expect(d.extra.inputSchema).toBeUndefined();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("returns null for unknown kind, missing entry, bad id, or no manifest", () => {
|
|
132
|
+
expect(nodeDetail(m, "nope:orders.place")).toBeNull();
|
|
133
|
+
expect(nodeDetail(m, "action:does.not.exist")).toBeNull();
|
|
134
|
+
expect(nodeDetail(m, "noseparator")).toBeNull();
|
|
135
|
+
expect(nodeDetail(null, "action:orders.place")).toBeNull();
|
|
136
|
+
expect(nodeDetail(m, null)).toBeNull();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("surfaces invariants as a typed field, not in extra", () => {
|
|
140
|
+
const withRules = {
|
|
141
|
+
actions: [
|
|
142
|
+
{
|
|
143
|
+
name: "mod.approve",
|
|
144
|
+
app: "mod",
|
|
145
|
+
invariants: [
|
|
146
|
+
{ rule: '(i) => i.by.length > 0 || "Needs a moderator"', message: "Needs a moderator" },
|
|
147
|
+
{ rule: "(i) => i.ok" },
|
|
148
|
+
],
|
|
149
|
+
},
|
|
150
|
+
],
|
|
151
|
+
} as unknown as Manifest;
|
|
152
|
+
const d = nodeDetail(withRules, "action:mod.approve")!;
|
|
153
|
+
expect(d.invariants).toEqual([
|
|
154
|
+
{ rule: '(i) => i.by.length > 0 || "Needs a moderator"', message: "Needs a moderator" },
|
|
155
|
+
{ rule: "(i) => i.ok" },
|
|
156
|
+
]);
|
|
157
|
+
expect(d.extra.invariants).toBeUndefined();
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe("toInvariants", () => {
|
|
162
|
+
it("normalizes well-formed entries and drops junk", () => {
|
|
163
|
+
expect(
|
|
164
|
+
toInvariants([{ rule: "a", message: "A" }, { rule: "b" }, { rule: 123 }, "nope", null]),
|
|
165
|
+
).toEqual([{ rule: "a", message: "A" }, { rule: "b" }]);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("returns undefined for non-arrays and empty results", () => {
|
|
169
|
+
expect(toInvariants(undefined)).toBeUndefined();
|
|
170
|
+
expect(toInvariants("x")).toBeUndefined();
|
|
171
|
+
expect(toInvariants([])).toBeUndefined();
|
|
172
|
+
expect(toInvariants([{ message: "no rule" }])).toBeUndefined();
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe("layoutBcGraph", () => {
|
|
177
|
+
const graph: BcGraph = {
|
|
178
|
+
bcs: [
|
|
179
|
+
{
|
|
180
|
+
name: "a",
|
|
181
|
+
rows: [
|
|
182
|
+
{ id: "action:a1", kind: "action", name: "a1" },
|
|
183
|
+
{ id: "action:a2", kind: "action", name: "a2" },
|
|
184
|
+
{ id: "action:a3", kind: "action", name: "a3" },
|
|
185
|
+
],
|
|
186
|
+
},
|
|
187
|
+
{ name: "b", rows: [] },
|
|
188
|
+
{ name: "c", rows: [] },
|
|
189
|
+
{ name: "d", rows: [] },
|
|
190
|
+
],
|
|
191
|
+
edges: [],
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
it("positions cards in a grid by column count", () => {
|
|
195
|
+
const nodes = layoutBcGraph(graph, { columns: 2, width: 100, gapX: 20 });
|
|
196
|
+
expect(nodes).toHaveLength(4);
|
|
197
|
+
// row 0
|
|
198
|
+
expect(nodes[0]!.x).toBe(0);
|
|
199
|
+
expect(nodes[1]!.x).toBe(120);
|
|
200
|
+
expect(nodes[0]!.y).toBe(nodes[1]!.y);
|
|
201
|
+
// row 1 wraps below row 0
|
|
202
|
+
expect(nodes[2]!.x).toBe(0);
|
|
203
|
+
expect(nodes[2]!.y).toBeGreaterThan(nodes[0]!.y);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("scales card height with member count", () => {
|
|
207
|
+
const nodes = layoutBcGraph(graph, { columns: 4 });
|
|
208
|
+
const tall = nodes.find((n) => n.name === "a")!;
|
|
209
|
+
const short = nodes.find((n) => n.name === "b")!;
|
|
210
|
+
expect(tall.height).toBeGreaterThan(short.height);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("never overlaps rows — next row clears the tallest card above", () => {
|
|
214
|
+
const nodes = layoutBcGraph(graph, { columns: 2 });
|
|
215
|
+
const rowZeroBottom = Math.max(nodes[0]!.y + nodes[0]!.height, nodes[1]!.y + nodes[1]!.height);
|
|
216
|
+
expect(nodes[2]!.y).toBeGreaterThanOrEqual(rowZeroBottom);
|
|
217
|
+
});
|
|
218
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import type { Manifest } from "../manifest";
|
|
3
|
+
import {
|
|
4
|
+
dispatchTargets,
|
|
5
|
+
filterTargets,
|
|
6
|
+
schemaFields,
|
|
7
|
+
scaffoldInput,
|
|
8
|
+
isInlineRenderable,
|
|
9
|
+
allInlineRenderable,
|
|
10
|
+
} from "../dispatch-form";
|
|
11
|
+
|
|
12
|
+
const objectSchema = {
|
|
13
|
+
type: "object",
|
|
14
|
+
properties: {
|
|
15
|
+
authorId: { type: "string" },
|
|
16
|
+
body: { type: "string" },
|
|
17
|
+
flagged: { type: "boolean", default: false },
|
|
18
|
+
severity: { type: "string", enum: ["low", "high"] },
|
|
19
|
+
},
|
|
20
|
+
required: ["authorId", "body"],
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const manifest = {
|
|
24
|
+
actions: [
|
|
25
|
+
{
|
|
26
|
+
name: "moderation.submit-post",
|
|
27
|
+
app: "moderation",
|
|
28
|
+
description: "post",
|
|
29
|
+
inputSchema: objectSchema,
|
|
30
|
+
},
|
|
31
|
+
{ name: "moderation.flag", app: "moderation" },
|
|
32
|
+
],
|
|
33
|
+
queries: [{ name: "moderation.queue", app: "moderation", projection: "queue" }],
|
|
34
|
+
} as unknown as Manifest;
|
|
35
|
+
|
|
36
|
+
describe("dispatchTargets", () => {
|
|
37
|
+
it("reads actions then queries from the flat manifest arrays", () => {
|
|
38
|
+
const t = dispatchTargets(manifest);
|
|
39
|
+
expect(t.map((x) => x.name)).toEqual([
|
|
40
|
+
"moderation.submit-post",
|
|
41
|
+
"moderation.flag",
|
|
42
|
+
"moderation.queue",
|
|
43
|
+
]);
|
|
44
|
+
expect(t[0].kind).toBe("action");
|
|
45
|
+
expect(t[2].kind).toBe("query");
|
|
46
|
+
expect(t[0].inputSchema).toBe(objectSchema);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("is tolerant of a null / partial manifest", () => {
|
|
50
|
+
expect(dispatchTargets(null)).toEqual([]);
|
|
51
|
+
expect(dispatchTargets({} as Manifest)).toEqual([]);
|
|
52
|
+
expect(dispatchTargets({ actions: "nope" } as unknown as Manifest)).toEqual([]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("skips entries without a name", () => {
|
|
56
|
+
const m = {
|
|
57
|
+
actions: [{ app: "x" }, { name: "ok", app: "x" }],
|
|
58
|
+
} as unknown as Manifest;
|
|
59
|
+
expect(dispatchTargets(m).map((t) => t.name)).toEqual(["ok"]);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("filterTargets", () => {
|
|
64
|
+
const targets = dispatchTargets(manifest);
|
|
65
|
+
it("returns all on an empty query", () => {
|
|
66
|
+
expect(filterTargets(targets, " ")).toHaveLength(3);
|
|
67
|
+
});
|
|
68
|
+
it("matches name, app, and description case-insensitively", () => {
|
|
69
|
+
expect(filterTargets(targets, "FLAG").map((t) => t.name)).toEqual(["moderation.flag"]);
|
|
70
|
+
expect(filterTargets(targets, "post").map((t) => t.name)).toEqual(["moderation.submit-post"]);
|
|
71
|
+
expect(filterTargets(targets, "queue").map((t) => t.name)).toEqual(["moderation.queue"]);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("schemaFields", () => {
|
|
76
|
+
it("flattens top-level properties with required/default/enum", () => {
|
|
77
|
+
const f = schemaFields(objectSchema);
|
|
78
|
+
expect(f.map((x) => x.name)).toEqual(["authorId", "body", "flagged", "severity"]);
|
|
79
|
+
expect(f.find((x) => x.name === "authorId")?.required).toBe(true);
|
|
80
|
+
expect(f.find((x) => x.name === "flagged")?.default).toBe(false);
|
|
81
|
+
expect(f.find((x) => x.name === "severity")?.enum).toEqual(["low", "high"]);
|
|
82
|
+
});
|
|
83
|
+
it("returns [] for a non-object / schemaless input", () => {
|
|
84
|
+
expect(schemaFields(undefined)).toEqual([]);
|
|
85
|
+
expect(schemaFields({ type: "string" })).toEqual([]);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("scaffoldInput", () => {
|
|
90
|
+
it("seeds type-appropriate values and honors defaults", () => {
|
|
91
|
+
expect(scaffoldInput(objectSchema)).toEqual({
|
|
92
|
+
authorId: "",
|
|
93
|
+
body: "",
|
|
94
|
+
flagged: false,
|
|
95
|
+
severity: "",
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
it("returns {} for a schemaless input", () => {
|
|
99
|
+
expect(scaffoldInput(null)).toEqual({});
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("inline renderability", () => {
|
|
104
|
+
it("treats primitives as inline-renderable", () => {
|
|
105
|
+
expect(isInlineRenderable({ name: "a", type: "string", required: true })).toBe(true);
|
|
106
|
+
expect(isInlineRenderable({ name: "a", type: "object", required: false })).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
it("allInlineRenderable is true only when all fields are primitive", () => {
|
|
109
|
+
expect(allInlineRenderable(schemaFields(objectSchema))).toBe(true);
|
|
110
|
+
expect(allInlineRenderable([{ name: "x", type: "object", required: false }])).toBe(false);
|
|
111
|
+
expect(allInlineRenderable([])).toBe(false);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
explainError,
|
|
4
|
+
categorize,
|
|
5
|
+
severityOf,
|
|
6
|
+
subjectOf,
|
|
7
|
+
attemptOf,
|
|
8
|
+
retryNarrative,
|
|
9
|
+
maxSeverity,
|
|
10
|
+
} from "../error-friendly";
|
|
11
|
+
import type { TelemetryRecord } from "../telemetry";
|
|
12
|
+
|
|
13
|
+
const rec = (over: Partial<TelemetryRecord> & { kind: string }): TelemetryRecord => over;
|
|
14
|
+
|
|
15
|
+
describe("subjectOf", () => {
|
|
16
|
+
it("reads the failing unit off whichever id field is present", () => {
|
|
17
|
+
expect(subjectOf(rec({ kind: "action.failed", action: "orders.charge" }))).toEqual({
|
|
18
|
+
kind: "action",
|
|
19
|
+
name: "orders.charge",
|
|
20
|
+
});
|
|
21
|
+
expect(subjectOf(rec({ kind: "projection.failed", projection: "balances" }))).toEqual({
|
|
22
|
+
kind: "projection",
|
|
23
|
+
name: "balances",
|
|
24
|
+
});
|
|
25
|
+
expect(subjectOf(rec({ kind: "external.call.failed", call: "stripe.charge" }))).toEqual({
|
|
26
|
+
kind: "external call",
|
|
27
|
+
name: "stripe.charge",
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
it("falls back to the source event, then a generic label", () => {
|
|
31
|
+
expect(subjectOf(rec({ kind: "reaction.failed", sourceEvent: "OrderPlaced" }))).toEqual({
|
|
32
|
+
kind: "operation",
|
|
33
|
+
name: "OrderPlaced",
|
|
34
|
+
});
|
|
35
|
+
expect(subjectOf(rec({ kind: "x" })).name).toBe("an operation");
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("categorize", () => {
|
|
40
|
+
it("classifies by error name / message / code first", () => {
|
|
41
|
+
expect(
|
|
42
|
+
categorize(rec({ kind: "action.failed", error: { name: "ZodError", message: "x" } })),
|
|
43
|
+
).toBe("validation");
|
|
44
|
+
expect(
|
|
45
|
+
categorize(
|
|
46
|
+
rec({ kind: "action.failed", error: { name: "E", message: "field is required" } }),
|
|
47
|
+
),
|
|
48
|
+
).toBe("validation");
|
|
49
|
+
expect(
|
|
50
|
+
categorize(
|
|
51
|
+
rec({ kind: "action.failed", error: { name: "E", message: "card declined", code: 402 } }),
|
|
52
|
+
),
|
|
53
|
+
).toBe("declined");
|
|
54
|
+
expect(
|
|
55
|
+
categorize(
|
|
56
|
+
rec({ kind: "action.failed", error: { name: "Forbidden", message: "x", code: 403 } }),
|
|
57
|
+
),
|
|
58
|
+
).toBe("unauthorized");
|
|
59
|
+
expect(
|
|
60
|
+
categorize(
|
|
61
|
+
rec({ kind: "action.failed", error: { name: "E", message: "user not found", code: 404 } }),
|
|
62
|
+
),
|
|
63
|
+
).toBe("not-found");
|
|
64
|
+
expect(
|
|
65
|
+
categorize(
|
|
66
|
+
rec({ kind: "action.failed", error: { name: "E", message: "already exists", code: 409 } }),
|
|
67
|
+
),
|
|
68
|
+
).toBe("conflict");
|
|
69
|
+
expect(
|
|
70
|
+
categorize(
|
|
71
|
+
rec({ kind: "action.failed", error: { name: "TimeoutError", message: "timed out" } }),
|
|
72
|
+
),
|
|
73
|
+
).toBe("timeout");
|
|
74
|
+
});
|
|
75
|
+
it("falls back to the record kind for the forge terminal shapes", () => {
|
|
76
|
+
expect(categorize(rec({ kind: "dlq.recorded", error: { name: "E", message: "boom" } }))).toBe(
|
|
77
|
+
"dead-letter",
|
|
78
|
+
);
|
|
79
|
+
expect(
|
|
80
|
+
categorize(rec({ kind: "reaction.exhausted", error: { name: "E", message: "boom" } })),
|
|
81
|
+
).toBe("exhausted");
|
|
82
|
+
expect(
|
|
83
|
+
categorize(rec({ kind: "external.call.failed", error: { name: "E", message: "boom" } })),
|
|
84
|
+
).toBe("external");
|
|
85
|
+
expect(
|
|
86
|
+
categorize(rec({ kind: "projection.failed", error: { name: "E", message: "boom" } })),
|
|
87
|
+
).toBe("downstream");
|
|
88
|
+
});
|
|
89
|
+
it("is 'unknown' when nothing matches", () => {
|
|
90
|
+
expect(
|
|
91
|
+
categorize(rec({ kind: "action.failed", error: { name: "WeirdError", message: "?" } })),
|
|
92
|
+
).toBe("unknown");
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("attemptOf / retryNarrative", () => {
|
|
97
|
+
it("normalises the retrying shape", () => {
|
|
98
|
+
const r = rec({
|
|
99
|
+
kind: "action.failed",
|
|
100
|
+
attempt: 2,
|
|
101
|
+
maxAttempts: 3,
|
|
102
|
+
willRetry: true,
|
|
103
|
+
error: { name: "E", message: "x" },
|
|
104
|
+
});
|
|
105
|
+
expect(attemptOf(r)).toMatchObject({
|
|
106
|
+
attempt: 2,
|
|
107
|
+
maxAttempts: 3,
|
|
108
|
+
willRetry: true,
|
|
109
|
+
terminal: false,
|
|
110
|
+
});
|
|
111
|
+
expect(retryNarrative(r)).toBe("attempt 2 of 3, retrying");
|
|
112
|
+
});
|
|
113
|
+
it("marks terminal shapes and reports total attempts", () => {
|
|
114
|
+
const r = rec({ kind: "dlq.recorded", attempts: 3, error: { name: "E", message: "x" } });
|
|
115
|
+
expect(attemptOf(r)).toMatchObject({ attempts: 3, willRetry: false, terminal: true });
|
|
116
|
+
expect(retryNarrative(r)).toBe("3 attempts — gave up");
|
|
117
|
+
});
|
|
118
|
+
it("returns null when there's nothing to say", () => {
|
|
119
|
+
expect(retryNarrative(rec({ kind: "action.failed", attempt: 1, maxAttempts: 1 }))).toBe(
|
|
120
|
+
"attempt 1 of 1",
|
|
121
|
+
);
|
|
122
|
+
expect(retryNarrative(rec({ kind: "projection.failed" }))).toBeNull();
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe("severityOf", () => {
|
|
127
|
+
it("terminal shapes are critical", () => {
|
|
128
|
+
expect(severityOf(rec({ kind: "dlq.recorded" }))).toBe("critical");
|
|
129
|
+
expect(severityOf(rec({ kind: "reaction.exhausted" }))).toBe("critical");
|
|
130
|
+
});
|
|
131
|
+
it("a failure that will retry is only a warning", () => {
|
|
132
|
+
expect(
|
|
133
|
+
severityOf(
|
|
134
|
+
rec({ kind: "action.failed", willRetry: true, error: { name: "E", message: "x" } }),
|
|
135
|
+
),
|
|
136
|
+
).toBe("warning");
|
|
137
|
+
});
|
|
138
|
+
it("validation / declined are warnings (expected control-flow)", () => {
|
|
139
|
+
expect(
|
|
140
|
+
severityOf(rec({ kind: "action.failed", error: { name: "ZodError", message: "x" } })),
|
|
141
|
+
).toBe("warning");
|
|
142
|
+
expect(
|
|
143
|
+
severityOf(rec({ kind: "action.failed", error: { name: "E", message: "declined" } })),
|
|
144
|
+
).toBe("warning");
|
|
145
|
+
});
|
|
146
|
+
it("an unrecoverable runtime failure is high", () => {
|
|
147
|
+
expect(
|
|
148
|
+
severityOf(
|
|
149
|
+
rec({
|
|
150
|
+
kind: "action.failed",
|
|
151
|
+
willRetry: false,
|
|
152
|
+
error: { name: "TypeError", message: "boom" },
|
|
153
|
+
}),
|
|
154
|
+
),
|
|
155
|
+
).toBe("high");
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe("maxSeverity", () => {
|
|
160
|
+
it("picks the louder", () => {
|
|
161
|
+
expect(maxSeverity("warning", "critical")).toBe("critical");
|
|
162
|
+
expect(maxSeverity("high", "warning")).toBe("high");
|
|
163
|
+
expect(maxSeverity("info", "info")).toBe("info");
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe("explainError", () => {
|
|
168
|
+
it("produces a friendly title/summary/suggestion naming the subject", () => {
|
|
169
|
+
const f = explainError(
|
|
170
|
+
rec({
|
|
171
|
+
kind: "action.failed",
|
|
172
|
+
action: "orders.charge",
|
|
173
|
+
willRetry: false,
|
|
174
|
+
error: { name: "PaymentDeclined", message: "card declined", code: 402 },
|
|
175
|
+
}),
|
|
176
|
+
);
|
|
177
|
+
expect(f.category).toBe("declined");
|
|
178
|
+
expect(f.title).toBe("Declined");
|
|
179
|
+
expect(f.summary).toContain("orders.charge");
|
|
180
|
+
expect(f.suggestion.length).toBeGreaterThan(0);
|
|
181
|
+
expect(f.severity).toBe("warning");
|
|
182
|
+
expect(f.rawMessage).toBe("card declined");
|
|
183
|
+
});
|
|
184
|
+
it("explains a dead-lettered action loudly", () => {
|
|
185
|
+
const f = explainError(
|
|
186
|
+
rec({
|
|
187
|
+
kind: "dlq.recorded",
|
|
188
|
+
action: "orders.ship",
|
|
189
|
+
attempts: 5,
|
|
190
|
+
error: { name: "TypeError", message: "boom" },
|
|
191
|
+
}),
|
|
192
|
+
);
|
|
193
|
+
expect(f.severity).toBe("critical");
|
|
194
|
+
expect(f.title).toBe("Dead-lettered");
|
|
195
|
+
expect(f.summary).toContain("orders.ship");
|
|
196
|
+
expect(f.attempt.terminal).toBe(true);
|
|
197
|
+
});
|
|
198
|
+
});
|