@nwire/studio 0.10.1 → 0.11.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.
@@ -1,4 +1,12 @@
1
1
  <script setup lang="ts">
2
+ /**
3
+ * Topology — apps + plugins + event flow.
4
+ *
5
+ * Each app is a node. Its installed plugins are listed inside the node;
6
+ * its outbound sinks are listed below. Edges are event-flow edges from
7
+ * the `graph.events` cache: producer-app → consumer-app per event,
8
+ * coloured for cross-app vs in-app.
9
+ */
2
10
  import { computed, ref } from "vue";
3
11
  import { VueFlow, type Node, type Edge, MarkerType } from "@vue-flow/core";
4
12
  import { Background } from "@vue-flow/background";
@@ -10,100 +18,78 @@ import "@vue-flow/controls/dist/style.css";
10
18
 
11
19
  const { cache } = useCache();
12
20
 
13
- // App-level lane layout: each app gets a row; its modules sit on that row.
14
- const COLS_PER_ROW = 4;
15
- const NODE_W = 220;
16
- const NODE_H = 100;
17
- const COL_GAP = 60;
18
- const ROW_GAP = 140;
21
+ const NODE_W = 260;
22
+ const NODE_GAP_X = 80;
23
+ const NODE_GAP_Y = 80;
24
+ const COLS = 3;
19
25
 
20
26
  const elements = computed<{ nodes: Node[]; edges: Edge[] }>(() => {
21
27
  if (!cache.value) return { nodes: [], edges: [] };
22
-
23
28
  const nodes: Node[] = [];
24
- let y = 40;
25
29
 
26
- for (const app of cache.value.apps) {
27
- // App banner / header
30
+ for (let i = 0; i < cache.value.apps.length; i++) {
31
+ const app = cache.value.apps[i]!;
32
+ const pluginsForApp = cache.value.plugins.filter((p) => p.app === app.name);
33
+ const sinksForApp = cache.value.sinks?.filter((s) => s.app === app.name) ?? [];
34
+ const actionsCount = cache.value.actions.filter((a) => a.app === app.name).length;
35
+ const eventsCount = cache.value.events.filter((e) => e.app === app.name).length;
36
+ const projectionsCount = cache.value.projections.filter((p) => p.app === app.name).length;
37
+
38
+ const col = i % COLS;
39
+ const row = Math.floor(i / COLS);
40
+ const height = 110 + pluginsForApp.length * 18 + sinksForApp.length * 18;
41
+
28
42
  nodes.push({
29
43
  id: `app:${app.name}`,
30
- position: { x: 20, y },
44
+ position: { x: 40 + col * (NODE_W + NODE_GAP_X), y: 40 + row * (height + NODE_GAP_Y) },
31
45
  type: "default",
32
- data: { label: app.name },
46
+ data: {
47
+ label: app.name,
48
+ plugins: pluginsForApp.map((p) => p.name),
49
+ sinks: sinksForApp.map((s) => `${s.position} · ${s.kind ?? s.name}`),
50
+ actionsCount,
51
+ eventsCount,
52
+ projectionsCount,
53
+ },
33
54
  style: {
34
- width: `${COLS_PER_ROW * (NODE_W + COL_GAP) + 80}px`,
35
- height: "44px",
36
- background: "rgba(34, 197, 94, 0.10)",
55
+ width: `${NODE_W}px`,
56
+ height: `${height}px`,
57
+ background: "#0a0a0a",
37
58
  border: "1px solid rgb(34, 197, 94)",
38
- color: "rgb(134, 239, 172)",
39
- fontWeight: "600",
40
- fontSize: "13px",
41
- textAlign: "left",
42
- padding: "10px 16px",
59
+ color: "#e4e4e7",
43
60
  borderRadius: "8px",
61
+ padding: "0",
44
62
  },
45
- selectable: false,
46
- draggable: false,
47
63
  });
48
-
49
- let col = 0;
50
- let row = 0;
51
- const moduleY = y + 60;
52
- for (const moduleName of app.modules) {
53
- const mod = cache.value.modules.find((m) => m.name === moduleName && m.app === app.name);
54
- const x = 40 + col * (NODE_W + COL_GAP);
55
- const ny = moduleY + row * (NODE_H + ROW_GAP * 0.5);
56
- nodes.push({
57
- id: `${app.name}:${moduleName}`,
58
- position: { x, y: ny },
59
- data: {
60
- label: moduleName,
61
- subtitle: mod
62
- ? `${mod.counts.actions}A · ${mod.counts.events}E · ${mod.counts.actors}@`
63
- : "",
64
- },
65
- style: {
66
- width: `${NODE_W}px`,
67
- height: `${NODE_H}px`,
68
- background: "#18181b",
69
- border: "1px solid #3f3f46",
70
- color: "#fafafa",
71
- padding: "10px",
72
- borderRadius: "6px",
73
- },
74
- type: "default",
75
- });
76
- col++;
77
- if (col >= COLS_PER_ROW) {
78
- col = 0;
79
- row++;
80
- }
81
- }
82
- const totalRows = Math.max(1, Math.ceil(app.modules.length / COLS_PER_ROW));
83
- y = moduleY + totalRows * (NODE_H + ROW_GAP * 0.5) + 30;
84
64
  }
85
65
 
86
- // Edges: every event-graph edge from producer-moduleeach consumer-module.
66
+ // Edges from the event graph: producer-app → consumer-app.
87
67
  const edges: Edge[] = [];
88
- for (const edge of cache.value.graph.events) {
89
- const sourceId = `${edge.producer.app}:${edge.producer.module}`;
90
- for (const cons of edge.consumers) {
91
- const targetId = `${cons.app}:${cons.module}`;
92
- if (sourceId === targetId) continue; // skip self-loops for readability
93
- const cross = cons.app !== edge.producer.app;
68
+ const eventLog = cache.value.graph?.events ?? [];
69
+ for (const edge of eventLog) {
70
+ const producer = (edge as { producer?: { app?: string } }).producer;
71
+ const consumers = (edge as { consumers?: { app?: string; via?: string }[] }).consumers ?? [];
72
+ const sourceAppName = producer?.app;
73
+ if (!sourceAppName) continue;
74
+ const sourceId = `app:${sourceAppName}`;
75
+ for (const cons of consumers) {
76
+ if (!cons.app) continue;
77
+ const targetId = `app:${cons.app}`;
78
+ if (sourceId === targetId) continue;
79
+ const eventName = (edge as { event?: string }).event ?? "";
94
80
  edges.push({
95
- id: `${sourceId}->${targetId}::${edge.event}::${cons.via}`,
81
+ id: `${sourceId}->${targetId}::${eventName}::${cons.via ?? "via"}`,
96
82
  source: sourceId,
97
83
  target: targetId,
98
- label: edge.event,
84
+ label: eventName,
99
85
  type: "smoothstep",
100
- animated: cross,
101
- style: { stroke: cross ? "#a78bfa" : "#3f3f46", strokeWidth: cross ? 2 : 1 },
86
+ animated: true,
87
+ style: { stroke: "#a78bfa", strokeWidth: 2 },
102
88
  labelStyle: { fontSize: "10px", fill: "#a1a1aa" },
103
89
  labelBgStyle: { fill: "#18181b" },
104
90
  labelBgPadding: [4, 2] as [number, number],
105
91
  labelBgBorderRadius: 4,
106
- markerEnd: { type: MarkerType.ArrowClosed, color: cross ? "#a78bfa" : "#71717a" },
92
+ markerEnd: { type: MarkerType.ArrowClosed, color: "#a78bfa" },
107
93
  });
108
94
  }
109
95
  }
@@ -114,49 +100,63 @@ const elements = computed<{ nodes: Node[]; edges: Edge[] }>(() => {
114
100
  const showCrossOnly = ref(false);
115
101
  const filtered = computed(() => {
116
102
  if (!showCrossOnly.value) return elements.value;
117
- const edges = elements.value.edges.filter((e) => {
118
- const src = (e.source as string).split(":")[0];
119
- const tgt = (e.target as string).split(":")[0];
120
- return src !== tgt;
121
- });
103
+ const edges = elements.value.edges.filter((e) => e.source !== e.target);
122
104
  return { nodes: elements.value.nodes, edges };
123
105
  });
124
106
  </script>
125
107
 
126
108
  <template>
127
- <div v-if="cache" class="h-full flex flex-col">
109
+ <div v-if="cache" class="h-full flex flex-col" data-testid="topology-page">
128
110
  <div class="border-b border-zinc-800 px-6 py-3 flex items-center justify-between">
129
111
  <div>
130
112
  <h1 class="text-lg font-semibold tracking-tight">Topology</h1>
131
113
  <p class="text-xs text-zinc-500">
132
- Apps · bounded contexts · event flows
114
+ Apps · plugins · sinks · cross-app event flows
133
115
  <span class="ml-2">
134
116
  <span class="inline-block w-3 h-0.5 bg-purple-400 align-middle mr-1"></span>
135
- cross-service
136
- </span>
137
- <span class="ml-3">
138
- <span class="inline-block w-3 h-0.5 bg-zinc-600 align-middle mr-1"></span>
139
- in-process
117
+ cross-app
140
118
  </span>
141
119
  </p>
142
120
  </div>
143
121
  <label class="flex items-center gap-2 text-xs text-zinc-400">
144
- <input type="checkbox" v-model="showCrossOnly" class="accent-purple-400" />
145
- Cross-service only
122
+ <input v-model="showCrossOnly" type="checkbox" class="accent-purple-400" />
123
+ Cross-app only
146
124
  </label>
147
125
  </div>
148
- <div class="flex-1">
126
+
127
+ <div class="flex-1 relative">
149
128
  <VueFlow
150
129
  :nodes="filtered.nodes"
151
130
  :edges="filtered.edges"
152
- :default-viewport="{ x: 0, y: 0, zoom: 0.85 }"
153
- :min-zoom="0.3"
154
- :max-zoom="2"
155
131
  :fit-view-on-init="true"
156
- :nodes-draggable="false"
157
- :nodes-connectable="false"
132
+ :min-zoom="0.2"
158
133
  >
159
- <Background pattern-color="#3f3f46" :gap="20" />
134
+ <template #node-default="props">
135
+ <div class="px-3 py-2 text-left h-full flex flex-col gap-1">
136
+ <div class="font-mono text-sm text-emerald-300">{{ props.data.label }}</div>
137
+ <div class="text-[10px] text-zinc-500">
138
+ {{ props.data.actionsCount }}A · {{ props.data.eventsCount }}E ·
139
+ {{ props.data.projectionsCount }}P
140
+ </div>
141
+ <div v-if="props.data.plugins.length > 0" class="text-[10px] text-zinc-400 mt-1">
142
+ <div class="uppercase tracking-wider text-zinc-600">Plugins</div>
143
+ <div
144
+ v-for="p in props.data.plugins"
145
+ :key="p"
146
+ class="font-mono truncate text-zinc-300"
147
+ >
148
+ {{ p }}
149
+ </div>
150
+ </div>
151
+ <div v-if="props.data.sinks.length > 0" class="text-[10px] mt-1">
152
+ <div class="uppercase tracking-wider text-zinc-600">Sinks</div>
153
+ <div v-for="s in props.data.sinks" :key="s" class="font-mono truncate text-amber-200">
154
+ {{ s }}
155
+ </div>
156
+ </div>
157
+ </div>
158
+ </template>
159
+ <Background pattern-color="#27272a" />
160
160
  <Controls />
161
161
  </VueFlow>
162
162
  </div>
@@ -214,11 +214,11 @@ const causalForest = computed<TraceNode[]>(() => {
214
214
  const eventIndex = computed(() => {
215
215
  const m = new Map<
216
216
  string,
217
- { module: string; app: string; source?: { file: string; line: number; column?: number } }
217
+ { app: string; source?: { file: string; line: number; column?: number } }
218
218
  >();
219
219
  if (cache.value) {
220
220
  for (const e of cache.value.events) {
221
- m.set(e.name, { module: e.module, app: e.app, source: e.source });
221
+ m.set(e.name, { app: e.app, source: e.source });
222
222
  }
223
223
  }
224
224
  return m;
@@ -227,7 +227,6 @@ const eventIndex = computed(() => {
227
227
  // ── Context explorer ─────────────────────────────────────────────────
228
228
  interface ContextDigest {
229
229
  apps: Set<string>;
230
- modules: Set<string>;
231
230
  uniqueEvents: Set<string>;
232
231
  startedAt: string;
233
232
  endedAt: string;
@@ -243,7 +242,6 @@ const context = computed<ContextDigest | undefined>(() => {
243
242
  const evts = selectedTraceEvents.value;
244
243
  if (evts.length === 0) return undefined;
245
244
  const apps = new Set<string>();
246
- const modules = new Set<string>();
247
245
  const unique = new Set<string>();
248
246
  const tenants = new Set<string>();
249
247
  const users = new Set<string>();
@@ -254,8 +252,6 @@ const context = computed<ContextDigest | undefined>(() => {
254
252
  apps.add(e.appName);
255
253
  if (e.eventName) {
256
254
  unique.add(e.eventName);
257
- const meta = eventIndex.value.get(e.eventName);
258
- if (meta) modules.add(`${meta.app}/${meta.module}`);
259
255
  }
260
256
  if (e.envelope.tenant) tenants.add(e.envelope.tenant);
261
257
  if (e.envelope.userId) users.add(e.envelope.userId);
@@ -269,7 +265,6 @@ const context = computed<ContextDigest | undefined>(() => {
269
265
 
270
266
  return {
271
267
  apps,
272
- modules,
273
268
  uniqueEvents: unique,
274
269
  startedAt,
275
270
  endedAt,
@@ -444,20 +439,6 @@ watch(
444
439
  </div>
445
440
  </section>
446
441
 
447
- <section v-if="context.modules.size" class="px-4 py-3">
448
- <h3 class="text-[10px] uppercase tracking-wide text-zinc-500 mb-2">Modules touched</h3>
449
- <ul class="space-y-1">
450
- <li
451
- v-for="m in [...context.modules]"
452
- :key="m"
453
- class="flex items-center gap-2 text-xs font-mono"
454
- >
455
- <Boxes class="w-3 h-3 text-violet-400" />
456
- {{ m }}
457
- </li>
458
- </ul>
459
- </section>
460
-
461
442
  <section v-if="context.uniqueEvents.size" class="px-4 py-3">
462
443
  <h3 class="text-[10px] uppercase tracking-wide text-zinc-500 mb-2">Distinct events</h3>
463
444
  <ul class="space-y-1">
@@ -45,7 +45,7 @@ interface SourceLoc {
45
45
  const props = defineProps<{
46
46
  node: TraceNode;
47
47
  depth: number;
48
- eventIndex: Map<string, { module: string; app: string; source?: SourceLoc }>;
48
+ eventIndex: Map<string, { app: string; source?: SourceLoc }>;
49
49
  isExpanded: (id: string) => boolean;
50
50
  toggle: (id: string) => void;
51
51
  formatTime: (iso: string) => string;
@@ -115,9 +115,7 @@ function open(s: SourceLoc | undefined) {
115
115
  <span class="text-[10px] text-zinc-500 tabular-nums">
116
116
  {{ formatTime(node.evt.capturedAt) }}
117
117
  </span>
118
- <span v-if="meta" class="text-[10px] text-zinc-500 font-mono">
119
- · {{ meta.app }}/{{ meta.module }}
120
- </span>
118
+ <span v-if="meta" class="text-[10px] text-zinc-500 font-mono"> · {{ meta.app }} </span>
121
119
  <button v-if="source" type="button" class="ml-auto" @click="open(source)">
122
120
  <SourcePill :source="source" compact />
123
121
  </button>
@@ -29,9 +29,8 @@ const sourcePreview = ref<{ file: string; line: number; column?: number } | null
29
29
  function applyQueryPreselect(): void {
30
30
  const name = route.query.name;
31
31
  if (typeof name !== "string" || name.length === 0) return;
32
- // Match by workflow name across (app, module). First match wins.
33
32
  const found = cache.value?.workflows.find((w) => w.name === name);
34
- if (found) selected.value = `${found.app}::${found.module}::${found.name}`;
33
+ if (found) selected.value = `${found.app}::${found.name}`;
35
34
  }
36
35
 
37
36
  onMounted(applyQueryPreselect);
@@ -45,16 +44,14 @@ const filtered = computed(() => {
45
44
  (w) =>
46
45
  !q ||
47
46
  w.name.toLowerCase().includes(q) ||
48
- w.module.toLowerCase().includes(q) ||
49
47
  w.app.toLowerCase().includes(q) ||
50
- w.subscribesTo.some((e) => e.toLowerCase().includes(q)) ||
51
- w.dispatches.some((a) => a.toLowerCase().includes(q)) ||
48
+ (w.subscribesTo ?? []).some((e) => e.toLowerCase().includes(q)) ||
49
+ (w.dispatches ?? []).some((a) => a.toLowerCase().includes(q)) ||
52
50
  (w.description ?? "").toLowerCase().includes(q),
53
51
  );
54
52
  });
55
53
 
56
- const key = (w: { app: string; module: string; name: string }) =>
57
- `${w.app}::${w.module}::${w.name}`;
54
+ const key = (w: { app: string; name: string }) => `${w.app}::${w.name}`;
58
55
  const detail = computed(() => filtered.value.find((w) => key(w) === selected.value) ?? null);
59
56
  </script>
60
57
 
@@ -80,7 +77,7 @@ const detail = computed(() => filtered.value.find((w) => key(w) === selected.val
80
77
 
81
78
  <MasterDetail v-else class="flex-1">
82
79
  <template #listHeader>
83
- <FilterInput v-model="filter" placeholder="filter by name, module, event, action…" />
80
+ <FilterInput v-model="filter" placeholder="filter by name, app, event, action…" />
84
81
  </template>
85
82
 
86
83
  <template #list>
@@ -99,16 +96,18 @@ const detail = computed(() => filtered.value.find((w) => key(w) === selected.val
99
96
  :is="w.public ? Globe : Lock"
100
97
  class="w-3 h-3"
101
98
  :class="w.public ? 'text-emerald-400' : 'text-zinc-500'"
102
- :title="w.public ? 'public — exposed across modules' : 'private — module-internal'"
99
+ :title="w.public ? 'public — exposed across apps' : 'private — app-internal'"
103
100
  />
104
- <span class="text-[10px] text-zinc-500">{{ w.app }} · {{ w.module }}</span>
101
+ <span class="text-[10px] text-zinc-500">{{ w.app }}</span>
105
102
  </template>
106
- <template v-if="w.description || w.subscribesTo.length > 0" #description>
103
+ <template v-if="w.description || (w.subscribesTo ?? []).length > 0" #description>
107
104
  <div v-if="w.description">{{ w.description }}</div>
108
- <div v-if="w.subscribesTo.length > 0" class="text-zinc-600 mt-0.5">
105
+ <div v-if="(w.subscribesTo ?? []).length > 0" class="text-zinc-600 mt-0.5">
109
106
  <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>
107
+ {{ (w.subscribesTo ?? []).slice(0, 2).join(", ") }}
108
+ <span v-if="(w.subscribesTo ?? []).length > 2">
109
+ +{{ (w.subscribesTo ?? []).length - 2 }}
110
+ </span>
112
111
  </div>
113
112
  </template>
114
113
  </ListRow>
@@ -120,7 +119,7 @@ const detail = computed(() => filtered.value.find((w) => key(w) === selected.val
120
119
  <div class="p-6 space-y-5" data-testid="workflow-detail">
121
120
  <div>
122
121
  <div class="text-[10px] uppercase tracking-wide text-zinc-500">
123
- {{ detail.app }} · {{ detail.module }}
122
+ {{ detail.app }}
124
123
  </div>
125
124
  <h2 class="font-mono text-xl mt-1">{{ detail.name }}</h2>
126
125
  <p v-if="detail.description" class="text-sm text-zinc-400 mt-2 max-w-2xl">
@@ -136,12 +135,12 @@ const detail = computed(() => filtered.value.find((w) => key(w) === selected.val
136
135
 
137
136
  <div class="space-y-3">
138
137
  <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">
138
+ <div v-if="(detail.subscribesTo ?? []).length === 0" class="text-xs text-zinc-600">
140
139
  No event subscriptions declared.
141
140
  </div>
142
141
  <div v-else class="space-y-1">
143
142
  <button
144
- v-for="ev in detail.subscribesTo"
143
+ v-for="ev in detail.subscribesTo ?? []"
145
144
  :key="ev"
146
145
  type="button"
147
146
  class="flex items-center gap-2 font-mono text-sm text-left hover:text-cyan-300"
@@ -156,12 +155,12 @@ const detail = computed(() => filtered.value.find((w) => key(w) === selected.val
156
155
 
157
156
  <div class="space-y-3">
158
157
  <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">
158
+ <div v-if="(detail.dispatches ?? []).length === 0" class="text-xs text-zinc-600">
160
159
  Pure observer — no action dispatches.
161
160
  </div>
162
161
  <div v-else class="space-y-1">
163
162
  <button
164
- v-for="action in detail.dispatches"
163
+ v-for="action in detail.dispatches ?? []"
165
164
  :key="action"
166
165
  type="button"
167
166
  class="flex items-center gap-2 font-mono text-sm text-left hover:text-amber-300"
@@ -175,13 +174,7 @@ const detail = computed(() => filtered.value.find((w) => key(w) === selected.val
175
174
  </div>
176
175
 
177
176
  <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>
177
+ <SourcePill :source="detail.source" @click="sourcePreview = detail.source!" />
185
178
  </div>
186
179
  </div>
187
180
  </template>
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Projections page — renders the list from cache and cross-links to
3
+ * each query that reads the projection.
4
+ */
5
+ import { describe, it, expect, beforeEach, vi } from "vitest";
6
+ import { mount, flushPromises } from "@vue/test-utils";
7
+ import { createRouter, createMemoryHistory } from "vue-router";
8
+ import Projections from "../Projections.vue";
9
+
10
+ const counterProjection = {
11
+ name: "counter-total",
12
+ app: "shop",
13
+ };
14
+ const counterQuery = {
15
+ name: "counter.get-count",
16
+ app: "shop",
17
+ public: false,
18
+ projection: "counter-total",
19
+ };
20
+
21
+ beforeEach(() => {
22
+ globalThis.fetch = vi.fn((url: string | URL) => {
23
+ const u = String(url);
24
+ if (u.includes("/__nwire/manifest.json")) {
25
+ return Promise.resolve(
26
+ new Response(
27
+ JSON.stringify({
28
+ generatedAt: new Date().toISOString(),
29
+ apps: [],
30
+ modules: [],
31
+ actions: [],
32
+ events: [],
33
+ actors: [],
34
+ projections: [counterProjection],
35
+ queries: [counterQuery],
36
+ resolvers: [],
37
+ routes: [],
38
+ workflows: [],
39
+ externalCalls: [],
40
+ inboundWebhooks: [],
41
+ outboxes: [],
42
+ inboxes: [],
43
+ crons: [],
44
+ hooks: [],
45
+ plugins: [],
46
+ bindings: [],
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: "/projections", name: "projections", component: Projections },
62
+ { path: "/queries", name: "queries", component: { template: "<div/>" } },
63
+ ],
64
+ });
65
+ }
66
+
67
+ describe("Projections", () => {
68
+ it("renders projections from the cache", async () => {
69
+ const router = makeRouter();
70
+ await router.push("/projections");
71
+ const wrapper = mount(Projections, { global: { plugins: [router] } });
72
+ await flushPromises();
73
+ await flushPromises();
74
+
75
+ expect(wrapper.text()).toContain("counter-total");
76
+ expect(wrapper.find("[data-testid=projections-page]").exists()).toBe(true);
77
+ });
78
+
79
+ it("deep-links via ?name=… and shows linked queries", async () => {
80
+ const router = makeRouter();
81
+ await router.push("/projections?name=counter-total");
82
+ const wrapper = mount(Projections, { global: { plugins: [router] } });
83
+ await flushPromises();
84
+ await flushPromises();
85
+
86
+ const detail = wrapper.find("[data-testid=projection-detail]");
87
+ expect(detail.exists()).toBe(true);
88
+ expect(detail.text()).toContain("counter.get-count");
89
+ });
90
+ });
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Queries page — renders the list from cache and cross-links to the
3
+ * backing projection when the query is projection-form.
4
+ */
5
+ import { describe, it, expect, beforeEach, vi } from "vitest";
6
+ import { mount, flushPromises } from "@vue/test-utils";
7
+ import { createRouter, createMemoryHistory } from "vue-router";
8
+ import Queries from "../Queries.vue";
9
+
10
+ const counterQuery = {
11
+ name: "counter.get-count",
12
+ app: "shop",
13
+ public: true,
14
+ projection: "counter-total",
15
+ };
16
+
17
+ beforeEach(() => {
18
+ globalThis.fetch = vi.fn((url: string | URL) => {
19
+ const u = String(url);
20
+ if (u.includes("/__nwire/manifest.json")) {
21
+ return Promise.resolve(
22
+ new Response(
23
+ JSON.stringify({
24
+ generatedAt: new Date().toISOString(),
25
+ apps: [],
26
+ modules: [],
27
+ actions: [],
28
+ events: [],
29
+ actors: [],
30
+ projections: [],
31
+ queries: [counterQuery],
32
+ resolvers: [],
33
+ routes: [],
34
+ workflows: [],
35
+ externalCalls: [],
36
+ inboundWebhooks: [],
37
+ outboxes: [],
38
+ inboxes: [],
39
+ crons: [],
40
+ hooks: [],
41
+ plugins: [],
42
+ bindings: [],
43
+ graph: { events: [] },
44
+ }),
45
+ { status: 200 },
46
+ ),
47
+ );
48
+ }
49
+ return Promise.resolve(new Response("", { status: 404 }));
50
+ }) as typeof fetch;
51
+ });
52
+
53
+ function makeRouter() {
54
+ return createRouter({
55
+ history: createMemoryHistory(),
56
+ routes: [
57
+ { path: "/queries", name: "queries", component: Queries },
58
+ { path: "/projections", name: "projections", component: { template: "<div/>" } },
59
+ ],
60
+ });
61
+ }
62
+
63
+ describe("Queries", () => {
64
+ it("renders queries from the cache", async () => {
65
+ const router = makeRouter();
66
+ await router.push("/queries");
67
+ const wrapper = mount(Queries, { global: { plugins: [router] } });
68
+ await flushPromises();
69
+ await flushPromises();
70
+
71
+ expect(wrapper.text()).toContain("counter.get-count");
72
+ expect(wrapper.find("[data-testid=queries-page]").exists()).toBe(true);
73
+ });
74
+
75
+ it("deep-links via ?name=… and shows the backing projection", async () => {
76
+ const router = makeRouter();
77
+ await router.push("/queries?name=counter.get-count");
78
+ const wrapper = mount(Queries, { global: { plugins: [router] } });
79
+ await flushPromises();
80
+ await flushPromises();
81
+
82
+ const detail = wrapper.find("[data-testid=query-detail]");
83
+ expect(detail.exists()).toBe(true);
84
+ expect(detail.text()).toContain("counter-total");
85
+ });
86
+ });