@nwire/studio 0.12.1 → 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
@@ -1,16 +1,41 @@
1
1
  /**
2
- * Home page mount test — verifies graceful empty rendering when:
3
- * - the manifest is empty
4
- * - the live telemetry stream errors immediately (no wire running)
2
+ * Home page mount test — the discovery dashboard over native data
3
+ * (`useDiscovery` + `useManifest` + `useTelemetry` + `useProject`).
5
4
  *
6
- * All three sections should still render with their empty-state messages.
5
+ * Covers: the discovered-projects grid renders from the catalog + live status;
6
+ * a card shows its health + composition stats; the active-project quick-stats
7
+ * strip derives counts from the manifest; the empty state shows when the
8
+ * catalog is empty.
7
9
  */
8
- import { describe, it, expect, beforeEach, vi } from "vitest";
10
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
9
11
  import { mount, flushPromises } from "@vue/test-utils";
10
12
  import { createRouter, createMemoryHistory } from "vue-router";
13
+ import { VueQueryPlugin } from "@tanstack/vue-query";
11
14
  import Home from "../Home.vue";
12
15
 
13
- // Stub EventSource so we don't try to open a real connection.
16
+ const CWD = "/repo/orders";
17
+
18
+ const manifest = {
19
+ version: 3,
20
+ model: {
21
+ nodes: [
22
+ { id: "action:orders.place", kind: "action", name: "orders.place" },
23
+ { id: "action:orders.cancel", kind: "action", name: "orders.cancel" },
24
+ { id: "event:orders.placed", kind: "event", name: "orders.placed" },
25
+ { id: "projection:orders.summary", kind: "projection", name: "orders.summary" },
26
+ { id: "app:orders", kind: "app", name: "orders" },
27
+ ],
28
+ edges: [],
29
+ },
30
+ };
31
+
32
+ const status = {
33
+ [CWD]: {
34
+ hasManifest: true,
35
+ processes: [{ id: "p1", pid: 123, port: 4000, status: "running", startedAt: "x" }],
36
+ },
37
+ };
38
+
14
39
  class FakeEventSource {
15
40
  onopen: (() => void) | null = null;
16
41
  onerror: (() => void) | null = null;
@@ -21,78 +46,108 @@ class FakeEventSource {
21
46
  close(): void {}
22
47
  }
23
48
 
49
+ function seedCatalog(empty = false): void {
50
+ if (empty) {
51
+ localStorage.clear();
52
+ return;
53
+ }
54
+ localStorage.setItem(
55
+ "nwire.projects",
56
+ JSON.stringify({
57
+ [CWD]: {
58
+ cwd: CWD,
59
+ name: "orders",
60
+ lastVisited: "2026-01-01T00:00:00.000Z",
61
+ composition: { apps: 1, plugins: 2, actions: 2, events: 1, resolvers: 0, workflows: 1 },
62
+ },
63
+ }),
64
+ );
65
+ localStorage.setItem("nwire.activeProject", CWD);
66
+ }
67
+
24
68
  beforeEach(() => {
25
- // jsdom/happy-dom doesn't ship EventSource — install our stub.
26
69
  (globalThis as unknown as { EventSource: typeof FakeEventSource }).EventSource = FakeEventSource;
27
-
28
- // Mock fetch — manifest call returns empty cache; telemetry/recent fails.
29
70
  globalThis.fetch = vi.fn((url: string | URL) => {
30
71
  const u = String(url);
72
+ if (u.includes("/__nwire/projects/status")) {
73
+ return Promise.resolve(new Response(JSON.stringify(status), { status: 200 }));
74
+ }
31
75
  if (u.includes("/__nwire/manifest.json")) {
32
- return Promise.resolve(
33
- new Response(
34
- JSON.stringify({
35
- generatedAt: new Date().toISOString(),
36
- apps: [],
37
- modules: [],
38
- actions: [],
39
- events: [],
40
- actors: [],
41
- projections: [],
42
- queries: [],
43
- resolvers: [],
44
- routes: [],
45
- workflows: [],
46
- externalCalls: [],
47
- inboundWebhooks: [],
48
- outboxes: [],
49
- inboxes: [],
50
- crons: [],
51
- hooks: [],
52
- plugins: [],
53
- graph: { events: [] },
54
- }),
55
- { status: 200 },
56
- ),
57
- );
76
+ return Promise.resolve(new Response(JSON.stringify(manifest), { status: 200 }));
58
77
  }
59
78
  if (u.includes("/_nwire/telemetry/recent")) {
60
- return Promise.resolve(new Response("", { status: 502 }));
79
+ return Promise.resolve(new Response("[]", { status: 200 }));
61
80
  }
62
81
  return Promise.resolve(new Response("", { status: 404 }));
63
82
  }) as typeof fetch;
64
83
  });
65
84
 
85
+ afterEach(() => {
86
+ localStorage.clear();
87
+ });
88
+
66
89
  function makeRouter() {
67
90
  return createRouter({
68
91
  history: createMemoryHistory(),
69
92
  routes: [
70
93
  { path: "/", name: "home", component: Home },
71
94
  { path: "/trace", name: "trace", component: { template: "<div/>" } },
72
- { path: "/hooks", name: "hooks", component: { template: "<div/>" } },
95
+ { path: "/streams", name: "streams", component: { template: "<div/>" } },
96
+ { path: "/projects", name: "projects", component: { template: "<div/>" } },
97
+ { path: "/projects/:slug/:page", name: "page", component: { template: "<div/>" } },
73
98
  ],
74
99
  });
75
100
  }
76
101
 
102
+ async function mountHome() {
103
+ const router = makeRouter();
104
+ const wrapper = mount(Home, { global: { plugins: [router, VueQueryPlugin] } });
105
+ await flushPromises();
106
+ await flushPromises();
107
+ return { wrapper, router };
108
+ }
109
+
77
110
  describe("Home", () => {
78
- it("mounts and renders all three sections with empty/no-live states", async () => {
79
- const router = makeRouter();
80
- const wrapper = mount(Home, { global: { plugins: [router] } });
81
- await flushPromises();
111
+ it("renders the discovered-projects grid from the catalog + live status", async () => {
112
+ seedCatalog();
113
+ const { wrapper } = await mountHome();
82
114
 
83
115
  expect(wrapper.find("[data-testid=home-page]").exists()).toBe(true);
84
- expect(wrapper.find("[data-testid=home-failures]").exists()).toBe(true);
85
- expect(wrapper.find("[data-testid=home-metrics]").exists()).toBe(true);
86
- expect(wrapper.find("[data-testid=home-composition]").exists()).toBe(true);
87
-
88
- // SSE errored → metrics panel shows the no-live-data degrade message.
89
- expect(wrapper.find("[data-testid=home-metrics-no-live]").exists()).toBe(true);
90
-
91
- // Failures: zero rows arrived; we show the "no failures" recovery line.
92
- // (recent fetch returned 502 so we are in the no-live-data branch.)
93
- expect(
94
- wrapper.find("[data-testid=home-failures-no-live]").exists() ||
95
- wrapper.find("[data-testid=home-failures-empty]").exists(),
96
- ).toBe(true);
116
+ const cards = wrapper.findAll("[data-testid=home-project-card]");
117
+ expect(cards).toHaveLength(1);
118
+ expect(wrapper.find("[data-testid=home-project-name]").text()).toBe("orders");
119
+ // Running health (a live process is registered).
120
+ expect(wrapper.find("[data-testid=home-project-health]").text()).toContain("Running");
121
+ // Composition stats from the snapshot are present (not pending).
122
+ expect(wrapper.find("[data-testid=home-project-stats]").exists()).toBe(true);
123
+ });
124
+
125
+ it("derives the active-project quick stats from the manifest", async () => {
126
+ seedCatalog();
127
+ const { wrapper } = await mountHome();
128
+
129
+ const strip = wrapper.find("[data-testid=home-quickstats]");
130
+ expect(strip.exists()).toBe(true);
131
+ const values = strip.findAll("[data-testid=kpi-value]").map((n) => n.text());
132
+ // Nodes=5, Actions=2, Events=1, Projections=1, Errors=0 (telemetry empty).
133
+ expect(values).toEqual(["5", "2", "1", "1", "0"]);
134
+ });
135
+
136
+ it("jumps into a project's map, switching the active project without reload", async () => {
137
+ seedCatalog();
138
+ const { wrapper, router } = await mountHome();
139
+
140
+ await wrapper.find("[data-testid=home-jump-map]").trigger("click");
141
+ await flushPromises();
142
+
143
+ expect(router.currentRoute.value.fullPath).toBe("/projects/orders/map");
144
+ });
145
+
146
+ it("shows the empty state when no projects are discovered", async () => {
147
+ seedCatalog(true);
148
+ const { wrapper } = await mountHome();
149
+
150
+ expect(wrapper.find("[data-testid=home-projects-empty]").exists()).toBe(true);
151
+ expect(wrapper.find("[data-testid=home-project-card]").exists()).toBe(false);
97
152
  });
98
153
  });
@@ -8,6 +8,7 @@
8
8
  import { describe, it, expect, beforeEach, vi } from "vitest";
9
9
  import { mount, flushPromises } from "@vue/test-utils";
10
10
  import { createRouter, createMemoryHistory } from "vue-router";
11
+ import { VueQueryPlugin } from "@tanstack/vue-query";
11
12
  import Hooks from "../Hooks.vue";
12
13
 
13
14
  class FakeEventSource {
@@ -58,8 +59,7 @@ beforeEach(() => {
58
59
  outboxes: [],
59
60
  inboxes: [],
60
61
  crons: [],
61
- hooks: [pluginBootHook, otherHook],
62
- plugins: [],
62
+ topology: { hooks: [pluginBootHook, otherHook], plugins: [] },
63
63
  graph: { events: [] },
64
64
  }),
65
65
  { status: 200 },
@@ -84,7 +84,7 @@ describe("Hooks deep-links", () => {
84
84
  it("preselects a hook by name and shows the 'Registered by' plugin link", async () => {
85
85
  const router = makeRouter();
86
86
  await router.push("/hooks?name=plugin.boot:auth");
87
- const wrapper = mount(Hooks, { global: { plugins: [router] } });
87
+ const wrapper = mount(Hooks, { global: { plugins: [router, VueQueryPlugin] } });
88
88
  await flushPromises();
89
89
  await flushPromises();
90
90
 
@@ -95,7 +95,7 @@ describe("Hooks deep-links", () => {
95
95
  it("does not render the plugin link for non-plugin hooks", async () => {
96
96
  const router = makeRouter();
97
97
  await router.push("/hooks?name=action.before:submitAssignment");
98
- const wrapper = mount(Hooks, { global: { plugins: [router] } });
98
+ const wrapper = mount(Hooks, { global: { plugins: [router, VueQueryPlugin] } });
99
99
  await flushPromises();
100
100
  await flushPromises();
101
101
 
@@ -106,7 +106,7 @@ describe("Hooks deep-links", () => {
106
106
  it("clicking the plugin link navigates to /plugins?name=<n>", async () => {
107
107
  const router = makeRouter();
108
108
  await router.push("/hooks?name=plugin.boot:auth");
109
- const wrapper = mount(Hooks, { global: { plugins: [router] } });
109
+ const wrapper = mount(Hooks, { global: { plugins: [router, VueQueryPlugin] } });
110
110
  await flushPromises();
111
111
  await flushPromises();
112
112
 
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Inspect page — verifies the unified per-kind browser over the native deep
3
+ * manifest: the kind rail lists present kinds with counts, the node list
4
+ * filters, and selecting a node renders its detail + relationships.
5
+ */
6
+ import { describe, it, expect, beforeEach, vi } from "vitest";
7
+ import { mount, flushPromises } from "@vue/test-utils";
8
+ import { createRouter, createMemoryHistory } from "vue-router";
9
+ import { VueQueryPlugin } from "@tanstack/vue-query";
10
+ import Inspect from "../Inspect.vue";
11
+
12
+ const manifest = {
13
+ version: 3,
14
+ actions: [
15
+ {
16
+ name: "orders.place",
17
+ app: "orders",
18
+ public: true,
19
+ description: "Place an order",
20
+ inputSchema: { type: "object" },
21
+ emits: ["orders.placed"],
22
+ source: { file: "orders/place.ts", line: 3 },
23
+ },
24
+ ],
25
+ events: [{ name: "orders.placed", app: "orders" }],
26
+ model: {
27
+ nodes: [
28
+ {
29
+ id: "action:orders.place",
30
+ kind: "action",
31
+ name: "orders.place",
32
+ intent: { description: "Place an order", public: true },
33
+ data: { emits: ["orders.placed"] },
34
+ },
35
+ { id: "event:orders.placed", kind: "event", name: "orders.placed" },
36
+ ],
37
+ edges: [{ from: "action:orders.place", to: "event:orders.placed", type: "emits" }],
38
+ },
39
+ };
40
+
41
+ beforeEach(() => {
42
+ globalThis.fetch = vi.fn((url: string | URL) => {
43
+ if (String(url).includes("/__nwire/manifest.json")) {
44
+ return Promise.resolve(new Response(JSON.stringify(manifest), { status: 200 }));
45
+ }
46
+ return Promise.resolve(new Response("", { status: 404 }));
47
+ }) as typeof fetch;
48
+ });
49
+
50
+ function makeRouter() {
51
+ return createRouter({
52
+ history: createMemoryHistory(),
53
+ routes: [
54
+ { path: "/inspect", name: "inspect", component: Inspect },
55
+ { path: "/dispatch", name: "dispatch", component: { template: "<div/>" } },
56
+ ],
57
+ });
58
+ }
59
+
60
+ async function mountInspect(query = "") {
61
+ const router = makeRouter();
62
+ await router.push(`/inspect${query}`);
63
+ const wrapper = mount(Inspect, { global: { plugins: [router, VueQueryPlugin] } });
64
+ await flushPromises();
65
+ await flushPromises();
66
+ return { wrapper, router };
67
+ }
68
+
69
+ describe("Inspect page", () => {
70
+ it("lists present kinds with counts in the rail", async () => {
71
+ const { wrapper } = await mountInspect();
72
+ const rail = wrapper.get('[data-testid="kind-rail"]');
73
+ expect(rail.text()).toContain("Actions");
74
+ expect(rail.text()).toContain("Events");
75
+ expect(wrapper.find('[data-testid="kind-action"]').exists()).toBe(true);
76
+ });
77
+
78
+ it("auto-selects the first node and renders its detail", async () => {
79
+ const { wrapper } = await mountInspect();
80
+ expect(wrapper.get('[data-testid="inspect-detail"]').text()).toContain("orders.place");
81
+ // dispatchable kind → the Dispatch link shows
82
+ expect(wrapper.find('[data-testid="dispatch-link"]').exists()).toBe(true);
83
+ });
84
+
85
+ it("shows relationships for the selected node", async () => {
86
+ const { wrapper } = await mountInspect();
87
+ const rel = wrapper.find('[data-testid="relationships"]');
88
+ expect(rel.exists()).toBe(true);
89
+ expect(rel.text()).toContain("orders.placed");
90
+ });
91
+
92
+ it("switches kinds when a rail entry is clicked", async () => {
93
+ const { wrapper } = await mountInspect();
94
+ await wrapper.get('[data-testid="kind-event"]').trigger("click");
95
+ await flushPromises();
96
+ expect(wrapper.get('[data-testid="inspect-detail"]').text()).toContain("orders.placed");
97
+ });
98
+
99
+ it("honors ?kind= + ?id= deep-links", async () => {
100
+ const { wrapper } = await mountInspect("?kind=event&id=event:orders.placed");
101
+ expect(wrapper.get('[data-testid="inspect-detail"]').text()).toContain("orders.placed");
102
+ });
103
+
104
+ it("filters the node list", async () => {
105
+ const { wrapper } = await mountInspect();
106
+ const input = wrapper.get('[data-testid="filter-input"]');
107
+ await input.setValue("zzz");
108
+ await flushPromises();
109
+ expect(wrapper.findAll('[data-testid="inspect-row"]')).toHaveLength(0);
110
+ });
111
+ });
@@ -1,11 +1,14 @@
1
1
  /**
2
- * Plugins page mount test verifies the empty-state renders when the
3
- * manifest has zero plugins, and the SSE stream is stubbed so we don't try
4
- * to open a real connection in jsdom.
2
+ * Plugins page testsnative topology. Verifies:
3
+ * 1. The empty-state renders when the topology has zero plugins.
4
+ * 2. A populated topology lists plugins, and /plugins?name=<n> preselects one,
5
+ * surfacing its lifecycle hooks with a cross-link to /hooks?name=<hookName>.
6
+ * The SSE stream is stubbed (FakeEventSource) so jsdom opens no real connection.
5
7
  */
6
8
  import { describe, it, expect, beforeEach, vi } from "vitest";
7
9
  import { mount, flushPromises } from "@vue/test-utils";
8
10
  import { createRouter, createMemoryHistory } from "vue-router";
11
+ import { VueQueryPlugin } from "@tanstack/vue-query";
9
12
  import Plugins from "../Plugins.vue";
10
13
 
11
14
  class FakeEventSource {
@@ -18,41 +21,54 @@ class FakeEventSource {
18
21
  close(): void {}
19
22
  }
20
23
 
21
- beforeEach(() => {
22
- (globalThis as unknown as { EventSource: typeof FakeEventSource }).EventSource = FakeEventSource;
24
+ const EMPTY_TOPOLOGY = { hooks: [], plugins: [] };
25
+ const POPULATED_TOPOLOGY = {
26
+ plugins: [{ name: "auth" }, { name: "storage" }],
27
+ hooks: [
28
+ { name: "plugin.boot:auth", chain: 2, listeners: 1 },
29
+ { name: "plugin.shutdown:auth", chain: 1, listeners: 0 },
30
+ { name: "plugin.boot:storage", chain: 1, listeners: 0 },
31
+ ],
32
+ capabilities: [],
33
+ ctxByKind: {},
34
+ };
35
+
36
+ function manifestWith(topology: object) {
37
+ return {
38
+ version: 3,
39
+ generatedAt: new Date().toISOString(),
40
+ apps: [],
41
+ actions: [],
42
+ events: [],
43
+ actors: [],
44
+ projections: [],
45
+ queries: [],
46
+ resolvers: [],
47
+ routes: [],
48
+ workflows: [],
49
+ externalCalls: [],
50
+ inboundWebhooks: [],
51
+ outboxes: [],
52
+ inboxes: [],
53
+ crons: [],
54
+ graph: { events: [] },
55
+ topology,
56
+ model: { nodes: [], edges: [] },
57
+ };
58
+ }
23
59
 
60
+ function stubManifest(topology: object): void {
61
+ (globalThis as unknown as { EventSource: typeof FakeEventSource }).EventSource = FakeEventSource;
24
62
  globalThis.fetch = vi.fn((url: string | URL) => {
25
- const u = String(url);
26
- if (u.includes("/__nwire/manifest.json")) {
27
- return Promise.resolve(
28
- new Response(
29
- JSON.stringify({
30
- generatedAt: new Date().toISOString(),
31
- apps: [],
32
- modules: [],
33
- actions: [],
34
- events: [],
35
- actors: [],
36
- projections: [],
37
- queries: [],
38
- resolvers: [],
39
- routes: [],
40
- workflows: [],
41
- externalCalls: [],
42
- inboundWebhooks: [],
43
- outboxes: [],
44
- inboxes: [],
45
- crons: [],
46
- hooks: [],
47
- plugins: [],
48
- graph: { events: [] },
49
- }),
50
- { status: 200 },
51
- ),
52
- );
63
+ if (String(url).includes("/__nwire/manifest.json")) {
64
+ return Promise.resolve(new Response(JSON.stringify(manifestWith(topology)), { status: 200 }));
53
65
  }
54
66
  return Promise.resolve(new Response("", { status: 404 }));
55
67
  }) as typeof fetch;
68
+ }
69
+
70
+ beforeEach(() => {
71
+ stubManifest(EMPTY_TOPOLOGY);
56
72
  });
57
73
 
58
74
  function makeRouter() {
@@ -66,9 +82,10 @@ function makeRouter() {
66
82
  }
67
83
 
68
84
  describe("Plugins", () => {
69
- it("renders the empty state when cache.plugins is empty", async () => {
85
+ it("renders the empty state when the topology has no plugins", async () => {
70
86
  const router = makeRouter();
71
- const wrapper = mount(Plugins, { global: { plugins: [router] } });
87
+ const wrapper = mount(Plugins, { global: { plugins: [router, VueQueryPlugin] } });
88
+ await flushPromises();
72
89
  await flushPromises();
73
90
 
74
91
  expect(wrapper.find("[data-testid=plugins-page]").exists()).toBe(true);
@@ -77,4 +94,37 @@ describe("Plugins", () => {
77
94
  expect(wrapper.find("[data-testid=plugins-list]").exists()).toBe(false);
78
95
  expect(wrapper.find("[data-testid=plugins-detail]").exists()).toBe(false);
79
96
  });
97
+
98
+ it("lists plugins from native topology", async () => {
99
+ stubManifest(POPULATED_TOPOLOGY);
100
+ const router = makeRouter();
101
+ const wrapper = mount(Plugins, { global: { plugins: [router, VueQueryPlugin] } });
102
+ await flushPromises();
103
+ await flushPromises();
104
+
105
+ expect(wrapper.find("[data-testid=plugins-empty]").exists()).toBe(false);
106
+ expect(wrapper.find("[data-testid=plugins-list]").exists()).toBe(true);
107
+ expect(wrapper.text()).toContain("auth");
108
+ expect(wrapper.text()).toContain("storage");
109
+ });
110
+
111
+ it("preselects via /plugins?name=<n> and shows lifecycle hooks linking to /hooks", async () => {
112
+ stubManifest(POPULATED_TOPOLOGY);
113
+ const router = makeRouter();
114
+ await router.push("/plugins?name=auth");
115
+ const wrapper = mount(Plugins, { global: { plugins: [router, VueQueryPlugin] } });
116
+ await flushPromises();
117
+ await flushPromises();
118
+
119
+ const detail = wrapper.find("[data-testid=plugins-detail]");
120
+ expect(detail.exists()).toBe(true);
121
+ // The two auth lifecycle hooks render as cross-links to /hooks.
122
+ expect(wrapper.find("[data-testid='hook-link-plugin.boot:auth']").exists()).toBe(true);
123
+ expect(wrapper.find("[data-testid='hook-link-plugin.shutdown:auth']").exists()).toBe(true);
124
+
125
+ await wrapper.find("[data-testid='hook-link-plugin.boot:auth']").trigger("click");
126
+ await flushPromises();
127
+ expect(router.currentRoute.value.path).toBe("/hooks");
128
+ expect(router.currentRoute.value.query.name).toBe("plugin.boot:auth");
129
+ });
80
130
  });
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Flow / Trace page — verifies the telemetry-driven waterfall: backfilled
3
+ * records group into a trace, render as a depth-ordered waterfall with a
4
+ * failure badge, and selecting a span drives the metadata inspector.
5
+ */
6
+ import { describe, it, expect, beforeEach, vi } from "vitest";
7
+ import { mount, flushPromises } from "@vue/test-utils";
8
+ import { createRouter, createMemoryHistory } from "vue-router";
9
+ import Trace from "../Trace.vue";
10
+
11
+ const env = (messageId: string, causationId: string) => ({
12
+ messageId,
13
+ causationId,
14
+ correlationId: "c1",
15
+ });
16
+
17
+ const backfill = [
18
+ { kind: "action.dispatched", name: "orders.place", durationMs: 10, envelope: env("m1", "m1") },
19
+ { kind: "query.executed", name: "orders.lookup", durationMs: 90, envelope: env("m2", "m1") },
20
+ {
21
+ kind: "action.failed",
22
+ name: "orders.charge",
23
+ error: { name: "E", message: "declined" },
24
+ envelope: env("m3", "m1"),
25
+ },
26
+ ];
27
+
28
+ // What the SSE stream replays on open. The server backfills the current run
29
+ // over the live stream (no separate `/recent` fetch), so the fake emits these
30
+ // as `message` events once the page has wired its `onmessage`.
31
+ let streamRecords: unknown[] = [];
32
+
33
+ // A fake live stream — on connect it replays `streamRecords` as message events,
34
+ // mirroring `/__nwire/telemetry/live`'s open-time backfill.
35
+ class FakeEventSource {
36
+ onopen: (() => void) | null = null;
37
+ onerror: (() => void) | null = null;
38
+ onmessage: ((m: { data: string }) => void) | null = null;
39
+ listeners: Record<string, Array<(ev: { data: string }) => void>> = {};
40
+ constructor(public readonly url: string) {
41
+ queueMicrotask(() => {
42
+ this.onopen?.();
43
+ for (const rec of streamRecords) this.onmessage?.({ data: JSON.stringify(rec) });
44
+ });
45
+ }
46
+ addEventListener(type: string, fn: (ev: { data: string }) => void): void {
47
+ (this.listeners[type] ??= []).push(fn);
48
+ }
49
+ removeEventListener(): void {}
50
+ close(): void {}
51
+ }
52
+
53
+ beforeEach(() => {
54
+ streamRecords = backfill;
55
+ (globalThis as unknown as { EventSource: typeof FakeEventSource }).EventSource = FakeEventSource;
56
+ // Live mode backfills over SSE; the only fetch is the run-list for the picker.
57
+ globalThis.fetch = vi.fn((url: string | URL) => {
58
+ const u = String(url);
59
+ if (u.includes("/__nwire/telemetry/runs")) {
60
+ return Promise.resolve(new Response(JSON.stringify({ runs: [] }), { status: 200 }));
61
+ }
62
+ return Promise.resolve(new Response("", { status: 404 }));
63
+ }) as typeof fetch;
64
+ });
65
+
66
+ function makeRouter() {
67
+ return createRouter({
68
+ history: createMemoryHistory(),
69
+ routes: [
70
+ { path: "/trace", name: "trace", component: Trace },
71
+ { path: "/", name: "home", component: { template: "<div/>" } },
72
+ ],
73
+ });
74
+ }
75
+
76
+ describe("Flow / Trace", () => {
77
+ it("renders the trace picker with a failure count and a waterfall row per span", async () => {
78
+ const router = makeRouter();
79
+ router.push("/trace?correlationId=c1");
80
+ await router.isReady();
81
+
82
+ const wrapper = mount(Trace, { global: { plugins: [router] } });
83
+ await flushPromises();
84
+
85
+ expect(wrapper.find('[data-testid="trace-row"]').exists()).toBe(true);
86
+ expect(wrapper.get('[data-testid="trace-failures"]').text()).toContain("1");
87
+ expect(wrapper.findAll('[data-testid="waterfall-row"]')).toHaveLength(3);
88
+ // The failed span gets the red treatment.
89
+ expect(wrapper.find('[data-failed="true"]').exists()).toBe(true);
90
+ });
91
+
92
+ it("drives the metadata inspector from the selected span", async () => {
93
+ const router = makeRouter();
94
+ router.push("/trace?correlationId=c1");
95
+ await router.isReady();
96
+
97
+ const wrapper = mount(Trace, { global: { plugins: [router] } });
98
+ await flushPromises();
99
+
100
+ await wrapper.findAll('[data-testid="waterfall-row"]')[1]!.trigger("click");
101
+ const inspector = wrapper.get('[data-testid="metadata-inspector"]');
102
+ expect(inspector.text()).toContain("orders.lookup");
103
+ expect(inspector.text()).toContain("envelope.messageId");
104
+ });
105
+
106
+ it("shows the empty state when no trace is selected", async () => {
107
+ const router = makeRouter();
108
+ router.push("/trace");
109
+ await router.isReady();
110
+ streamRecords = [];
111
+
112
+ const wrapper = mount(Trace, { global: { plugins: [router] } });
113
+ await flushPromises();
114
+
115
+ expect(wrapper.find('[data-testid="empty-state"]').exists()).toBe(true);
116
+ });
117
+ });