@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,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
|
+
});
|