@nwire/studio 0.12.0 → 0.13.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/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,231 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
projectHealth,
|
|
4
|
+
healthBadge,
|
|
5
|
+
healthLabel,
|
|
6
|
+
projectPort,
|
|
7
|
+
sortProjects,
|
|
8
|
+
projectStats,
|
|
9
|
+
statsArePending,
|
|
10
|
+
quickStats,
|
|
11
|
+
countErrors,
|
|
12
|
+
activityLabel,
|
|
13
|
+
recentActivity,
|
|
14
|
+
} from "../home";
|
|
15
|
+
import { manifestView } from "../manifest";
|
|
16
|
+
import type { Manifest } from "../manifest";
|
|
17
|
+
import type { DiscoveredProject } from "../../composables/useDiscovery";
|
|
18
|
+
import type { TelemetryRecord } from "../telemetry";
|
|
19
|
+
import type { ProjectSnapshot } from "../../lib/project-catalog";
|
|
20
|
+
|
|
21
|
+
function snapshot(over: Partial<ProjectSnapshot> = {}): ProjectSnapshot {
|
|
22
|
+
return {
|
|
23
|
+
cwd: "/repo/app",
|
|
24
|
+
name: "app",
|
|
25
|
+
lastVisited: "2026-01-01T00:00:00.000Z",
|
|
26
|
+
...over,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function project(over: Partial<DiscoveredProject> = {}): DiscoveredProject {
|
|
31
|
+
return {
|
|
32
|
+
cwd: over.snapshot?.cwd ?? over.cwd ?? "/repo/app",
|
|
33
|
+
snapshot: snapshot(over.snapshot),
|
|
34
|
+
status: over.status,
|
|
35
|
+
active: over.active ?? false,
|
|
36
|
+
...over,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe("projectHealth", () => {
|
|
41
|
+
it("is running when a live process is registered", () => {
|
|
42
|
+
expect(projectHealth(project({ status: { processes: [{ port: 4000 }] } }))).toBe("running");
|
|
43
|
+
});
|
|
44
|
+
it("is running when the legacy `running` flag is set", () => {
|
|
45
|
+
expect(projectHealth(project({ status: { running: true } }))).toBe("running");
|
|
46
|
+
});
|
|
47
|
+
it("is on-disk when a manifest exists but no process is up", () => {
|
|
48
|
+
expect(projectHealth(project({ status: { hasManifest: true, processes: [] } }))).toBe(
|
|
49
|
+
"on-disk",
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
it("is unreachable with neither a process nor a manifest", () => {
|
|
53
|
+
expect(projectHealth(project({ status: { hasManifest: false, processes: [] } }))).toBe(
|
|
54
|
+
"unreachable",
|
|
55
|
+
);
|
|
56
|
+
expect(projectHealth(project({ status: undefined }))).toBe("unreachable");
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("health presentation", () => {
|
|
61
|
+
it("maps health to a badge token", () => {
|
|
62
|
+
expect(healthBadge("running")).toBe("live");
|
|
63
|
+
expect(healthBadge("on-disk")).toBe("idle");
|
|
64
|
+
expect(healthBadge("unreachable")).toBe("error");
|
|
65
|
+
});
|
|
66
|
+
it("labels each health", () => {
|
|
67
|
+
expect(healthLabel("running")).toBe("Running");
|
|
68
|
+
expect(healthLabel("on-disk")).toBe("On disk");
|
|
69
|
+
expect(healthLabel("unreachable")).toBe("Unreachable");
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("projectPort", () => {
|
|
74
|
+
it("returns the first process port", () => {
|
|
75
|
+
expect(projectPort(project({ status: { processes: [{}, { port: 4111 }] } }))).toBe(4111);
|
|
76
|
+
});
|
|
77
|
+
it("is undefined when no port is known", () => {
|
|
78
|
+
expect(projectPort(project({ status: { processes: [] } }))).toBeUndefined();
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("sortProjects", () => {
|
|
83
|
+
it("orders active first, then by health, then by name", () => {
|
|
84
|
+
const running = project({
|
|
85
|
+
snapshot: snapshot({ cwd: "/r", name: "running" }),
|
|
86
|
+
status: { processes: [{}] },
|
|
87
|
+
});
|
|
88
|
+
const onDisk = project({
|
|
89
|
+
snapshot: snapshot({ cwd: "/d", name: "ondisk" }),
|
|
90
|
+
status: { hasManifest: true },
|
|
91
|
+
});
|
|
92
|
+
const dead = project({ snapshot: snapshot({ cwd: "/x", name: "dead" }), status: {} });
|
|
93
|
+
const active = project({
|
|
94
|
+
snapshot: snapshot({ cwd: "/a", name: "zeta-active" }),
|
|
95
|
+
active: true,
|
|
96
|
+
status: {},
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const out = sortProjects([dead, onDisk, running, active]);
|
|
100
|
+
expect(out.map((p) => p.snapshot.name)).toEqual(["zeta-active", "running", "ondisk", "dead"]);
|
|
101
|
+
});
|
|
102
|
+
it("does not mutate the input", () => {
|
|
103
|
+
const list = [
|
|
104
|
+
project({ snapshot: snapshot({ name: "b" }) }),
|
|
105
|
+
project({ snapshot: snapshot({ name: "a" }) }),
|
|
106
|
+
];
|
|
107
|
+
const copy = [...list];
|
|
108
|
+
sortProjects(list);
|
|
109
|
+
expect(list).toEqual(copy);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("projectStats", () => {
|
|
114
|
+
it("reads composition counts from the snapshot", () => {
|
|
115
|
+
const p = project({
|
|
116
|
+
snapshot: snapshot({
|
|
117
|
+
composition: { apps: 1, plugins: 2, actions: 3, events: 4, resolvers: 5, workflows: 6 },
|
|
118
|
+
}),
|
|
119
|
+
});
|
|
120
|
+
expect(projectStats(p)).toEqual({
|
|
121
|
+
apps: 1,
|
|
122
|
+
plugins: 2,
|
|
123
|
+
actions: 3,
|
|
124
|
+
events: 4,
|
|
125
|
+
resolvers: 5,
|
|
126
|
+
workflows: 6,
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
it("falls back to zeros when no composition is recorded", () => {
|
|
130
|
+
expect(projectStats(project())).toEqual({
|
|
131
|
+
apps: 0,
|
|
132
|
+
plugins: 0,
|
|
133
|
+
actions: 0,
|
|
134
|
+
events: 0,
|
|
135
|
+
resolvers: 0,
|
|
136
|
+
workflows: 0,
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
it("flags a pending (not-yet-scanned) snapshot", () => {
|
|
140
|
+
expect(statsArePending(project())).toBe(true);
|
|
141
|
+
expect(
|
|
142
|
+
statsArePending(
|
|
143
|
+
project({
|
|
144
|
+
snapshot: snapshot({ composition: { apps: 0, plugins: 0, actions: 0, events: 0 } }),
|
|
145
|
+
}),
|
|
146
|
+
),
|
|
147
|
+
).toBe(false);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
function manifest(): Manifest {
|
|
152
|
+
return {
|
|
153
|
+
version: 3,
|
|
154
|
+
model: {
|
|
155
|
+
nodes: [
|
|
156
|
+
{ id: "action:a", kind: "action", name: "a" },
|
|
157
|
+
{ id: "action:b", kind: "action", name: "b" },
|
|
158
|
+
{ id: "event:E", kind: "event", name: "E" },
|
|
159
|
+
{ id: "projection:P", kind: "projection", name: "P" },
|
|
160
|
+
{ id: "app:app", kind: "app", name: "app" },
|
|
161
|
+
],
|
|
162
|
+
edges: [],
|
|
163
|
+
},
|
|
164
|
+
} as unknown as Manifest;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
describe("quickStats", () => {
|
|
168
|
+
it("derives KPI counts from the manifest view + error count", () => {
|
|
169
|
+
const stats = quickStats(manifestView(manifest()), 7);
|
|
170
|
+
const byLabel = Object.fromEntries(stats.map((s) => [s.label, s.value]));
|
|
171
|
+
expect(byLabel.Nodes).toBe(5);
|
|
172
|
+
expect(byLabel.Actions).toBe(2);
|
|
173
|
+
expect(byLabel.Events).toBe(1);
|
|
174
|
+
expect(byLabel.Projections).toBe(1);
|
|
175
|
+
expect(byLabel.Errors).toBe(7);
|
|
176
|
+
});
|
|
177
|
+
it("reports zeros for a null view (manifest not loaded)", () => {
|
|
178
|
+
const stats = quickStats(null, 0);
|
|
179
|
+
expect(stats.every((s) => s.value === 0)).toBe(true);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe("countErrors", () => {
|
|
184
|
+
it("counts failure records", () => {
|
|
185
|
+
const recs: TelemetryRecord[] = [
|
|
186
|
+
{ kind: "action.dispatched" },
|
|
187
|
+
{ kind: "action.failed" },
|
|
188
|
+
{ kind: "event.published", error: { name: "X", message: "boom" } },
|
|
189
|
+
];
|
|
190
|
+
expect(countErrors(recs)).toBe(2);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe("activityLabel", () => {
|
|
195
|
+
it("prefers the named subject", () => {
|
|
196
|
+
expect(activityLabel({ kind: "action.dispatched", action: "orders.place" })).toBe(
|
|
197
|
+
"orders.place",
|
|
198
|
+
);
|
|
199
|
+
expect(activityLabel({ kind: "event.published", event: "OrderPlaced" })).toBe("OrderPlaced");
|
|
200
|
+
});
|
|
201
|
+
it("falls back to the kind", () => {
|
|
202
|
+
expect(activityLabel({ kind: "timer.fired" })).toBe("timer.fired");
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
describe("recentActivity", () => {
|
|
207
|
+
const recs: TelemetryRecord[] = [
|
|
208
|
+
{
|
|
209
|
+
kind: "action.dispatched",
|
|
210
|
+
action: "a",
|
|
211
|
+
ts: "1",
|
|
212
|
+
envelope: { messageId: "m1", correlationId: "c1", causationId: "m1" },
|
|
213
|
+
},
|
|
214
|
+
{ kind: "action.failed", action: "b", ts: "2" },
|
|
215
|
+
{ kind: "event.published", event: "E", ts: "3" },
|
|
216
|
+
];
|
|
217
|
+
|
|
218
|
+
it("returns newest-first, capped at the limit", () => {
|
|
219
|
+
const items = recentActivity(recs, 2);
|
|
220
|
+
expect(items.map((i) => i.label)).toEqual(["E", "b"]);
|
|
221
|
+
});
|
|
222
|
+
it("marks failures and carries the correlation id + colour family", () => {
|
|
223
|
+
const items = recentActivity(recs, 3);
|
|
224
|
+
const failed = items.find((i) => i.label === "b");
|
|
225
|
+
const dispatched = items.find((i) => i.label === "a");
|
|
226
|
+
expect(failed?.failed).toBe(true);
|
|
227
|
+
expect(dispatched?.failed).toBe(false);
|
|
228
|
+
expect(dispatched?.correlationId).toBe("c1");
|
|
229
|
+
expect(dispatched?.colorKey).toBe("action");
|
|
230
|
+
});
|
|
231
|
+
});
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { manifestView, type Manifest } from "../manifest";
|
|
3
|
+
import {
|
|
4
|
+
inspectKinds,
|
|
5
|
+
nodesOfKind,
|
|
6
|
+
matchesNode,
|
|
7
|
+
nodeToDetail,
|
|
8
|
+
relationships,
|
|
9
|
+
kindLabel,
|
|
10
|
+
isDispatchable,
|
|
11
|
+
} from "../inspect";
|
|
12
|
+
|
|
13
|
+
const manifest = {
|
|
14
|
+
version: 3,
|
|
15
|
+
// flat arrays — nodeToDetail prefers these for schema
|
|
16
|
+
actions: [
|
|
17
|
+
{
|
|
18
|
+
name: "orders.place",
|
|
19
|
+
app: "orders",
|
|
20
|
+
public: true,
|
|
21
|
+
description: "Place an order",
|
|
22
|
+
inputSchema: { type: "object" },
|
|
23
|
+
emits: ["orders.placed"],
|
|
24
|
+
source: { file: "orders/place.ts", line: 3 },
|
|
25
|
+
},
|
|
26
|
+
],
|
|
27
|
+
events: [{ name: "orders.placed", app: "orders" }],
|
|
28
|
+
model: {
|
|
29
|
+
nodes: [
|
|
30
|
+
{
|
|
31
|
+
id: "action:orders.place",
|
|
32
|
+
kind: "action",
|
|
33
|
+
name: "orders.place",
|
|
34
|
+
source: { file: "orders/place.ts", line: 3 },
|
|
35
|
+
intent: { description: "Place an order", public: true },
|
|
36
|
+
data: { emits: ["orders.placed"] },
|
|
37
|
+
},
|
|
38
|
+
{ id: "event:orders.placed", kind: "event", name: "orders.placed" },
|
|
39
|
+
{
|
|
40
|
+
id: "plugin:auth",
|
|
41
|
+
kind: "plugin",
|
|
42
|
+
name: "auth",
|
|
43
|
+
source: { file: "plugins/auth.ts", line: 1 },
|
|
44
|
+
intent: { description: "Auth plugin" },
|
|
45
|
+
data: { contributes: ["binding:user"] },
|
|
46
|
+
},
|
|
47
|
+
{ id: "query:orders.by-id", kind: "query", name: "orders.by-id" },
|
|
48
|
+
{
|
|
49
|
+
id: "app:orders",
|
|
50
|
+
kind: "app",
|
|
51
|
+
name: "orders",
|
|
52
|
+
intent: { description: "Orders BC" },
|
|
53
|
+
data: {
|
|
54
|
+
plugins: ["forge.actions"],
|
|
55
|
+
env: ["DATABASE_URL", "PORT"],
|
|
56
|
+
config: [{ file: "/abs/app/config/orders.ts", keys: ["orders", "limits"] }],
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
edges: [
|
|
61
|
+
{ from: "action:orders.place", to: "event:orders.placed", type: "emits" },
|
|
62
|
+
{ from: "query:orders.by-id", to: "action:orders.place", type: "dispatches" },
|
|
63
|
+
],
|
|
64
|
+
},
|
|
65
|
+
} as unknown as Manifest;
|
|
66
|
+
|
|
67
|
+
const view = manifestView(manifest);
|
|
68
|
+
|
|
69
|
+
describe("kindLabel", () => {
|
|
70
|
+
it("maps kinds to friendly labels, falling back to the raw kind", () => {
|
|
71
|
+
expect(kindLabel("action")).toBe("Actions");
|
|
72
|
+
expect(kindLabel("sinkStage")).toBe("Sinks");
|
|
73
|
+
expect(kindLabel("mystery")).toBe("mystery");
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("inspectKinds", () => {
|
|
78
|
+
it("lists present kinds with counts in curated order", () => {
|
|
79
|
+
const out = inspectKinds(view);
|
|
80
|
+
expect(out.map((k) => k.kind)).toEqual(["action", "query", "event", "app", "plugin"]);
|
|
81
|
+
expect(out.find((k) => k.kind === "action")!.count).toBe(1);
|
|
82
|
+
});
|
|
83
|
+
it("returns [] for a null view", () => {
|
|
84
|
+
expect(inspectKinds(null)).toEqual([]);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("nodesOfKind", () => {
|
|
89
|
+
it("returns the kind's nodes, name-sorted", () => {
|
|
90
|
+
expect(nodesOfKind(view, "action").map((n) => n.name)).toEqual(["orders.place"]);
|
|
91
|
+
expect(nodesOfKind(view, "nope")).toEqual([]);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("matchesNode", () => {
|
|
96
|
+
const node = nodesOfKind(view, "action")[0]!;
|
|
97
|
+
it("matches name + description, case-insensitively", () => {
|
|
98
|
+
expect(matchesNode(node, "PLACE")).toBe(true);
|
|
99
|
+
expect(matchesNode(node, "place an order")).toBe(true);
|
|
100
|
+
expect(matchesNode(node, "zzz")).toBe(false);
|
|
101
|
+
});
|
|
102
|
+
it("empty query matches", () => {
|
|
103
|
+
expect(matchesNode(node, "")).toBe(true);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("nodeToDetail", () => {
|
|
108
|
+
it("prefers the flat entry (carries the schema) for member kinds", () => {
|
|
109
|
+
const node = nodesOfKind(view, "action")[0]!;
|
|
110
|
+
const d = nodeToDetail(view, manifest, node)!;
|
|
111
|
+
expect(d.name).toBe("orders.place");
|
|
112
|
+
expect(d.schema).toEqual({ type: "object" });
|
|
113
|
+
expect(d.emits).toEqual(["orders.placed"]);
|
|
114
|
+
expect(d.public).toBe(true);
|
|
115
|
+
});
|
|
116
|
+
it("builds detail from the graph node for structural kinds (no flat entry)", () => {
|
|
117
|
+
const node = nodesOfKind(view, "plugin")[0]!;
|
|
118
|
+
const d = nodeToDetail(view, manifest, node)!;
|
|
119
|
+
expect(d.kind).toBe("plugin");
|
|
120
|
+
expect(d.description).toBe("Auth plugin");
|
|
121
|
+
expect(d.source).toEqual({ file: "plugins/auth.ts", line: 1 });
|
|
122
|
+
expect(d.extra.contributes).toEqual(["binding:user"]);
|
|
123
|
+
});
|
|
124
|
+
it("returns null for a null node", () => {
|
|
125
|
+
expect(nodeToDetail(view, manifest, null)).toBeNull();
|
|
126
|
+
});
|
|
127
|
+
it("surfaces env + config on app nodes (not in generic extra)", () => {
|
|
128
|
+
const node = nodesOfKind(view, "app")[0]!;
|
|
129
|
+
const d = nodeToDetail(view, manifest, node)!;
|
|
130
|
+
expect(d.kind).toBe("app");
|
|
131
|
+
expect(d.env).toEqual(["DATABASE_URL", "PORT"]);
|
|
132
|
+
expect(d.config).toEqual([{ file: "/abs/app/config/orders.ts", keys: ["orders", "limits"] }]);
|
|
133
|
+
// env/config render as their own sections — never duplicated into Details.
|
|
134
|
+
expect(d.extra.env).toBeUndefined();
|
|
135
|
+
expect(d.extra.config).toBeUndefined();
|
|
136
|
+
expect(d.extra.plugins).toEqual(["forge.actions"]);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe("relationships", () => {
|
|
141
|
+
it("groups a node's edges by humanized, direction-aware relation", () => {
|
|
142
|
+
const groups = relationships(view, "action:orders.place");
|
|
143
|
+
const byLabel = Object.fromEntries(groups.map((g) => [g.label, g.items.map((i) => i.id)]));
|
|
144
|
+
expect(byLabel["Emits"]).toEqual(["event:orders.placed"]);
|
|
145
|
+
expect(byLabel["Dispatched by"]).toEqual(["query:orders.by-id"]);
|
|
146
|
+
});
|
|
147
|
+
it("is empty for a node with no edges", () => {
|
|
148
|
+
expect(relationships(view, "event:orders.placed").every((g) => g.items.length)).toBe(true);
|
|
149
|
+
expect(relationships(view, "nope:x")).toEqual([]);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe("isDispatchable", () => {
|
|
154
|
+
it("is true for action / command / query", () => {
|
|
155
|
+
expect(isDispatchable("action")).toBe(true);
|
|
156
|
+
expect(isDispatchable("command")).toBe(true);
|
|
157
|
+
expect(isDispatchable("query")).toBe(true);
|
|
158
|
+
expect(isDispatchable("event")).toBe(false);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { kindColor, kindVariant, recordColorKey, KIND_COLOR, KIND_LEGEND } from "../kind-colors";
|
|
3
|
+
|
|
4
|
+
describe("kindColor", () => {
|
|
5
|
+
it("returns the canonical hex for a known kind", () => {
|
|
6
|
+
expect(kindColor("action")).toBe(KIND_COLOR.action);
|
|
7
|
+
expect(kindColor("workflow")).toBe(KIND_COLOR.workflow);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("falls back to the neutral dim for unknown/undefined", () => {
|
|
11
|
+
expect(kindColor("nope")).toBe("#8a97ad");
|
|
12
|
+
expect(kindColor(undefined)).toBe("#8a97ad");
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe("kindVariant", () => {
|
|
17
|
+
it("maps dispatch kinds to info", () => {
|
|
18
|
+
for (const k of ["action", "command", "query", "route"]) {
|
|
19
|
+
expect(kindVariant(k)).toBe("info");
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("maps app to success and actor/externalCall to warning", () => {
|
|
24
|
+
expect(kindVariant("app")).toBe("success");
|
|
25
|
+
expect(kindVariant("actor")).toBe("warning");
|
|
26
|
+
expect(kindVariant("externalCall")).toBe("warning");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("falls back to neutral", () => {
|
|
30
|
+
expect(kindVariant("event")).toBe("neutral");
|
|
31
|
+
expect(kindVariant(undefined)).toBe("neutral");
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("recordColorKey", () => {
|
|
36
|
+
it("maps dotted record kinds back to their handler-kind colour key", () => {
|
|
37
|
+
expect(recordColorKey("action.completed")).toBe("action");
|
|
38
|
+
expect(recordColorKey("event.published")).toBe("event");
|
|
39
|
+
expect(recordColorKey("query.executed")).toBe("query");
|
|
40
|
+
expect(recordColorKey("projection.folded")).toBe("projection");
|
|
41
|
+
expect(recordColorKey("actor.transitioned")).toBe("actor");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("maps family + stage kinds to their group colour", () => {
|
|
45
|
+
expect(recordColorKey("reaction.fired")).toBe("workflow");
|
|
46
|
+
expect(recordColorKey("external.call.failed")).toBe("externalCall");
|
|
47
|
+
expect(recordColorKey("source.stage")).toBe("sourceStage");
|
|
48
|
+
expect(recordColorKey("sink.stage")).toBe("sinkStage");
|
|
49
|
+
expect(kindColor(recordColorKey("external.call.failed"))).toBe(KIND_COLOR.externalCall);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("KIND_LEGEND", () => {
|
|
54
|
+
it("lists the user-facing handler kinds and every entry has a colour", () => {
|
|
55
|
+
expect(KIND_LEGEND).toContain("action");
|
|
56
|
+
expect(KIND_LEGEND).toContain("projection");
|
|
57
|
+
for (const k of KIND_LEGEND) expect(kindColor(k)).not.toBe("#8a97ad");
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import type { TelemetryRecord } from "../telemetry";
|
|
3
|
+
import {
|
|
4
|
+
recordTime,
|
|
5
|
+
recordSubject,
|
|
6
|
+
recordDuration,
|
|
7
|
+
matchesFilter,
|
|
8
|
+
filterRecords,
|
|
9
|
+
sortRecords,
|
|
10
|
+
nextIndex,
|
|
11
|
+
computeKpis,
|
|
12
|
+
distinctKinds,
|
|
13
|
+
shortId,
|
|
14
|
+
} from "../live-table";
|
|
15
|
+
|
|
16
|
+
function rec(over: Partial<TelemetryRecord> & { kind: string }): TelemetryRecord {
|
|
17
|
+
return over as TelemetryRecord;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const dispatched = rec({
|
|
21
|
+
kind: "action.dispatched",
|
|
22
|
+
action: "moderation.submit-post",
|
|
23
|
+
ts: "2026-06-15T10:00:00.000Z",
|
|
24
|
+
appName: "moderation",
|
|
25
|
+
envelope: {
|
|
26
|
+
messageId: "m-1-aaa",
|
|
27
|
+
correlationId: "c-1-zzz",
|
|
28
|
+
causationId: "m-1-aaa",
|
|
29
|
+
tenant: "acme",
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
const completed = rec({
|
|
33
|
+
kind: "action.completed",
|
|
34
|
+
action: "moderation.submit-post",
|
|
35
|
+
durationMs: 42,
|
|
36
|
+
ts: "2026-06-15T10:00:01.000Z",
|
|
37
|
+
envelope: { messageId: "m-2", correlationId: "c-1-zzz", causationId: "m-1-aaa" },
|
|
38
|
+
});
|
|
39
|
+
const failed = rec({
|
|
40
|
+
kind: "action.failed",
|
|
41
|
+
action: "moderation.submit-post",
|
|
42
|
+
ts: "2026-06-15T10:00:02.000Z",
|
|
43
|
+
error: { name: "Error", message: "boom" },
|
|
44
|
+
envelope: { messageId: "m-3", correlationId: "c-2", causationId: "m-1-aaa" },
|
|
45
|
+
});
|
|
46
|
+
const published = rec({
|
|
47
|
+
kind: "event.published",
|
|
48
|
+
event: { name: "post.submitted" },
|
|
49
|
+
ts: "2026-06-15T10:00:00.500Z",
|
|
50
|
+
envelope: { messageId: "m-4", correlationId: "c-1-zzz", causationId: "m-1-aaa" },
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("recordTime", () => {
|
|
54
|
+
it("parses ts to epoch millis", () => {
|
|
55
|
+
expect(recordTime(dispatched)).toBe(Date.parse("2026-06-15T10:00:00.000Z"));
|
|
56
|
+
});
|
|
57
|
+
it("is 0 when ts is missing or unparseable", () => {
|
|
58
|
+
expect(recordTime(rec({ kind: "x" }))).toBe(0);
|
|
59
|
+
expect(recordTime(rec({ kind: "x", ts: "not-a-date" }))).toBe(0);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("recordSubject", () => {
|
|
64
|
+
it("reads the per-kind subject field", () => {
|
|
65
|
+
expect(recordSubject(dispatched)).toBe("moderation.submit-post");
|
|
66
|
+
expect(recordSubject(rec({ kind: "query.executed", query: "users.by-id" }))).toBe(
|
|
67
|
+
"users.by-id",
|
|
68
|
+
);
|
|
69
|
+
expect(recordSubject(rec({ kind: "actor.transitioned", actor: "Post" }))).toBe("Post");
|
|
70
|
+
});
|
|
71
|
+
it("unwraps an EventMessage-shaped event or a string event", () => {
|
|
72
|
+
expect(recordSubject(published)).toBe("post.submitted");
|
|
73
|
+
expect(recordSubject(rec({ kind: "listener.fired", event: "post.submitted" }))).toBe(
|
|
74
|
+
"post.submitted",
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
it("falls back to em-dash when nothing identifies it", () => {
|
|
78
|
+
expect(recordSubject(rec({ kind: "mystery" }))).toBe("—");
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("recordDuration", () => {
|
|
83
|
+
it("returns the ms when present, else undefined", () => {
|
|
84
|
+
expect(recordDuration(completed)).toBe(42);
|
|
85
|
+
expect(recordDuration(dispatched)).toBeUndefined();
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("matchesFilter", () => {
|
|
90
|
+
it("matches across kind, subject, ids, tenant and error", () => {
|
|
91
|
+
expect(matchesFilter(dispatched, "submit-post")).toBe(true);
|
|
92
|
+
expect(matchesFilter(dispatched, "ACME")).toBe(true); // case-insensitive
|
|
93
|
+
expect(matchesFilter(dispatched, "c-1-zzz")).toBe(true);
|
|
94
|
+
expect(matchesFilter(failed, "boom")).toBe(true);
|
|
95
|
+
expect(matchesFilter(dispatched, "nope")).toBe(false);
|
|
96
|
+
});
|
|
97
|
+
it("an empty query matches everything", () => {
|
|
98
|
+
expect(matchesFilter(dispatched, "")).toBe(true);
|
|
99
|
+
expect(matchesFilter(dispatched, " ")).toBe(true);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("filterRecords", () => {
|
|
104
|
+
const all = [dispatched, completed, failed, published];
|
|
105
|
+
it("filters by kind", () => {
|
|
106
|
+
expect(filterRecords(all, { kind: "action.failed" })).toEqual([failed]);
|
|
107
|
+
});
|
|
108
|
+
it("filters by correlation chain", () => {
|
|
109
|
+
expect(filterRecords(all, { correlationId: "c-1-zzz" })).toEqual([
|
|
110
|
+
dispatched,
|
|
111
|
+
completed,
|
|
112
|
+
published,
|
|
113
|
+
]);
|
|
114
|
+
});
|
|
115
|
+
it("filters failures only", () => {
|
|
116
|
+
expect(filterRecords(all, { failuresOnly: true })).toEqual([failed]);
|
|
117
|
+
});
|
|
118
|
+
it("combines clauses (AND)", () => {
|
|
119
|
+
expect(filterRecords(all, { correlationId: "c-1-zzz", text: "event" })).toEqual([published]);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe("sortRecords", () => {
|
|
124
|
+
const all = [dispatched, completed, failed, published];
|
|
125
|
+
it("sorts by time descending (newest first) without mutating input", () => {
|
|
126
|
+
const out = sortRecords(all, "time", "desc");
|
|
127
|
+
expect(out.map((r) => r.kind)).toEqual([
|
|
128
|
+
"action.failed",
|
|
129
|
+
"action.completed",
|
|
130
|
+
"event.published",
|
|
131
|
+
"action.dispatched",
|
|
132
|
+
]);
|
|
133
|
+
expect(all[0]).toBe(dispatched); // original order intact
|
|
134
|
+
});
|
|
135
|
+
it("sorts by duration ascending (missing durations sort first)", () => {
|
|
136
|
+
const out = sortRecords(all, "duration", "asc");
|
|
137
|
+
expect(out[out.length - 1]).toBe(completed); // only one with a duration → last asc
|
|
138
|
+
});
|
|
139
|
+
it("sorts by subject and is stable on ties", () => {
|
|
140
|
+
const a = rec({ kind: "action.completed", action: "z", ts: "2026-06-15T10:00:00.000Z" });
|
|
141
|
+
const b = rec({ kind: "action.completed", action: "z", ts: "2026-06-15T10:00:05.000Z" });
|
|
142
|
+
const out = sortRecords([a, b], "subject", "asc");
|
|
143
|
+
expect(out).toEqual([a, b]); // first-seen order preserved on equal subjects
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe("nextIndex", () => {
|
|
148
|
+
it("returns -1 for an empty list", () => {
|
|
149
|
+
expect(nextIndex(0, 0, 1)).toBe(-1);
|
|
150
|
+
});
|
|
151
|
+
it("starts at the head/tail from no selection", () => {
|
|
152
|
+
expect(nextIndex(-1, 5, 1)).toBe(0);
|
|
153
|
+
expect(nextIndex(-1, 5, -1)).toBe(4);
|
|
154
|
+
});
|
|
155
|
+
it("clamps at the ends (no wrap)", () => {
|
|
156
|
+
expect(nextIndex(0, 5, -1)).toBe(0);
|
|
157
|
+
expect(nextIndex(4, 5, 1)).toBe(4);
|
|
158
|
+
expect(nextIndex(2, 5, 1)).toBe(3);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe("computeKpis", () => {
|
|
163
|
+
it("rolls up totals, failures, distinct kinds + chains, and rate/s", () => {
|
|
164
|
+
const k = computeKpis([dispatched, completed, failed, published]);
|
|
165
|
+
expect(k.total).toBe(4);
|
|
166
|
+
expect(k.failures).toBe(1);
|
|
167
|
+
expect(k.kinds).toBe(4);
|
|
168
|
+
expect(k.chains).toBe(2); // c-1-zzz + c-2
|
|
169
|
+
// span = 2s across 4 records → 2/s
|
|
170
|
+
expect(k.perSecond).toBe(2);
|
|
171
|
+
});
|
|
172
|
+
it("rate is 0 with fewer than two timestamps", () => {
|
|
173
|
+
expect(computeKpis([dispatched]).perSecond).toBe(0);
|
|
174
|
+
expect(computeKpis([]).total).toBe(0);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe("distinctKinds", () => {
|
|
179
|
+
it("lists kinds in first-seen order", () => {
|
|
180
|
+
expect(distinctKinds([completed, dispatched, completed, published])).toEqual([
|
|
181
|
+
"action.completed",
|
|
182
|
+
"action.dispatched",
|
|
183
|
+
"event.published",
|
|
184
|
+
]);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe("shortId", () => {
|
|
189
|
+
it("takes the first dash segment", () => {
|
|
190
|
+
expect(shortId("c-1-zzz")).toBe("c");
|
|
191
|
+
expect(shortId("abcdef0123456789")).toBe("abcdef01");
|
|
192
|
+
expect(shortId(undefined)).toBe("—");
|
|
193
|
+
});
|
|
194
|
+
});
|