@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.
Files changed (130) hide show
  1. package/package.json +5 -3
  2. package/src/App.vue +62 -56
  3. package/src/components/BcCard.stories.ts +47 -0
  4. package/src/components/BcCard.vue +152 -0
  5. package/src/components/DurationBar.stories.ts +55 -0
  6. package/src/components/DurationBar.vue +72 -0
  7. package/src/components/ErrorCard.stories.ts +133 -0
  8. package/src/components/ErrorCard.vue +153 -0
  9. package/src/components/GraphCanvas.stories.ts +48 -0
  10. package/src/components/GraphCanvas.vue +88 -0
  11. package/src/components/KpiTile.stories.ts +32 -0
  12. package/src/components/KpiTile.vue +39 -0
  13. package/src/components/LiveTable.stories.ts +78 -0
  14. package/src/components/LiveTable.vue +186 -0
  15. package/src/components/MetadataInspector.stories.ts +53 -0
  16. package/src/components/MetadataInspector.vue +105 -0
  17. package/src/components/NodeCard.stories.ts +44 -0
  18. package/src/components/NodeCard.vue +150 -0
  19. package/src/components/RcaPanel.stories.ts +95 -0
  20. package/src/components/RcaPanel.vue +223 -0
  21. package/src/components/ServiceNode.vue +134 -0
  22. package/src/components/SourceDrawer.vue +6 -4
  23. package/src/components/SourcePill.vue +10 -3
  24. package/src/components/StatusBadge.stories.ts +33 -0
  25. package/src/components/StatusBadge.vue +54 -0
  26. package/src/components/Waterfall.stories.ts +85 -0
  27. package/src/components/Waterfall.vue +53 -0
  28. package/src/components/WaterfallRow.vue +74 -0
  29. package/src/components/__tests__/BcCard.test.ts +53 -0
  30. package/src/components/__tests__/DurationBar.test.ts +31 -0
  31. package/src/components/__tests__/ErrorCard.test.ts +71 -0
  32. package/src/components/__tests__/KpiTile.test.ts +23 -0
  33. package/src/components/__tests__/LiveTable.test.ts +100 -0
  34. package/src/components/__tests__/MetadataInspector.test.ts +38 -0
  35. package/src/components/__tests__/NodeCard.test.ts +116 -0
  36. package/src/components/__tests__/RcaPanel.test.ts +81 -0
  37. package/src/components/__tests__/StatusBadge.test.ts +23 -0
  38. package/src/components/__tests__/Waterfall.test.ts +54 -0
  39. package/src/components/index.ts +13 -0
  40. package/src/composables/__tests__/composables-context.test.ts +107 -0
  41. package/src/composables/__tests__/useTelemetry.test.ts +104 -0
  42. package/src/composables/__tests__/useTelemetryRuns.test.ts +282 -0
  43. package/src/composables/useDiscovery.ts +73 -0
  44. package/src/composables/useEndpoints.ts +94 -0
  45. package/src/composables/useLogTail.ts +51 -0
  46. package/src/composables/useManifest.ts +43 -0
  47. package/src/composables/useProcesses.ts +114 -0
  48. package/src/composables/useProject.ts +34 -0
  49. package/src/composables/useTelemetry.ts +270 -0
  50. package/src/lib/__tests__/bc-graph.test.ts +218 -0
  51. package/src/lib/__tests__/dispatch-form.test.ts +113 -0
  52. package/src/lib/__tests__/error-friendly.test.ts +198 -0
  53. package/src/lib/__tests__/home.test.ts +231 -0
  54. package/src/lib/__tests__/inspect.test.ts +160 -0
  55. package/src/lib/__tests__/kind-colors.test.ts +59 -0
  56. package/src/lib/__tests__/live-table.test.ts +194 -0
  57. package/src/lib/__tests__/manifest-health.test.ts +120 -0
  58. package/src/lib/__tests__/manifest.test.ts +87 -0
  59. package/src/lib/__tests__/metadata.test.ts +47 -0
  60. package/src/lib/__tests__/node-metrics.test.ts +144 -0
  61. package/src/lib/__tests__/operate.test.ts +97 -0
  62. package/src/lib/__tests__/pipeline-flow.test.ts +79 -0
  63. package/src/lib/__tests__/rca.test.ts +124 -0
  64. package/src/lib/__tests__/telemetry.test.ts +91 -0
  65. package/src/lib/__tests__/topology-graph.test.ts +331 -0
  66. package/src/lib/__tests__/topology-view.test.ts +154 -0
  67. package/src/lib/__tests__/waterfall.test.ts +165 -0
  68. package/src/lib/bc-graph.ts +298 -0
  69. package/src/lib/dispatch-form.ts +160 -0
  70. package/src/lib/error-friendly.ts +288 -0
  71. package/src/lib/home.ts +191 -0
  72. package/src/lib/inspect.ts +226 -0
  73. package/src/lib/kind-colors.ts +132 -0
  74. package/src/lib/live-table.ts +204 -0
  75. package/src/lib/manifest-health.ts +71 -0
  76. package/src/lib/manifest.ts +139 -0
  77. package/src/lib/metadata.ts +52 -0
  78. package/src/lib/node-metrics.ts +242 -0
  79. package/src/lib/operate.ts +114 -0
  80. package/src/lib/pipeline-flow.ts +120 -0
  81. package/src/lib/rca.ts +193 -0
  82. package/src/lib/telemetry.ts +155 -0
  83. package/src/lib/topology-graph.ts +551 -0
  84. package/src/lib/topology-view.ts +185 -0
  85. package/src/lib/waterfall.ts +148 -0
  86. package/src/main.ts +63 -29
  87. package/src/pages/Errors.vue +272 -0
  88. package/src/pages/Home.stories.ts +7 -8
  89. package/src/pages/Home.vue +255 -540
  90. package/src/pages/Hooks.stories.ts +44 -0
  91. package/src/pages/Hooks.vue +165 -164
  92. package/src/pages/Inspect.vue +240 -0
  93. package/src/pages/Map.vue +187 -0
  94. package/src/pages/Operate.vue +74 -0
  95. package/src/pages/Plugins.stories.ts +1 -1
  96. package/src/pages/Plugins.vue +174 -238
  97. package/src/pages/Projects.vue +62 -60
  98. package/src/pages/Streams.vue +344 -0
  99. package/src/pages/Topology.vue +318 -136
  100. package/src/pages/Trace.vue +174 -412
  101. package/src/pages/__tests__/Home.test.ts +109 -54
  102. package/src/pages/__tests__/Hooks.test.ts +5 -5
  103. package/src/pages/__tests__/Inspect.test.ts +111 -0
  104. package/src/pages/__tests__/Plugins.test.ts +85 -35
  105. package/src/pages/__tests__/Trace.test.ts +117 -0
  106. package/src/pages/operate/CommandsPanel.vue +186 -0
  107. package/src/pages/{Dispatch.vue → operate/DispatchPanel.vue} +100 -203
  108. package/src/pages/operate/EndpointPicker.vue +56 -0
  109. package/src/pages/operate/RunPanel.vue +316 -0
  110. package/src/server/__tests__/nwire-read.test.ts +80 -0
  111. package/src/server/nwire-read.ts +63 -0
  112. package/vite.config.ts +220 -2
  113. package/src/lib/__tests__/normalize-cache.test.ts +0 -105
  114. package/src/lib/cache.ts +0 -312
  115. package/src/lib/normalize-cache.ts +0 -92
  116. package/src/pages/Actions.vue +0 -171
  117. package/src/pages/Apps.vue +0 -177
  118. package/src/pages/Commands.vue +0 -262
  119. package/src/pages/Events.vue +0 -210
  120. package/src/pages/Live.vue +0 -249
  121. package/src/pages/Overview.vue +0 -161
  122. package/src/pages/Projections.vue +0 -148
  123. package/src/pages/Queries.vue +0 -148
  124. package/src/pages/Run.vue +0 -618
  125. package/src/pages/Sinks.vue +0 -124
  126. package/src/pages/TraceNode.vue +0 -164
  127. package/src/pages/Workflows.vue +0 -184
  128. package/src/pages/__tests__/Actions.test.ts +0 -98
  129. package/src/pages/__tests__/Projections.test.ts +0 -90
  130. package/src/pages/__tests__/Queries.test.ts +0 -86
@@ -0,0 +1,124 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { groupIncidents, buildTimeline, computeImpact } from "../rca";
3
+ import type { TelemetryRecord } from "../telemetry";
4
+
5
+ const env = (
6
+ messageId: string,
7
+ causationId: string,
8
+ correlationId: string,
9
+ extra?: Record<string, string>,
10
+ ) => ({
11
+ messageId,
12
+ causationId,
13
+ correlationId,
14
+ ...extra,
15
+ });
16
+
17
+ // A failing chain: dispatch → published → action.failed (retry) → dlq.recorded,
18
+ // all on correlation c1, plus an unrelated success on c2 and a solo failure.
19
+ const RECORDS: TelemetryRecord[] = [
20
+ {
21
+ kind: "action.dispatched",
22
+ action: "orders.charge",
23
+ appName: "billing",
24
+ ts: "2026-01-01T00:00:00.000Z",
25
+ envelope: env("m1", "m1", "c1", { tenant: "acme", userId: "u1" }),
26
+ },
27
+ {
28
+ kind: "event.published",
29
+ event: "ChargeStarted",
30
+ appName: "billing",
31
+ ts: "2026-01-01T00:00:00.010Z",
32
+ envelope: env("m2", "m1", "c1"),
33
+ },
34
+ {
35
+ kind: "action.failed",
36
+ action: "orders.charge",
37
+ appName: "billing",
38
+ attempt: 1,
39
+ maxAttempts: 3,
40
+ willRetry: true,
41
+ ts: "2026-01-01T00:00:00.050Z",
42
+ error: { name: "TimeoutError", message: "timed out" },
43
+ envelope: env("m3", "m1", "c1"),
44
+ },
45
+ {
46
+ kind: "dlq.recorded",
47
+ action: "orders.charge",
48
+ appName: "billing",
49
+ attempts: 3,
50
+ ts: "2026-01-01T00:00:00.300Z",
51
+ error: { name: "TimeoutError", message: "timed out" },
52
+ envelope: env("m4", "m1", "c1"),
53
+ },
54
+ {
55
+ kind: "action.completed",
56
+ action: "orders.ok",
57
+ appName: "billing",
58
+ ts: "2026-01-01T00:00:01.000Z",
59
+ envelope: env("n1", "n1", "c2"),
60
+ },
61
+ {
62
+ kind: "external.call.failed",
63
+ call: "stripe.charge",
64
+ target: "https://stripe",
65
+ attempt: 1,
66
+ willRetry: false,
67
+ ts: "2026-01-01T00:00:02.000Z",
68
+ error: { name: "FetchError", message: "ECONNREFUSED" },
69
+ },
70
+ ];
71
+
72
+ describe("groupIncidents", () => {
73
+ it("groups failures by correlation; the terminal outcome is the headline", () => {
74
+ const incidents = groupIncidents(RECORDS);
75
+ const c1 = incidents.find((i) => i.id === "c1")!;
76
+ expect(c1).toBeTruthy();
77
+ expect(c1.failures).toHaveLength(2); // action.failed + dlq.recorded
78
+ expect(c1.root.kind).toBe("dlq.recorded"); // terminal outcome represents the incident
79
+ expect(c1.related).toHaveLength(4); // whole chain, not just failures
80
+ expect(c1.severity).toBe("critical"); // dlq.recorded dominates
81
+ expect(c1.count).toBe(2);
82
+ });
83
+ it("treats an envelope-less failure as a solo incident", () => {
84
+ const incidents = groupIncidents(RECORDS);
85
+ const solo = incidents.find((i) => i.id.startsWith("solo:"))!;
86
+ expect(solo).toBeTruthy();
87
+ expect(solo.root.kind).toBe("external.call.failed");
88
+ expect(solo.related).toHaveLength(1);
89
+ });
90
+ it("ignores correlations with no failures", () => {
91
+ const incidents = groupIncidents(RECORDS);
92
+ expect(incidents.find((i) => i.id === "c2")).toBeUndefined();
93
+ });
94
+ it("sorts newest first", () => {
95
+ const incidents = groupIncidents(RECORDS);
96
+ // the solo external failure is the latest record → first
97
+ expect(incidents[0]!.id).toMatch(/^solo:/);
98
+ });
99
+ });
100
+
101
+ describe("buildTimeline", () => {
102
+ it("orders the chain with ms offsets from the first record", () => {
103
+ const c1 = groupIncidents(RECORDS).find((i) => i.id === "c1")!;
104
+ const tl = buildTimeline(c1);
105
+ expect(tl).toHaveLength(4);
106
+ expect(tl[0]!.offsetMs).toBe(0);
107
+ expect(tl[3]!.offsetMs).toBe(300);
108
+ expect(tl.filter((e) => e.failure)).toHaveLength(2);
109
+ });
110
+ });
111
+
112
+ describe("computeImpact", () => {
113
+ it("derives the blast radius from the chain", () => {
114
+ const c1 = groupIncidents(RECORDS).find((i) => i.id === "c1")!;
115
+ const impact = computeImpact(c1);
116
+ expect(impact.tenant).toBe("acme");
117
+ expect(impact.userId).toBe("u1");
118
+ expect(impact.apps).toEqual(["billing"]);
119
+ expect(impact.records).toBe(4);
120
+ expect(impact.failures).toBe(2);
121
+ expect(impact.retries).toBe(2); // 3 attempts → 2 retries
122
+ expect(impact.deadLettered).toBe(true);
123
+ });
124
+ });
@@ -0,0 +1,91 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ parseRecord,
4
+ isFailure,
5
+ groupByCorrelation,
6
+ buildCorrelationTree,
7
+ type TelemetryRecord,
8
+ } from "../telemetry";
9
+
10
+ const env = (messageId: string, causationId: string, correlationId = "c1") => ({
11
+ messageId,
12
+ causationId,
13
+ correlationId,
14
+ });
15
+
16
+ describe("parseRecord", () => {
17
+ it("parses a JSON string with a kind", () => {
18
+ const r = parseRecord('{"kind":"action.completed","durationMs":3}');
19
+ expect(r?.kind).toBe("action.completed");
20
+ expect(r?.durationMs).toBe(3);
21
+ });
22
+ it("accepts an already-parsed object", () => {
23
+ expect(parseRecord({ kind: "event.published" })?.kind).toBe("event.published");
24
+ });
25
+ it("returns null for invalid json, missing kind, or non-object", () => {
26
+ expect(parseRecord("not json")).toBeNull();
27
+ expect(parseRecord('{"no":"kind"}')).toBeNull();
28
+ expect(parseRecord(42)).toBeNull();
29
+ });
30
+ });
31
+
32
+ describe("isFailure", () => {
33
+ it("flags failure kinds + anything carrying an error", () => {
34
+ expect(isFailure({ kind: "action.failed" })).toBe(true);
35
+ expect(isFailure({ kind: "dlq.recorded" })).toBe(true);
36
+ expect(isFailure({ kind: "external.call.failed" })).toBe(true);
37
+ expect(isFailure({ kind: "action.completed", error: { name: "E", message: "x" } })).toBe(true);
38
+ });
39
+ it("does not flag success kinds", () => {
40
+ expect(isFailure({ kind: "action.completed" })).toBe(false);
41
+ expect(isFailure({ kind: "event.published" })).toBe(false);
42
+ });
43
+ });
44
+
45
+ describe("groupByCorrelation", () => {
46
+ it("buckets by correlationId; envelope-less under ''", () => {
47
+ const recs: TelemetryRecord[] = [
48
+ { kind: "a", envelope: env("m1", "m1", "c1") },
49
+ { kind: "b", envelope: env("m2", "m1", "c1") },
50
+ { kind: "c", envelope: env("m3", "m3", "c2") },
51
+ { kind: "hook.step" },
52
+ ];
53
+ const g = groupByCorrelation(recs);
54
+ expect(g.get("c1")).toHaveLength(2);
55
+ expect(g.get("c2")).toHaveLength(1);
56
+ expect(g.get("")).toHaveLength(1);
57
+ });
58
+ });
59
+
60
+ describe("buildCorrelationTree", () => {
61
+ it("roots the chain head and nests children by causationId", () => {
62
+ const recs: TelemetryRecord[] = [
63
+ { kind: "action.dispatched", envelope: env("m1", "m1") }, // head (self-caused)
64
+ { kind: "event.published", envelope: env("m2", "m1") }, // child of m1
65
+ { kind: "listener.fired", envelope: env("m3", "m2") }, // child of m2
66
+ ];
67
+ const roots = buildCorrelationTree(recs);
68
+ expect(roots).toHaveLength(1);
69
+ expect(roots[0]!.record.kind).toBe("action.dispatched");
70
+ expect(roots[0]!.children[0]!.record.kind).toBe("event.published");
71
+ expect(roots[0]!.children[0]!.children[0]!.record.kind).toBe("listener.fired");
72
+ });
73
+ it("treats an orphan (parent not present) as a root, and skips envelope-less records", () => {
74
+ const recs: TelemetryRecord[] = [
75
+ { kind: "orphan", envelope: env("m9", "m-missing") },
76
+ { kind: "no-envelope" },
77
+ ];
78
+ const roots = buildCorrelationTree(recs);
79
+ expect(roots).toHaveLength(1);
80
+ expect(roots[0]!.record.kind).toBe("orphan");
81
+ });
82
+ it("keeps the first record per messageId (multi-stage dispatch)", () => {
83
+ const recs: TelemetryRecord[] = [
84
+ { kind: "action.dispatched", envelope: env("m1", "m1") },
85
+ { kind: "action.completed", envelope: env("m1", "m1") },
86
+ ];
87
+ const roots = buildCorrelationTree(recs);
88
+ expect(roots).toHaveLength(1);
89
+ expect(roots[0]!.record.kind).toBe("action.dispatched");
90
+ });
91
+ });
@@ -0,0 +1,331 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { manifestView, type Manifest } from "../manifest";
3
+ import {
4
+ buildAppTopology,
5
+ buildAppFlowEdges,
6
+ layoutAppTopology,
7
+ buildWiringGraph,
8
+ layoutWiringGraph,
9
+ buildEndpoints,
10
+ } from "../topology-graph";
11
+
12
+ /** A tolerant view over a flat (model-less) manifest — the common shipped shape. */
13
+ function viewOf(partial: Record<string, unknown>) {
14
+ return manifestView({ ...partial } as unknown as Manifest);
15
+ }
16
+
17
+ describe("topology-graph: buildAppTopology", () => {
18
+ it("clusters per app with merged plugins, sinks, and per-app counts", () => {
19
+ const view = viewOf({
20
+ apps: [
21
+ { name: "billing", plugins: ["forge"] },
22
+ { name: "moderation", plugins: [] },
23
+ ],
24
+ plugins: [
25
+ { name: "auth", app: "billing" },
26
+ { name: "forge", app: "billing" }, // dup of the inline list — de-duped
27
+ ],
28
+ sinks: [
29
+ { name: "queue", app: "billing", position: "terminal", kind: "bullmq" },
30
+ { name: "tap", app: "billing", position: "early", kind: "capture" },
31
+ ],
32
+ actions: [
33
+ { name: "billing.charge", app: "billing" },
34
+ { name: "moderation.submit", app: "moderation" },
35
+ ],
36
+ events: [{ name: "billing.charged", app: "billing" }],
37
+ projections: [{ name: "moderation.queue", app: "moderation" }],
38
+ });
39
+
40
+ const apps = buildAppTopology(view);
41
+ expect(apps.map((a) => a.name)).toEqual(["billing", "moderation"]); // name-sorted
42
+
43
+ const billing = apps[0]!;
44
+ expect(billing.plugins).toEqual(["auth", "forge"]); // merged + de-duped + sorted
45
+ expect(billing.actions).toBe(1);
46
+ expect(billing.events).toBe(1);
47
+ expect(billing.projections).toBe(0);
48
+ // Sinks ordered early → terminal.
49
+ expect(billing.sinks.map((s) => s.position)).toEqual(["early", "terminal"]);
50
+ expect(billing.sinks[0]!.label).toBe("early · capture");
51
+
52
+ const moderation = apps[1]!;
53
+ expect(moderation.plugins).toEqual([]);
54
+ expect(moderation.actions).toBe(1);
55
+ expect(moderation.projections).toBe(1);
56
+ });
57
+
58
+ it("buckets unscoped primitives under (unscoped)", () => {
59
+ const view = viewOf({ apps: [], actions: [{ name: "orphan" }] });
60
+ const apps = buildAppTopology(view);
61
+ expect(apps.map((a) => a.name)).toEqual(["(unscoped)"]);
62
+ expect(apps[0]!.actions).toBe(1);
63
+ });
64
+
65
+ it("returns [] for a null view", () => {
66
+ expect(buildAppTopology(null)).toEqual([]);
67
+ });
68
+ });
69
+
70
+ describe("topology-graph: buildAppFlowEdges", () => {
71
+ it("collapses the flat event graph into one cross-app edge per pair, dropping in-app edges", () => {
72
+ const view = viewOf({
73
+ actions: [
74
+ { name: "orders.place", app: "orders" },
75
+ { name: "billing.charge", app: "billing" },
76
+ ],
77
+ events: [
78
+ { name: "orders.placed", app: "orders" },
79
+ { name: "billing.charged", app: "billing" },
80
+ ],
81
+ graph: {
82
+ events: [
83
+ // in-app: action → its own event — dropped.
84
+ { from: "orders.place", to: "orders.placed", via: "emits" },
85
+ // cross-app: orders' event consumed in billing.
86
+ { from: "orders.placed", to: "billing.charge", via: "listens" },
87
+ ],
88
+ },
89
+ });
90
+
91
+ const edges = buildAppFlowEdges(view);
92
+ expect(edges).toHaveLength(1);
93
+ expect(edges[0]).toMatchObject({ from: "orders", to: "billing", label: "orders.placed" });
94
+ });
95
+
96
+ it("prefers the deep model edges (resolving node ids) over the flat graph", () => {
97
+ const view = viewOf({
98
+ actions: [{ name: "billing.charge", app: "billing" }],
99
+ events: [{ name: "orders.placed", app: "orders" }],
100
+ model: {
101
+ nodes: [
102
+ { id: "event:orders.placed", kind: "event", name: "orders.placed" },
103
+ { id: "action:billing.charge", kind: "action", name: "billing.charge" },
104
+ ],
105
+ edges: [{ from: "event:orders.placed", to: "action:billing.charge", type: "listens" }],
106
+ },
107
+ // A flat graph that, if used, would yield a different edge — proves precedence.
108
+ graph: { events: [{ from: "noise", to: "noise", via: "x" }] },
109
+ });
110
+
111
+ const edges = buildAppFlowEdges(view);
112
+ expect(edges).toEqual([{ from: "orders", to: "billing", label: "orders.placed" }]);
113
+ });
114
+
115
+ it("returns [] for a null view or no resolvable endpoints", () => {
116
+ expect(buildAppFlowEdges(null)).toEqual([]);
117
+ expect(buildAppFlowEdges(viewOf({ graph: { events: [{ from: "a", to: "b" }] } }))).toEqual([]);
118
+ });
119
+ });
120
+
121
+ describe("topology-graph: layoutAppTopology", () => {
122
+ const app = (name: string, plugins: string[] = [], sinks = 0) => ({
123
+ name,
124
+ plugins,
125
+ sinks: Array.from({ length: sinks }, (_, i) => ({ label: `s${i}` })),
126
+ actions: 0,
127
+ events: 0,
128
+ projections: 0,
129
+ });
130
+
131
+ it("places cards in a grid with deterministic positions", () => {
132
+ const nodes = layoutAppTopology([app("a"), app("b"), app("c"), app("d")], {
133
+ columns: 3,
134
+ width: 200,
135
+ gapX: 50,
136
+ originX: 0,
137
+ originY: 0,
138
+ });
139
+ // First row: three columns.
140
+ expect(nodes[0]!).toMatchObject({ x: 0, y: 0 });
141
+ expect(nodes[1]!).toMatchObject({ x: 250 });
142
+ expect(nodes[2]!).toMatchObject({ x: 500 });
143
+ // Fourth card wraps to the next row.
144
+ expect(nodes[3]!.x).toBe(0);
145
+ expect(nodes[3]!.y).toBeGreaterThan(0);
146
+ });
147
+
148
+ it("grows card height with plugin + sink count", () => {
149
+ const [small, tall] = layoutAppTopology([app("a"), app("b", ["p1", "p2"], 2)]);
150
+ expect(tall!.height).toBeGreaterThan(small!.height);
151
+ });
152
+
153
+ it("returns [] for no apps", () => {
154
+ expect(layoutAppTopology([])).toEqual([]);
155
+ });
156
+ });
157
+
158
+ /** A deep-model view — the wiring selectors read `model` directly. */
159
+ function modelView(nodes: unknown[], edges: unknown[], extra: Record<string, unknown> = {}) {
160
+ return manifestView({ ...extra, model: { nodes, edges } } as unknown as Manifest);
161
+ }
162
+
163
+ describe("topology-graph: buildWiringGraph", () => {
164
+ // route → action → event → projection/workflow, workflow → action (back-edge).
165
+ const nodes = [
166
+ { id: "route:GET /posts", kind: "route", name: "GET /posts", data: { app: "moderation" } },
167
+ { id: "action:moderation.submit", kind: "action", name: "moderation.submit" },
168
+ { id: "query:moderation.list", kind: "query", name: "moderation.list" },
169
+ { id: "event:moderation.submitted", kind: "event", name: "moderation.submitted" },
170
+ { id: "projection:queue", kind: "projection", name: "queue" },
171
+ { id: "workflow:auto", kind: "workflow", name: "auto" },
172
+ { id: "sinkStage:outbox", kind: "sinkStage", name: "outbox" },
173
+ // not a pipeline kind — excluded.
174
+ { id: "plugin:forge", kind: "plugin", name: "forge" },
175
+ ];
176
+ const edges = [
177
+ { from: "route:GET /posts", to: "action:moderation.submit", type: "triggers" },
178
+ { from: "action:moderation.submit", to: "event:moderation.submitted", type: "emits" },
179
+ { from: "event:moderation.submitted", to: "projection:queue", type: "delivers" },
180
+ { from: "event:moderation.submitted", to: "workflow:auto", type: "delivers" },
181
+ { from: "query:moderation.list", to: "projection:queue", type: "reads" },
182
+ { from: "workflow:auto", to: "action:moderation.submit", type: "dispatches" }, // back-edge
183
+ { from: "event:moderation.submitted", to: "sinkStage:outbox", type: "delivers" },
184
+ // infra edge — excluded.
185
+ { from: "capability:ctx", to: "action:moderation.submit", type: "provides" },
186
+ ];
187
+
188
+ it("lays nodes into source→handler→effect→sink lanes by kind", () => {
189
+ const g = buildWiringGraph(modelView(nodes, edges));
190
+ const lane = (id: string) => g.nodes.find((n) => n.id === id)?.layer;
191
+ expect(lane("route:GET /posts")).toBe("source");
192
+ expect(lane("action:moderation.submit")).toBe("handler");
193
+ expect(lane("query:moderation.list")).toBe("handler");
194
+ expect(lane("event:moderation.submitted")).toBe("effect");
195
+ expect(lane("projection:queue")).toBe("effect");
196
+ expect(lane("workflow:auto")).toBe("effect");
197
+ expect(lane("sinkStage:outbox")).toBe("sink");
198
+ // The plugin node is not a pipeline kind.
199
+ expect(g.nodes.find((n) => n.id === "plugin:forge")).toBeUndefined();
200
+ });
201
+
202
+ it("keeps only wiring edge types and flags right→left back-edges", () => {
203
+ const g = buildWiringGraph(modelView(nodes, edges));
204
+ // The `provides` infra edge is dropped.
205
+ expect(g.edges.some((e) => e.type === "provides")).toBe(false);
206
+ const dispatch = g.edges.find((e) => e.type === "dispatches");
207
+ expect(dispatch?.back).toBe(true); // effect (workflow) → handler (action)
208
+ const emit = g.edges.find((e) => e.type === "emits");
209
+ expect(emit?.back).toBe(false); // handler → effect
210
+ });
211
+
212
+ it("derives a node's app from data.app, else the dotted-name head", () => {
213
+ const g = buildWiringGraph(modelView(nodes, edges));
214
+ expect(g.nodes.find((n) => n.id === "route:GET /posts")?.app).toBe("moderation");
215
+ // action has no data.app → "moderation" from "moderation.submit".
216
+ expect(g.nodes.find((n) => n.id === "action:moderation.submit")?.app).toBe("moderation");
217
+ // single-word nodes (queue, auto, outbox) fall back to (unscoped).
218
+ expect(g.apps).toContain("moderation");
219
+ });
220
+
221
+ it("scopes to one app when filtered, dropping cross-app nodes", () => {
222
+ const two = [
223
+ { id: "action:a.one", kind: "action", name: "a.one" },
224
+ { id: "action:b.two", kind: "action", name: "b.two" },
225
+ ];
226
+ const g = buildWiringGraph(modelView(two, []), "a");
227
+ expect(g.nodes.map((n) => n.id)).toEqual(["action:a.one"]);
228
+ });
229
+
230
+ it("returns an empty graph for a null view", () => {
231
+ expect(buildWiringGraph(null)).toEqual({ nodes: [], edges: [], apps: [] });
232
+ });
233
+ });
234
+
235
+ describe("topology-graph: layoutWiringGraph", () => {
236
+ const g = (kinds: Array<[string, string]>) => ({
237
+ nodes: kinds.map(([id, kind]) => ({
238
+ id,
239
+ kind,
240
+ name: id,
241
+ layer:
242
+ kind === "route"
243
+ ? ("source" as const)
244
+ : kind === "action"
245
+ ? ("handler" as const)
246
+ : kind === "event"
247
+ ? ("effect" as const)
248
+ : ("sink" as const),
249
+ app: "x",
250
+ })),
251
+ edges: [],
252
+ apps: ["x"],
253
+ });
254
+
255
+ it("places lanes left→right by layer, stacking within a lane", () => {
256
+ const laid = layoutWiringGraph(
257
+ g([
258
+ ["route:r", "route"],
259
+ ["action:a1", "action"],
260
+ ["action:a2", "action"],
261
+ ["event:e", "event"],
262
+ ]),
263
+ { nodeWidth: 200, laneGap: 100 },
264
+ );
265
+ const x = (id: string) => laid.find((n) => n.id === id)!.x;
266
+ // source lane is left of handler lane is left of effect lane.
267
+ expect(x("route:r")).toBeLessThan(x("action:a1"));
268
+ expect(x("action:a1")).toBeLessThan(x("event:e"));
269
+ // two handlers share a column, stacked vertically.
270
+ expect(x("action:a1")).toBe(x("action:a2"));
271
+ const ay = laid.find((n) => n.id === "action:a1")!.y;
272
+ const a2y = laid.find((n) => n.id === "action:a2")!.y;
273
+ expect(a2y).toBeGreaterThan(ay);
274
+ });
275
+
276
+ it("collapses empty lanes — no gap where a layer has no nodes", () => {
277
+ // No handler or effect lane: source sits directly beside sink.
278
+ const laid = layoutWiringGraph(
279
+ g([
280
+ ["route:r", "route"],
281
+ ["sinkStage:s", "sink"],
282
+ ]),
283
+ { nodeWidth: 200, laneGap: 100 },
284
+ );
285
+ expect(laid.find((n) => n.id === "sinkStage:s")!.x).toBe(300);
286
+ });
287
+
288
+ it("returns [] for an empty graph", () => {
289
+ expect(layoutWiringGraph({ nodes: [], edges: [], apps: [] })).toEqual([]);
290
+ });
291
+ });
292
+
293
+ describe("topology-graph: buildEndpoints", () => {
294
+ it("lists routes as METHOD /path → handler, resolving the triggers edge", () => {
295
+ const nodes = [
296
+ {
297
+ id: "route:GET /posts",
298
+ kind: "route",
299
+ name: "GET /posts",
300
+ data: { method: "get", path: "/posts", app: "moderation" },
301
+ },
302
+ { id: "action:moderation.submit", kind: "action", name: "moderation.submit" },
303
+ ];
304
+ const edges = [{ from: "route:GET /posts", to: "action:moderation.submit", type: "triggers" }];
305
+ const eps = buildEndpoints(modelView(nodes, edges));
306
+ expect(eps).toHaveLength(1);
307
+ expect(eps[0]).toMatchObject({
308
+ method: "GET",
309
+ path: "/posts",
310
+ app: "moderation",
311
+ handler: { id: "action:moderation.submit", kind: "action", name: "moderation.submit" },
312
+ });
313
+ });
314
+
315
+ it("parses METHOD + path from the node name when no data fields exist", () => {
316
+ const eps = buildEndpoints(
317
+ modelView([{ id: "route:POST /charge", kind: "route", name: "POST /charge" }], []),
318
+ );
319
+ expect(eps[0]).toMatchObject({ method: "POST", path: "/charge", handler: undefined });
320
+ });
321
+
322
+ it("sorts by path then method, and returns [] for a null view", () => {
323
+ const nodes = [
324
+ { id: "route:b", kind: "route", name: "GET /b" },
325
+ { id: "route:a", kind: "route", name: "GET /a" },
326
+ ];
327
+ const eps = buildEndpoints(modelView(nodes, []));
328
+ expect(eps.map((e) => e.path)).toEqual(["/a", "/b"]);
329
+ expect(buildEndpoints(null)).toEqual([]);
330
+ });
331
+ });