@nwire/studio 0.9.1

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 (107) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +72 -0
  3. package/components.json +19 -0
  4. package/index.html +12 -0
  5. package/package.json +66 -0
  6. package/src/App.vue +305 -0
  7. package/src/components/EmptyState.stories.ts +53 -0
  8. package/src/components/EmptyState.vue +28 -0
  9. package/src/components/ErrorBoundary.vue +60 -0
  10. package/src/components/FilterInput.stories.ts +32 -0
  11. package/src/components/FilterInput.vue +33 -0
  12. package/src/components/JsonView.stories.ts +38 -0
  13. package/src/components/JsonView.vue +34 -0
  14. package/src/components/KindBadge.stories.ts +72 -0
  15. package/src/components/KindBadge.vue +59 -0
  16. package/src/components/ListRow.stories.ts +56 -0
  17. package/src/components/ListRow.vue +48 -0
  18. package/src/components/MasterDetail.stories.ts +74 -0
  19. package/src/components/MasterDetail.vue +35 -0
  20. package/src/components/MonacoViewer.vue +143 -0
  21. package/src/components/PageHeader.stories.ts +45 -0
  22. package/src/components/PageHeader.vue +46 -0
  23. package/src/components/SchemaNode.vue +208 -0
  24. package/src/components/SchemaTree.vue +65 -0
  25. package/src/components/SourceDrawer.vue +136 -0
  26. package/src/components/SourcePill.vue +103 -0
  27. package/src/components/__tests__/EmptyState.test.ts +28 -0
  28. package/src/components/__tests__/ErrorBoundary.test.ts +52 -0
  29. package/src/components/__tests__/FilterInput.test.ts +38 -0
  30. package/src/components/__tests__/JsonView.test.ts +33 -0
  31. package/src/components/__tests__/KindBadge.test.ts +39 -0
  32. package/src/components/__tests__/ListRow.test.ts +39 -0
  33. package/src/components/__tests__/MasterDetail.test.ts +40 -0
  34. package/src/components/__tests__/PageHeader.test.ts +42 -0
  35. package/src/components/index.ts +17 -0
  36. package/src/components/ui/badge/Badge.vue +17 -0
  37. package/src/components/ui/badge/index.ts +25 -0
  38. package/src/components/ui/button/Button.vue +28 -0
  39. package/src/components/ui/button/index.ts +34 -0
  40. package/src/components/ui/card/Card.vue +14 -0
  41. package/src/components/ui/card/CardContent.vue +14 -0
  42. package/src/components/ui/card/CardDescription.vue +14 -0
  43. package/src/components/ui/card/CardFooter.vue +14 -0
  44. package/src/components/ui/card/CardHeader.vue +14 -0
  45. package/src/components/ui/card/CardTitle.vue +14 -0
  46. package/src/components/ui/card/index.ts +6 -0
  47. package/src/components/ui/dialog/Dialog.vue +15 -0
  48. package/src/components/ui/dialog/DialogClose.vue +12 -0
  49. package/src/components/ui/dialog/DialogContent.vue +47 -0
  50. package/src/components/ui/dialog/DialogDescription.vue +22 -0
  51. package/src/components/ui/dialog/DialogFooter.vue +12 -0
  52. package/src/components/ui/dialog/DialogHeader.vue +14 -0
  53. package/src/components/ui/dialog/DialogScrollContent.vue +60 -0
  54. package/src/components/ui/dialog/DialogTitle.vue +22 -0
  55. package/src/components/ui/dialog/DialogTrigger.vue +12 -0
  56. package/src/components/ui/dialog/index.ts +9 -0
  57. package/src/components/ui/input/Input.vue +32 -0
  58. package/src/components/ui/input/index.ts +1 -0
  59. package/src/components/ui/scroll-area/ScrollArea.vue +22 -0
  60. package/src/components/ui/scroll-area/ScrollBar.vue +32 -0
  61. package/src/components/ui/scroll-area/index.ts +2 -0
  62. package/src/components/ui/separator/Separator.vue +27 -0
  63. package/src/components/ui/separator/index.ts +1 -0
  64. package/src/components/ui/skeleton/Skeleton.vue +14 -0
  65. package/src/components/ui/skeleton/index.ts +1 -0
  66. package/src/components/ui/tabs/Tabs.vue +15 -0
  67. package/src/components/ui/tabs/TabsContent.vue +25 -0
  68. package/src/components/ui/tabs/TabsList.vue +25 -0
  69. package/src/components/ui/tabs/TabsTrigger.vue +29 -0
  70. package/src/components/ui/tabs/index.ts +4 -0
  71. package/src/components/ui/tooltip/Tooltip.vue +15 -0
  72. package/src/components/ui/tooltip/TooltipContent.vue +40 -0
  73. package/src/components/ui/tooltip/TooltipProvider.vue +12 -0
  74. package/src/components/ui/tooltip/TooltipTrigger.vue +12 -0
  75. package/src/components/ui/tooltip/index.ts +4 -0
  76. package/src/composables/useCopy.ts +31 -0
  77. package/src/lib/__tests__/normalize-cache.test.ts +104 -0
  78. package/src/lib/cache.ts +334 -0
  79. package/src/lib/normalize-cache.ts +92 -0
  80. package/src/lib/project-catalog.ts +125 -0
  81. package/src/lib/utils.ts +6 -0
  82. package/src/main.ts +112 -0
  83. package/src/pages/Actions.vue +180 -0
  84. package/src/pages/Commands.vue +262 -0
  85. package/src/pages/Dispatch.vue +431 -0
  86. package/src/pages/Events.vue +166 -0
  87. package/src/pages/Home.stories.ts +47 -0
  88. package/src/pages/Home.vue +485 -0
  89. package/src/pages/Hooks.vue +297 -0
  90. package/src/pages/Live.vue +249 -0
  91. package/src/pages/Modules.vue +174 -0
  92. package/src/pages/Overview.vue +159 -0
  93. package/src/pages/Plugins.stories.ts +44 -0
  94. package/src/pages/Plugins.vue +403 -0
  95. package/src/pages/Projects.vue +272 -0
  96. package/src/pages/Run.vue +479 -0
  97. package/src/pages/Topology.vue +164 -0
  98. package/src/pages/Trace.vue +511 -0
  99. package/src/pages/TraceNode.vue +166 -0
  100. package/src/pages/Workflows.vue +191 -0
  101. package/src/pages/__tests__/Actions.test.ts +98 -0
  102. package/src/pages/__tests__/Home.test.ts +98 -0
  103. package/src/pages/__tests__/Hooks.test.ts +119 -0
  104. package/src/pages/__tests__/Plugins.test.ts +80 -0
  105. package/src/style.css +40 -0
  106. package/tsconfig.json +20 -0
  107. package/vite.config.ts +892 -0
@@ -0,0 +1,191 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Workflows — the unified event-driven side-effect primitive (reactions,
4
+ * translators, sagas; one shape). Each row in `cache.workflows` shows
5
+ * which events it subscribes to and which actions it dispatches.
6
+ */
7
+ import { computed, onMounted, ref, watch } from "vue";
8
+ import { useRoute, useRouter } from "vue-router";
9
+ import { useCache } from "@/lib/cache";
10
+ import { GitBranch, Zap, ArrowRight, Globe, Lock } from "lucide-vue-next";
11
+ import {
12
+ PageHeader,
13
+ FilterInput,
14
+ KindBadge,
15
+ EmptyState,
16
+ MasterDetail,
17
+ SourcePill,
18
+ SourceDrawer,
19
+ ListRow,
20
+ } from "@/components";
21
+
22
+ const route = useRoute();
23
+ const router = useRouter();
24
+ const { cache } = useCache();
25
+ const filter = ref("");
26
+ const selected = ref<string | null>(null);
27
+ const sourcePreview = ref<{ file: string; line: number; column?: number } | null>(null);
28
+
29
+ function applyQueryPreselect(): void {
30
+ const name = route.query.name;
31
+ if (typeof name !== "string" || name.length === 0) return;
32
+ // Match by workflow name across (app, module). First match wins.
33
+ const found = cache.value?.workflows.find((w) => w.name === name);
34
+ if (found) selected.value = `${found.app}::${found.module}::${found.name}`;
35
+ }
36
+
37
+ onMounted(applyQueryPreselect);
38
+ watch(() => route.query.name, applyQueryPreselect);
39
+ watch(() => cache.value, applyQueryPreselect);
40
+
41
+ const filtered = computed(() => {
42
+ if (!cache.value) return [];
43
+ const q = filter.value.toLowerCase();
44
+ return cache.value.workflows.filter(
45
+ (w) =>
46
+ !q ||
47
+ w.name.toLowerCase().includes(q) ||
48
+ w.module.toLowerCase().includes(q) ||
49
+ w.app.toLowerCase().includes(q) ||
50
+ w.subscribesTo.some((e) => e.toLowerCase().includes(q)) ||
51
+ w.dispatches.some((a) => a.toLowerCase().includes(q)) ||
52
+ (w.description ?? "").toLowerCase().includes(q),
53
+ );
54
+ });
55
+
56
+ const key = (w: { app: string; module: string; name: string }) =>
57
+ `${w.app}::${w.module}::${w.name}`;
58
+ const detail = computed(() => filtered.value.find((w) => key(w) === selected.value) ?? null);
59
+ </script>
60
+
61
+ <template>
62
+ <div v-if="cache" class="h-full flex flex-col" data-testid="workflows-page">
63
+ <div class="p-6 pb-3 border-b border-zinc-800">
64
+ <PageHeader
65
+ title="Workflows"
66
+ subtitle="Event-driven side effects — reactions, translators, sagas. One primitive."
67
+ :icon="GitBranch"
68
+ icon-color="text-violet-400"
69
+ :count="filtered.length"
70
+ :total="cache.workflows.length"
71
+ />
72
+ </div>
73
+
74
+ <EmptyState
75
+ v-if="cache.workflows.length === 0"
76
+ title="No workflows in cache"
77
+ hint="Workflows are declared via defineWorkflow(name, ({ on, send }) => ...). Run `nwire cache` after adding one."
78
+ :icon="GitBranch"
79
+ />
80
+
81
+ <MasterDetail v-else class="flex-1">
82
+ <template #listHeader>
83
+ <FilterInput v-model="filter" placeholder="filter by name, module, event, action…" />
84
+ </template>
85
+
86
+ <template #list>
87
+ <ListRow
88
+ v-for="w in filtered"
89
+ :key="key(w)"
90
+ :selected="selected === key(w)"
91
+ @click="selected = key(w)"
92
+ >
93
+ <template #title>
94
+ <GitBranch class="w-3 h-3 text-violet-400 shrink-0" />
95
+ <span class="font-mono text-sm truncate">{{ w.name }}</span>
96
+ </template>
97
+ <template #meta>
98
+ <component
99
+ :is="w.public ? Globe : Lock"
100
+ class="w-3 h-3"
101
+ :class="w.public ? 'text-emerald-400' : 'text-zinc-500'"
102
+ :title="w.public ? 'public — exposed across modules' : 'private — module-internal'"
103
+ />
104
+ <span class="text-[10px] text-zinc-500">{{ w.app }} · {{ w.module }}</span>
105
+ </template>
106
+ <template v-if="w.description || w.subscribesTo.length > 0" #description>
107
+ <div v-if="w.description">{{ w.description }}</div>
108
+ <div v-if="w.subscribesTo.length > 0" class="text-zinc-600 mt-0.5">
109
+ <span class="text-zinc-500">on</span>
110
+ {{ w.subscribesTo.slice(0, 2).join(", ") }}
111
+ <span v-if="w.subscribesTo.length > 2">+{{ w.subscribesTo.length - 2 }}</span>
112
+ </div>
113
+ </template>
114
+ </ListRow>
115
+ </template>
116
+
117
+ <template #empty>Select a workflow to view its event subscriptions and dispatches.</template>
118
+
119
+ <template v-if="detail" #detail>
120
+ <div class="p-6 space-y-5" data-testid="workflow-detail">
121
+ <div>
122
+ <div class="text-[10px] uppercase tracking-wide text-zinc-500">
123
+ {{ detail.app }} · {{ detail.module }}
124
+ </div>
125
+ <h2 class="font-mono text-xl mt-1">{{ detail.name }}</h2>
126
+ <p v-if="detail.description" class="text-sm text-zinc-400 mt-2 max-w-2xl">
127
+ {{ detail.description }}
128
+ </p>
129
+ </div>
130
+
131
+ <div class="flex flex-wrap gap-2">
132
+ <KindBadge :variant="detail.public ? 'public' : 'private'">
133
+ {{ detail.public ? "public" : "private" }}
134
+ </KindBadge>
135
+ </div>
136
+
137
+ <div class="space-y-3">
138
+ <h3 class="text-xs uppercase tracking-wide text-zinc-500">Listens to</h3>
139
+ <div v-if="detail.subscribesTo.length === 0" class="text-xs text-zinc-600">
140
+ No event subscriptions declared.
141
+ </div>
142
+ <div v-else class="space-y-1">
143
+ <button
144
+ v-for="ev in detail.subscribesTo"
145
+ :key="ev"
146
+ type="button"
147
+ class="flex items-center gap-2 font-mono text-sm text-left hover:text-cyan-300"
148
+ :data-testid="`event-link-${ev}`"
149
+ @click="router.push({ path: '/events', query: { name: ev } })"
150
+ >
151
+ <ArrowRight class="w-3.5 h-3.5 text-cyan-400" />
152
+ <span class="underline-offset-2 hover:underline">{{ ev }}</span>
153
+ </button>
154
+ </div>
155
+ </div>
156
+
157
+ <div class="space-y-3">
158
+ <h3 class="text-xs uppercase tracking-wide text-zinc-500">Dispatches</h3>
159
+ <div v-if="detail.dispatches.length === 0" class="text-xs text-zinc-600">
160
+ Pure observer — no action dispatches.
161
+ </div>
162
+ <div v-else class="space-y-1">
163
+ <button
164
+ v-for="action in detail.dispatches"
165
+ :key="action"
166
+ type="button"
167
+ class="flex items-center gap-2 font-mono text-sm text-left hover:text-amber-300"
168
+ :data-testid="`action-link-${action}`"
169
+ @click="router.push({ path: '/actions', query: { name: action } })"
170
+ >
171
+ <Zap class="w-3.5 h-3.5 text-amber-400" />
172
+ <span class="underline-offset-2 hover:underline">{{ action }}</span>
173
+ </button>
174
+ </div>
175
+ </div>
176
+
177
+ <div v-if="detail.source" class="pt-2">
178
+ <button
179
+ type="button"
180
+ class="inline-flex items-center"
181
+ @click="sourcePreview = detail.source!"
182
+ >
183
+ <SourcePill :source="detail.source" />
184
+ </button>
185
+ </div>
186
+ </div>
187
+ </template>
188
+ </MasterDetail>
189
+ <SourceDrawer :source="sourcePreview" @close="sourcePreview = null" />
190
+ </div>
191
+ </template>
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Actions page deep-link test — verifies that `/actions?name=<actionName>`
3
+ * preselects the matching action and that the cross-link buttons to
4
+ * /hooks and /trace are rendered with the right router destinations.
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 Actions from "../Actions.vue";
10
+
11
+ const fakeAction = {
12
+ name: "submitAssignment",
13
+ description: "Student submits an assignment.",
14
+ module: "submissions",
15
+ app: "learnflow",
16
+ schema: { type: "object" },
17
+ hasInlineHandler: true,
18
+ emits: ["AssignmentSubmitted"],
19
+ public: true,
20
+ };
21
+
22
+ beforeEach(() => {
23
+ globalThis.fetch = vi.fn((url: string | URL) => {
24
+ const u = String(url);
25
+ if (u.includes("/__nwire/manifest.json")) {
26
+ return Promise.resolve(
27
+ new Response(
28
+ JSON.stringify({
29
+ generatedAt: new Date().toISOString(),
30
+ apps: [],
31
+ modules: [],
32
+ actions: [fakeAction],
33
+ events: [],
34
+ actors: [],
35
+ projections: [],
36
+ queries: [],
37
+ resolvers: [],
38
+ routes: [],
39
+ workflows: [],
40
+ externalCalls: [],
41
+ inboundWebhooks: [],
42
+ outboxes: [],
43
+ inboxes: [],
44
+ crons: [],
45
+ hooks: [],
46
+ plugins: [],
47
+ graph: { events: [] },
48
+ }),
49
+ { status: 200 },
50
+ ),
51
+ );
52
+ }
53
+ return Promise.resolve(new Response("", { status: 404 }));
54
+ }) as typeof fetch;
55
+ });
56
+
57
+ function makeRouter() {
58
+ return createRouter({
59
+ history: createMemoryHistory(),
60
+ routes: [
61
+ { path: "/actions", name: "actions", component: Actions },
62
+ { path: "/hooks", name: "hooks", component: { template: "<div/>" } },
63
+ { path: "/trace", name: "trace", component: { template: "<div/>" } },
64
+ ],
65
+ });
66
+ }
67
+
68
+ describe("Actions deep-links", () => {
69
+ it("preselects the action when ?name=… is in the URL", async () => {
70
+ const router = makeRouter();
71
+ await router.push("/actions?name=submitAssignment");
72
+ const wrapper = mount(Actions, { global: { plugins: [router] } });
73
+ // Wait for cache fetch + onMounted preselect + computed re-render.
74
+ await flushPromises();
75
+ await flushPromises();
76
+
77
+ expect(wrapper.text()).toContain("submitAssignment");
78
+ // Schema heading is only shown when an action is selected.
79
+ expect(wrapper.text()).toContain("Input schema");
80
+ // Cross-link buttons rendered.
81
+ expect(wrapper.find("[data-testid=hooks-link-submitAssignment]").exists()).toBe(true);
82
+ expect(wrapper.find("[data-testid=trace-link-submitAssignment]").exists()).toBe(true);
83
+ });
84
+
85
+ it("clicking 'View hooks' navigates to /hooks?name=action.before:<name>", async () => {
86
+ const router = makeRouter();
87
+ await router.push("/actions?name=submitAssignment");
88
+ const wrapper = mount(Actions, { global: { plugins: [router] } });
89
+ await flushPromises();
90
+ await flushPromises();
91
+
92
+ await wrapper.find("[data-testid=hooks-link-submitAssignment]").trigger("click");
93
+ await flushPromises();
94
+
95
+ expect(router.currentRoute.value.path).toBe("/hooks");
96
+ expect(router.currentRoute.value.query.name).toBe("action.before:submitAssignment");
97
+ });
98
+ });
@@ -0,0 +1,98 @@
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)
5
+ *
6
+ * All three sections should still render with their empty-state messages.
7
+ */
8
+ import { describe, it, expect, beforeEach, vi } from "vitest";
9
+ import { mount, flushPromises } from "@vue/test-utils";
10
+ import { createRouter, createMemoryHistory } from "vue-router";
11
+ import Home from "../Home.vue";
12
+
13
+ // Stub EventSource so we don't try to open a real connection.
14
+ class FakeEventSource {
15
+ onopen: (() => void) | null = null;
16
+ onerror: (() => void) | null = null;
17
+ onmessage: ((m: { data: string }) => void) | null = null;
18
+ constructor(public readonly url: string) {
19
+ queueMicrotask(() => this.onerror?.());
20
+ }
21
+ close(): void {}
22
+ }
23
+
24
+ beforeEach(() => {
25
+ // jsdom/happy-dom doesn't ship EventSource — install our stub.
26
+ (globalThis as unknown as { EventSource: typeof FakeEventSource }).EventSource = FakeEventSource;
27
+
28
+ // Mock fetch — manifest call returns empty cache; telemetry/recent fails.
29
+ globalThis.fetch = vi.fn((url: string | URL) => {
30
+ const u = String(url);
31
+ 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
+ );
58
+ }
59
+ if (u.includes("/_nwire/telemetry/recent")) {
60
+ return Promise.resolve(new Response("", { status: 502 }));
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: "/", name: "home", component: Home },
71
+ { path: "/trace", name: "trace", component: { template: "<div/>" } },
72
+ { path: "/hooks", name: "hooks", component: { template: "<div/>" } },
73
+ ],
74
+ });
75
+ }
76
+
77
+ 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();
82
+
83
+ 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);
97
+ });
98
+ });
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Hooks page deep-link test — verifies:
3
+ * 1. /hooks?name=<hookName> preselects the matching hook by id.
4
+ * 2. When the hook is a `plugin.boot:<n>` / `plugin.shutdown:<n>` hook,
5
+ * a "Registered by" link is rendered that navigates to
6
+ * /plugins?name=<n>.
7
+ */
8
+ import { describe, it, expect, beforeEach, vi } from "vitest";
9
+ import { mount, flushPromises } from "@vue/test-utils";
10
+ import { createRouter, createMemoryHistory } from "vue-router";
11
+ import Hooks from "../Hooks.vue";
12
+
13
+ class FakeEventSource {
14
+ onopen: (() => void) | null = null;
15
+ onerror: (() => void) | null = null;
16
+ onmessage: ((m: { data: string }) => void) | null = null;
17
+ constructor(public readonly url: string) {
18
+ queueMicrotask(() => this.onerror?.());
19
+ }
20
+ close(): void {}
21
+ }
22
+
23
+ const pluginBootHook = {
24
+ id: "h-1",
25
+ name: "plugin.boot:auth",
26
+ chain: 2,
27
+ listeners: 1,
28
+ };
29
+ const otherHook = {
30
+ id: "h-2",
31
+ name: "action.before:submitAssignment",
32
+ chain: 1,
33
+ listeners: 0,
34
+ };
35
+
36
+ beforeEach(() => {
37
+ (globalThis as unknown as { EventSource: typeof FakeEventSource }).EventSource = FakeEventSource;
38
+
39
+ globalThis.fetch = vi.fn((url: string | URL) => {
40
+ const u = String(url);
41
+ if (u.includes("/__nwire/manifest.json")) {
42
+ return Promise.resolve(
43
+ new Response(
44
+ JSON.stringify({
45
+ generatedAt: new Date().toISOString(),
46
+ apps: [],
47
+ modules: [],
48
+ actions: [],
49
+ events: [],
50
+ actors: [],
51
+ projections: [],
52
+ queries: [],
53
+ resolvers: [],
54
+ routes: [],
55
+ workflows: [],
56
+ externalCalls: [],
57
+ inboundWebhooks: [],
58
+ outboxes: [],
59
+ inboxes: [],
60
+ crons: [],
61
+ hooks: [pluginBootHook, otherHook],
62
+ plugins: [],
63
+ graph: { events: [] },
64
+ }),
65
+ { status: 200 },
66
+ ),
67
+ );
68
+ }
69
+ return Promise.resolve(new Response("", { status: 404 }));
70
+ }) as typeof fetch;
71
+ });
72
+
73
+ function makeRouter() {
74
+ return createRouter({
75
+ history: createMemoryHistory(),
76
+ routes: [
77
+ { path: "/hooks", name: "hooks", component: Hooks },
78
+ { path: "/plugins", name: "plugins", component: { template: "<div/>" } },
79
+ ],
80
+ });
81
+ }
82
+
83
+ describe("Hooks deep-links", () => {
84
+ it("preselects a hook by name and shows the 'Registered by' plugin link", async () => {
85
+ const router = makeRouter();
86
+ await router.push("/hooks?name=plugin.boot:auth");
87
+ const wrapper = mount(Hooks, { global: { plugins: [router] } });
88
+ await flushPromises();
89
+ await flushPromises();
90
+
91
+ expect(wrapper.text()).toContain("plugin.boot:auth");
92
+ expect(wrapper.find("[data-testid=plugin-link-auth]").exists()).toBe(true);
93
+ });
94
+
95
+ it("does not render the plugin link for non-plugin hooks", async () => {
96
+ const router = makeRouter();
97
+ await router.push("/hooks?name=action.before:submitAssignment");
98
+ const wrapper = mount(Hooks, { global: { plugins: [router] } });
99
+ await flushPromises();
100
+ await flushPromises();
101
+
102
+ expect(wrapper.text()).toContain("action.before:submitAssignment");
103
+ expect(wrapper.find("[data-testid^=plugin-link-]").exists()).toBe(false);
104
+ });
105
+
106
+ it("clicking the plugin link navigates to /plugins?name=<n>", async () => {
107
+ const router = makeRouter();
108
+ await router.push("/hooks?name=plugin.boot:auth");
109
+ const wrapper = mount(Hooks, { global: { plugins: [router] } });
110
+ await flushPromises();
111
+ await flushPromises();
112
+
113
+ await wrapper.find("[data-testid=plugin-link-auth]").trigger("click");
114
+ await flushPromises();
115
+
116
+ expect(router.currentRoute.value.path).toBe("/plugins");
117
+ expect(router.currentRoute.value.query.name).toBe("auth");
118
+ });
119
+ });
@@ -0,0 +1,80 @@
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.
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 Plugins from "../Plugins.vue";
10
+
11
+ class FakeEventSource {
12
+ onopen: (() => void) | null = null;
13
+ onerror: (() => void) | null = null;
14
+ onmessage: ((m: { data: string }) => void) | null = null;
15
+ constructor(public readonly url: string) {
16
+ queueMicrotask(() => this.onerror?.());
17
+ }
18
+ close(): void {}
19
+ }
20
+
21
+ beforeEach(() => {
22
+ (globalThis as unknown as { EventSource: typeof FakeEventSource }).EventSource = FakeEventSource;
23
+
24
+ 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
+ );
53
+ }
54
+ return Promise.resolve(new Response("", { status: 404 }));
55
+ }) as typeof fetch;
56
+ });
57
+
58
+ function makeRouter() {
59
+ return createRouter({
60
+ history: createMemoryHistory(),
61
+ routes: [
62
+ { path: "/plugins", name: "plugins", component: Plugins },
63
+ { path: "/hooks", name: "hooks", component: { template: "<div/>" } },
64
+ ],
65
+ });
66
+ }
67
+
68
+ describe("Plugins", () => {
69
+ it("renders the empty state when cache.plugins is empty", async () => {
70
+ const router = makeRouter();
71
+ const wrapper = mount(Plugins, { global: { plugins: [router] } });
72
+ await flushPromises();
73
+
74
+ expect(wrapper.find("[data-testid=plugins-page]").exists()).toBe(true);
75
+ expect(wrapper.find("[data-testid=plugins-empty]").exists()).toBe(true);
76
+ // No list / detail / tap-list when empty.
77
+ expect(wrapper.find("[data-testid=plugins-list]").exists()).toBe(false);
78
+ expect(wrapper.find("[data-testid=plugins-detail]").exists()).toBe(false);
79
+ });
80
+ });
package/src/style.css ADDED
@@ -0,0 +1,40 @@
1
+ @import "tailwindcss";
2
+
3
+ @theme {
4
+ --color-background: #09090b;
5
+ --color-foreground: #fafafa;
6
+ --color-card: #18181b;
7
+ --color-card-foreground: #fafafa;
8
+ --color-border: #27272a;
9
+ --color-input: #27272a;
10
+ --color-ring: #d4d4d8;
11
+ --color-muted: #27272a;
12
+ --color-muted-foreground: #a1a1aa;
13
+ --color-accent: #27272a;
14
+ --color-accent-foreground: #fafafa;
15
+ --color-primary: #fafafa;
16
+ --color-primary-foreground: #18181b;
17
+ --color-destructive: #7f1d1d;
18
+ --color-destructive-foreground: #fafafa;
19
+ }
20
+
21
+ html,
22
+ body,
23
+ #app {
24
+ height: 100%;
25
+ }
26
+
27
+ /* Tighter VueFlow defaults for our canvas. */
28
+ .vue-flow__background {
29
+ background-color: #09090b;
30
+ }
31
+ .vue-flow__node {
32
+ font-family:
33
+ ui-sans-serif,
34
+ system-ui,
35
+ -apple-system,
36
+ BlinkMacSystemFont,
37
+ "Segoe UI",
38
+ Roboto,
39
+ sans-serif;
40
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
7
+ "jsx": "preserve",
8
+ "strict": true,
9
+ "skipLibCheck": true,
10
+ "esModuleInterop": true,
11
+ "resolveJsonModule": true,
12
+ "isolatedModules": true,
13
+ "noEmit": true,
14
+ "baseUrl": ".",
15
+ "paths": { "@/*": ["./src/*"] },
16
+ "types": ["vite/client"]
17
+ },
18
+ "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
19
+ "exclude": ["node_modules", "dist"]
20
+ }