@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
|
@@ -1,16 +1,41 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Home page mount test —
|
|
3
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
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:
|
|
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: "/
|
|
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("
|
|
79
|
-
|
|
80
|
-
const
|
|
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
|
-
|
|
85
|
-
expect(
|
|
86
|
-
expect(wrapper.find("[data-testid=home-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Plugins page tests — native 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
|
-
|
|
22
|
-
|
|
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
|
-
|
|
26
|
-
|
|
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
|
|
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
|
+
});
|