@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
@@ -0,0 +1,74 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * `WaterfallRow` — one row of the trace waterfall: a depth-indented gutter
4
+ * (kind dot + label + kind), and a time-lane bar positioned by `offsetPct` /
5
+ * `widthPct`. Critical-path rows get a brighter bar; failures go red; the
6
+ * selected row is highlighted. Purely presentational — `Waterfall` owns layout.
7
+ */
8
+ import { computed } from "vue";
9
+ import { kindColor, recordColorKey } from "@/lib/kind-colors";
10
+ import type { WaterfallRow } from "@/lib/waterfall";
11
+
12
+ const props = withDefaults(
13
+ defineProps<{
14
+ row: WaterfallRow;
15
+ selected?: boolean;
16
+ /** Rows beyond the replay cursor render dimmed. */
17
+ dimmed?: boolean;
18
+ }>(),
19
+ { selected: false, dimmed: false },
20
+ );
21
+
22
+ const emit = defineEmits<{ (e: "select", row: WaterfallRow): void }>();
23
+
24
+ const color = computed(() => kindColor(recordColorKey(props.row.record.kind)));
25
+ </script>
26
+
27
+ <template>
28
+ <button
29
+ class="group flex w-full items-center gap-2 px-2 py-1 text-left transition-colors"
30
+ :class="[selected ? 'bg-zinc-800/80' : 'hover:bg-zinc-900/60', dimmed ? 'opacity-30' : '']"
31
+ data-testid="waterfall-row"
32
+ :data-critical="row.critical ? 'true' : undefined"
33
+ :data-failed="row.failed ? 'true' : undefined"
34
+ @click="emit('select', row)"
35
+ >
36
+ <!-- Gutter: indent + dot + label -->
37
+ <div
38
+ class="flex items-center gap-1.5 w-1/2 min-w-0"
39
+ :style="{ paddingLeft: row.depth * 14 + 'px' }"
40
+ >
41
+ <span class="h-2 w-2 rounded-full shrink-0" :style="{ backgroundColor: color }" />
42
+ <span
43
+ class="font-mono text-xs truncate"
44
+ :class="row.failed ? 'text-rose-300' : 'text-zinc-200'"
45
+ data-testid="waterfall-label"
46
+ >
47
+ {{ row.label }}
48
+ </span>
49
+ <span class="text-[10px] text-zinc-600 truncate shrink-0">{{ row.record.kind }}</span>
50
+ </div>
51
+
52
+ <!-- Time lane -->
53
+ <div class="relative h-3 flex-1">
54
+ <span
55
+ class="absolute top-1/2 -translate-y-1/2 h-1.5 rounded-full"
56
+ :class="[
57
+ row.failed ? 'bg-rose-500/80' : '',
58
+ row.critical && !row.failed ? 'ring-1 ring-white/40' : '',
59
+ ]"
60
+ :style="{
61
+ left: row.offsetPct + '%',
62
+ width: row.widthPct + '%',
63
+ backgroundColor: row.failed ? undefined : color,
64
+ opacity: row.critical || row.failed ? 1 : 0.55,
65
+ }"
66
+ data-testid="waterfall-bar"
67
+ />
68
+ </div>
69
+
70
+ <span class="text-[10px] tabular-nums text-zinc-500 w-14 text-right shrink-0">
71
+ {{ row.durationMs.toFixed(1) }}ms
72
+ </span>
73
+ </button>
74
+ </template>
@@ -0,0 +1,53 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { mount } from "@vue/test-utils";
3
+ import BcCard from "../BcCard.vue";
4
+ import type { BcNode } from "@/lib/bc-graph";
5
+
6
+ const bc: BcNode = {
7
+ name: "orders",
8
+ rows: [
9
+ { id: "action:orders.place", kind: "action", name: "orders.place", public: true },
10
+ { id: "event:orders.placed", kind: "event", name: "orders.placed" },
11
+ { id: "query:orders.list", kind: "query", name: "orders.list" },
12
+ ],
13
+ };
14
+
15
+ describe("BcCard", () => {
16
+ it("shows the app name and member count", () => {
17
+ const w = mount(BcCard, { props: { bc } });
18
+ expect(w.get('[data-testid="bc-card-name"]').text()).toBe("orders");
19
+ expect(w.text()).toContain("3");
20
+ });
21
+
22
+ it("renders per-kind count chips", () => {
23
+ const w = mount(BcCard, { props: { bc } });
24
+ expect(w.text()).toContain("1 action");
25
+ expect(w.text()).toContain("1 event");
26
+ expect(w.text()).toContain("1 query");
27
+ });
28
+
29
+ it("strips the app prefix from row names", () => {
30
+ const w = mount(BcCard, { props: { bc } });
31
+ const row = w.get('[data-testid="bc-row-action:orders.place"]');
32
+ expect(row.text()).toContain("place");
33
+ expect(row.text()).not.toContain("orders.place");
34
+ });
35
+
36
+ it("marks public rows and emits select with the row on click", async () => {
37
+ const w = mount(BcCard, { props: { bc } });
38
+ const row = w.get('[data-testid="bc-row-action:orders.place"]');
39
+ expect(row.find('[data-testid="bc-row-public"]').exists()).toBe(true);
40
+ await row.trigger("click");
41
+ expect(w.emitted("select")?.[0]?.[0]).toMatchObject({ id: "action:orders.place" });
42
+ });
43
+
44
+ it("highlights the selected row", () => {
45
+ const w = mount(BcCard, { props: { bc, selectedId: "event:orders.placed" } });
46
+ expect(w.get('[data-testid="bc-row-event:orders.placed"]').classes()).toContain("bg-zinc-900");
47
+ });
48
+
49
+ it("shows an empty hint when the BC has no primitives", () => {
50
+ const w = mount(BcCard, { props: { bc: { name: "empty", rows: [] } } });
51
+ expect(w.text()).toContain("No primitives yet");
52
+ });
53
+ });
@@ -0,0 +1,31 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { mount } from "@vue/test-utils";
3
+ import DurationBar from "../DurationBar.vue";
4
+
5
+ describe("DurationBar", () => {
6
+ it("renders the duration label with one decimal", () => {
7
+ const w = mount(DurationBar, { props: { ms: 12.34 } });
8
+ expect(w.get('[data-testid="duration-value"]').text()).toBe("12.3ms");
9
+ });
10
+
11
+ it("is green under the warn threshold", () => {
12
+ const w = mount(DurationBar, { props: { ms: 10, warnMs: 50, dangerMs: 100 } });
13
+ expect(w.get('[data-testid="duration-bar"]').attributes("data-tone")).toBe("ok");
14
+ });
15
+
16
+ it("turns amber at the warn threshold", () => {
17
+ const w = mount(DurationBar, { props: { ms: 60, warnMs: 50, dangerMs: 100 } });
18
+ expect(w.get('[data-testid="duration-bar"]').attributes("data-tone")).toBe("warn");
19
+ });
20
+
21
+ it("turns red at the danger threshold", () => {
22
+ const w = mount(DurationBar, { props: { ms: 120, warnMs: 50, dangerMs: 100 } });
23
+ expect(w.get('[data-testid="duration-bar"]').attributes("data-tone")).toBe("danger");
24
+ });
25
+
26
+ it("scales the bar width against max", () => {
27
+ const w = mount(DurationBar, { props: { ms: 25, max: 100 } });
28
+ const bar = w.get('[data-testid="duration-bar"] > div > div');
29
+ expect(bar.attributes("style")).toContain("width: 25%");
30
+ });
31
+ });
@@ -0,0 +1,71 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { mount } from "@vue/test-utils";
3
+ import ErrorCard from "../ErrorCard.vue";
4
+ import type { TelemetryRecord } from "@/lib/telemetry";
5
+
6
+ const stubs = { RouterLink: { template: "<a data-stub-link><slot /></a>" } };
7
+
8
+ const mountCard = (record: TelemetryRecord, props: Record<string, unknown> = {}) =>
9
+ mount(ErrorCard, { props: { record, ...props }, global: { stubs } });
10
+
11
+ describe("ErrorCard", () => {
12
+ it("renders the friendly title + summary and tags the severity", () => {
13
+ const w = mountCard({
14
+ kind: "dlq.recorded",
15
+ action: "orders.ship",
16
+ attempts: 5,
17
+ error: { name: "TypeError", message: "boom" },
18
+ ts: "2026-01-01T00:00:00.000Z",
19
+ envelope: { messageId: "m", causationId: "m", correlationId: "c-123" },
20
+ });
21
+ expect(w.get('[data-testid="error-title"]').text()).toBe("Dead-lettered");
22
+ expect(w.get('[data-testid="error-summary"]').text()).toContain("orders.ship");
23
+ expect(w.get('[data-testid="error-card"]').attributes("data-severity")).toBe("critical");
24
+ expect(w.get('[data-testid="error-severity"]').text()).toBe("Critical");
25
+ });
26
+
27
+ it("shows the retry narrative when the record carries attempts", () => {
28
+ const w = mountCard({
29
+ kind: "action.failed",
30
+ action: "orders.charge",
31
+ attempt: 2,
32
+ maxAttempts: 3,
33
+ willRetry: true,
34
+ error: { name: "E", message: "x" },
35
+ });
36
+ expect(w.get('[data-testid="error-retry"]').text()).toContain("attempt 2 of 3");
37
+ });
38
+
39
+ it("renders a correlation chip linking to the trace", () => {
40
+ const w = mountCard({
41
+ kind: "action.failed",
42
+ action: "a",
43
+ error: { name: "E", message: "x" },
44
+ envelope: { messageId: "m", causationId: "m", correlationId: "c-abcdef-1" },
45
+ });
46
+ expect(w.get('[data-testid="error-correlation"]').text()).toContain("c");
47
+ });
48
+
49
+ it("emits select on click and reflects the selected state", async () => {
50
+ const w = mountCard(
51
+ { kind: "action.failed", action: "a", error: { name: "E", message: "x" } },
52
+ { selected: true },
53
+ );
54
+ await w.get('[data-testid="error-card"]').trigger("click");
55
+ expect(w.emitted("select")).toBeTruthy();
56
+ expect(w.get('[data-testid="error-card"]').classes()).toContain("border-zinc-600");
57
+ });
58
+
59
+ it("hides the correlation chip when asked", () => {
60
+ const w = mountCard(
61
+ {
62
+ kind: "action.failed",
63
+ action: "a",
64
+ error: { name: "E", message: "x" },
65
+ envelope: { messageId: "m", causationId: "m", correlationId: "c-1" },
66
+ },
67
+ { showCorrelation: false },
68
+ );
69
+ expect(w.find('[data-testid="error-correlation"]').exists()).toBe(false);
70
+ });
71
+ });
@@ -0,0 +1,23 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { mount } from "@vue/test-utils";
3
+ import KpiTile from "../KpiTile.vue";
4
+
5
+ describe("KpiTile", () => {
6
+ it("renders label, value and sub", () => {
7
+ const w = mount(KpiTile, { props: { label: "Actions", value: 12, sub: "3 public" } });
8
+ expect(w.text()).toContain("Actions");
9
+ expect(w.get('[data-testid="kpi-value"]').text()).toBe("12");
10
+ expect(w.text()).toContain("3 public");
11
+ });
12
+
13
+ it("applies the accent colour to the value", () => {
14
+ const w = mount(KpiTile, { props: { label: "Events", value: 4, accent: "#c08bff" } });
15
+ expect(w.get('[data-testid="kpi-value"]').attributes("style")).toContain("#c08bff");
16
+ });
17
+
18
+ it("omits the sub line when not given", () => {
19
+ const w = mount(KpiTile, { props: { label: "Queries", value: 0 } });
20
+ expect(w.text()).toContain("Queries");
21
+ expect(w.text()).toContain("0");
22
+ });
23
+ });
@@ -0,0 +1,100 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { mount } from "@vue/test-utils";
3
+ import LiveTable from "../LiveTable.vue";
4
+ import type { TelemetryRecord } from "@/lib/telemetry";
5
+
6
+ function rec(over: Partial<TelemetryRecord> & { kind: string }): TelemetryRecord {
7
+ return over as TelemetryRecord;
8
+ }
9
+
10
+ const rows: TelemetryRecord[] = [
11
+ rec({
12
+ kind: "action.dispatched",
13
+ action: "shop.place-order",
14
+ ts: "2026-06-15T10:00:00.000Z",
15
+ envelope: { messageId: "m1", correlationId: "c-abc-1", causationId: "m1" },
16
+ }),
17
+ rec({
18
+ kind: "action.completed",
19
+ action: "shop.place-order",
20
+ durationMs: 37,
21
+ ts: "2026-06-15T10:00:01.000Z",
22
+ envelope: { messageId: "m2", correlationId: "c-abc-1", causationId: "m1" },
23
+ }),
24
+ rec({
25
+ kind: "query.executed",
26
+ query: "shop.order-by-id",
27
+ durationMs: 4,
28
+ ts: "2026-06-15T10:00:02.000Z",
29
+ envelope: { messageId: "m3", correlationId: "c-def-2", causationId: "m1" },
30
+ }),
31
+ ];
32
+
33
+ describe("LiveTable", () => {
34
+ it("renders a row per record, newest first by default", () => {
35
+ const w = mount(LiveTable, { props: { rows } });
36
+ const cells = w.findAll('[data-testid="live-row"]');
37
+ expect(cells).toHaveLength(3);
38
+ // default sort is time desc → query.executed (10:00:02) leads
39
+ expect(cells[0]!.text()).toContain("query.executed");
40
+ });
41
+
42
+ it("shows subject, duration and short correlation", () => {
43
+ const w = mount(LiveTable, { props: { rows } });
44
+ const text = w.text();
45
+ expect(text).toContain("shop.place-order");
46
+ expect(text).toContain("37ms");
47
+ expect(text).toContain("c"); // shortId("c-abc-1") → "c"
48
+ });
49
+
50
+ it("toggles sort direction when a header is clicked", async () => {
51
+ const w = mount(LiveTable, { props: { rows } });
52
+ await w.find('[data-testid="col-time"]').trigger("click"); // time → asc
53
+ const cells = w.findAll('[data-testid="live-row"]');
54
+ expect(cells[0]!.text()).toContain("action.dispatched"); // oldest first now
55
+ });
56
+
57
+ it("re-sorts by a different column on header click", async () => {
58
+ const w = mount(LiveTable, { props: { rows } });
59
+ await w.find('[data-testid="col-subject"]').trigger("click"); // subject asc
60
+ const cells = w.findAll('[data-testid="live-row"]');
61
+ // "shop.order-by-id" < "shop.place-order"
62
+ expect(cells[0]!.text()).toContain("shop.order-by-id");
63
+ });
64
+
65
+ it("emits select + update:selectedIndex on row click", async () => {
66
+ const w = mount(LiveTable, { props: { rows } });
67
+ await w.findAll('[data-testid="live-row"]')[0]!.trigger("click");
68
+ expect(w.emitted("select")).toBeTruthy();
69
+ const idx = w.emitted("update:selectedIndex");
70
+ expect(idx).toBeTruthy();
71
+ expect(idx![0]![0]).toBe(0);
72
+ });
73
+
74
+ it("walks selection with ArrowDown / ArrowUp", async () => {
75
+ const w = mount(LiveTable, { props: { rows, selectedIndex: -1 } });
76
+ const grid = w.find('[data-testid="live-table"]');
77
+ await grid.trigger("keydown", { key: "ArrowDown" });
78
+ expect(w.emitted("update:selectedIndex")!.at(-1)![0]).toBe(0);
79
+
80
+ await w.setProps({ selectedIndex: 0 });
81
+ await grid.trigger("keydown", { key: "ArrowDown" });
82
+ expect(w.emitted("update:selectedIndex")!.at(-1)![0]).toBe(1);
83
+
84
+ await w.setProps({ selectedIndex: 1 });
85
+ await grid.trigger("keydown", { key: "ArrowUp" });
86
+ expect(w.emitted("update:selectedIndex")!.at(-1)![0]).toBe(0);
87
+ });
88
+
89
+ it("opens the selected row on Enter", async () => {
90
+ const w = mount(LiveTable, { props: { rows, selectedIndex: 0 } });
91
+ await w.find('[data-testid="live-table"]').trigger("keydown", { key: "Enter" });
92
+ expect(w.emitted("select")).toBeTruthy();
93
+ });
94
+
95
+ it("marks the selected row with aria-selected", () => {
96
+ const w = mount(LiveTable, { props: { rows, selectedIndex: 1 } });
97
+ const selected = w.findAll('[data-testid="live-row"]')[1]!;
98
+ expect(selected.attributes("aria-selected")).toBe("true");
99
+ });
100
+ });
@@ -0,0 +1,38 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { mount } from "@vue/test-utils";
3
+ import MetadataInspector from "../MetadataInspector.vue";
4
+
5
+ describe("MetadataInspector", () => {
6
+ it("renders one row per flattened leaf", () => {
7
+ const w = mount(MetadataInspector, {
8
+ props: { data: { kind: "action.completed", envelope: { messageId: "m1" } } },
9
+ });
10
+ const rows = w.findAll('[data-testid="meta-row"]');
11
+ expect(rows).toHaveLength(2);
12
+ expect(w.text()).toContain("envelope.messageId");
13
+ expect(w.text()).toContain("m1");
14
+ });
15
+
16
+ it("shows an empty hint for an empty object", () => {
17
+ const w = mount(MetadataInspector, { props: { data: {} } });
18
+ expect(w.find('[data-testid="meta-empty"]').exists()).toBe(true);
19
+ });
20
+
21
+ it("copies a value to the clipboard on row click", async () => {
22
+ const writeText = vi.fn().mockResolvedValue(undefined);
23
+ vi.stubGlobal("navigator", { clipboard: { writeText } });
24
+ const w = mount(MetadataInspector, { props: { data: { id: "abc" } } });
25
+ await w.get('[data-testid="meta-row"]').trigger("click");
26
+ expect(writeText).toHaveBeenCalledWith("abc");
27
+ vi.unstubAllGlobals();
28
+ });
29
+
30
+ it("copies the whole payload as JSON from the header", async () => {
31
+ const writeText = vi.fn().mockResolvedValue(undefined);
32
+ vi.stubGlobal("navigator", { clipboard: { writeText } });
33
+ const w = mount(MetadataInspector, { props: { data: { a: 1 }, label: "Span" } });
34
+ await w.get('[data-testid="meta-copy-all"]').trigger("click");
35
+ expect(writeText).toHaveBeenCalledWith(JSON.stringify({ a: 1 }, null, 2));
36
+ vi.unstubAllGlobals();
37
+ });
38
+ });
@@ -0,0 +1,116 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { mount } from "@vue/test-utils";
3
+ import NodeCard from "../NodeCard.vue";
4
+ import type { BcNodeDetail } from "@/lib/bc-graph";
5
+
6
+ const detail: BcNodeDetail = {
7
+ id: "action:orders.place",
8
+ kind: "action",
9
+ name: "orders.place",
10
+ app: "orders",
11
+ description: "Place an order.",
12
+ public: true,
13
+ source: { file: "/app/actions.ts", line: 12, column: 4 },
14
+ schema: { type: "object", properties: { id: { type: "string" } } },
15
+ emits: ["orders.placed"],
16
+ extra: { retry: 3 },
17
+ };
18
+
19
+ describe("NodeCard", () => {
20
+ it("renders the empty state with no detail", () => {
21
+ const w = mount(NodeCard, { props: { detail: null } });
22
+ expect(w.find('[data-testid="node-card-empty"]').exists()).toBe(true);
23
+ expect(w.find('[data-testid="node-card"]').exists()).toBe(false);
24
+ });
25
+
26
+ it("renders kind, name, app and description", () => {
27
+ const w = mount(NodeCard, { props: { detail } });
28
+ expect(w.text()).toContain("action");
29
+ expect(w.text()).toContain("orders.place");
30
+ expect(w.text()).toContain("orders");
31
+ expect(w.text()).toContain("Place an order.");
32
+ });
33
+
34
+ it("shows the public marker for public nodes", () => {
35
+ const w = mount(NodeCard, { props: { detail } });
36
+ expect(w.find('[data-testid="node-public"]').exists()).toBe(true);
37
+ const priv = mount(NodeCard, { props: { detail: { ...detail, public: false } } });
38
+ expect(priv.find('[data-testid="node-private"]').exists()).toBe(true);
39
+ });
40
+
41
+ it("emits select-event when an emitted event chip is clicked", async () => {
42
+ const w = mount(NodeCard, { props: { detail } });
43
+ await w.get('[data-testid="emits-orders.placed"]').trigger("click");
44
+ expect(w.emitted("select-event")?.[0]).toEqual(["orders.placed"]);
45
+ });
46
+
47
+ it("emits open-source when the source pill is clicked", async () => {
48
+ const w = mount(NodeCard, { props: { detail } });
49
+ await w.get('[data-testid="source-pill"]').trigger("click");
50
+ expect(w.emitted("open-source")?.[0]?.[0]).toMatchObject({ line: 12 });
51
+ });
52
+
53
+ it("renders a Business rules section listing each invariant message + rule", () => {
54
+ const withRules: BcNodeDetail = {
55
+ ...detail,
56
+ invariants: [
57
+ {
58
+ rule: '(i) => i.by.length > 0 || "Must name the moderator"',
59
+ message: "Must name the moderator",
60
+ },
61
+ { rule: "(i) => i.ok" },
62
+ ],
63
+ };
64
+ const w = mount(NodeCard, { props: { detail: withRules } });
65
+ const panel = w.find('[data-testid="business-rules"]');
66
+ expect(panel.exists()).toBe(true);
67
+ expect(panel.text()).toContain("Business rules");
68
+ expect(panel.text()).toContain("Must name the moderator");
69
+ expect(panel.text()).toContain('(i) => i.by.length > 0 || "Must name the moderator"');
70
+ // A rule with no message still shows its source plus the placeholder.
71
+ expect(panel.text()).toContain("(i) => i.ok");
72
+ expect(panel.text()).toContain("no stated message");
73
+ });
74
+
75
+ it("omits the Business rules section when there are no invariants", () => {
76
+ const w = mount(NodeCard, { props: { detail } });
77
+ expect(w.find('[data-testid="business-rules"]').exists()).toBe(false);
78
+ });
79
+
80
+ const appDetail: BcNodeDetail = {
81
+ id: "app:moderation",
82
+ kind: "app",
83
+ name: "moderation",
84
+ description: "Moderation BC.",
85
+ env: ["MODERATION_AUTO_THRESHOLD", "PORT"],
86
+ config: [{ file: "/abs/app/config/moderation.ts", keys: ["moderation", "spamMarker"] }],
87
+ extra: { plugins: ["forge.actions"] },
88
+ };
89
+
90
+ it("renders an Environment section listing each env var the app reads", () => {
91
+ const w = mount(NodeCard, { props: { detail: appDetail } });
92
+ const panel = w.find('[data-testid="environment"]');
93
+ expect(panel.exists()).toBe(true);
94
+ expect(panel.text()).toContain("Environment");
95
+ expect(w.find('[data-testid="env-PORT"]').exists()).toBe(true);
96
+ expect(w.find('[data-testid="env-MODERATION_AUTO_THRESHOLD"]').exists()).toBe(true);
97
+ });
98
+
99
+ it("renders a Config section with each module's file (config-relative) + keys", () => {
100
+ const w = mount(NodeCard, { props: { detail: appDetail } });
101
+ const panel = w.find('[data-testid="config"]');
102
+ expect(panel.exists()).toBe(true);
103
+ expect(panel.text()).toContain("Config");
104
+ // Path is shown from the config/ segment, not the absolute prefix.
105
+ expect(panel.text()).toContain("config/moderation.ts");
106
+ expect(panel.text()).not.toContain("/abs/app");
107
+ expect(panel.text()).toContain("moderation");
108
+ expect(panel.text()).toContain("spamMarker");
109
+ });
110
+
111
+ it("omits Environment + Config sections when absent", () => {
112
+ const w = mount(NodeCard, { props: { detail } });
113
+ expect(w.find('[data-testid="environment"]').exists()).toBe(false);
114
+ expect(w.find('[data-testid="config"]').exists()).toBe(false);
115
+ });
116
+ });
@@ -0,0 +1,81 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { mount } from "@vue/test-utils";
3
+ import RcaPanel from "../RcaPanel.vue";
4
+ import { groupIncidents } from "@/lib/rca";
5
+ import type { TelemetryRecord } from "@/lib/telemetry";
6
+
7
+ const env = (m: string, c: string, corr: string) => ({
8
+ messageId: m,
9
+ causationId: c,
10
+ correlationId: corr,
11
+ });
12
+
13
+ const RECORDS: TelemetryRecord[] = [
14
+ {
15
+ kind: "action.dispatched",
16
+ action: "orders.charge",
17
+ appName: "billing",
18
+ ts: "2026-01-01T00:00:00.000Z",
19
+ envelope: env("m1", "m1", "c1"),
20
+ },
21
+ {
22
+ kind: "action.failed",
23
+ action: "orders.charge",
24
+ appName: "billing",
25
+ attempt: 1,
26
+ maxAttempts: 3,
27
+ willRetry: true,
28
+ ts: "2026-01-01T00:00:00.050Z",
29
+ error: { name: "TimeoutError", message: "timed out", stack: "at chargeCard (billing.ts:42)" },
30
+ envelope: env("m2", "m1", "c1"),
31
+ },
32
+ {
33
+ kind: "dlq.recorded",
34
+ action: "orders.charge",
35
+ appName: "billing",
36
+ attempts: 3,
37
+ ts: "2026-01-01T00:00:00.300Z",
38
+ error: { name: "TimeoutError", message: "timed out" },
39
+ envelope: env("m3", "m1", "c1"),
40
+ },
41
+ ];
42
+
43
+ const incident = groupIncidents(RECORDS)[0]!;
44
+
45
+ describe("RcaPanel", () => {
46
+ it("opens on the 'What happened' tab with the suggested fix", () => {
47
+ const w = mount(RcaPanel, { props: { incident } });
48
+ expect(w.get('[data-testid="rca-what"]').exists()).toBe(true);
49
+ expect(w.get('[data-testid="rca-suggestion"]').text().length).toBeGreaterThan(0);
50
+ });
51
+
52
+ it("switches to the timeline and lists the whole chain with failures flagged", async () => {
53
+ const w = mount(RcaPanel, { props: { incident } });
54
+ await w.get('[data-testid="rca-tab-timeline"]').trigger("click");
55
+ const rows = w.findAll('[data-testid="rca-timeline-row"]');
56
+ expect(rows).toHaveLength(3);
57
+ expect(rows.filter((r) => r.attributes("data-failure") === "true")).toHaveLength(2);
58
+ });
59
+
60
+ it("shows the blast radius on the impact tab", async () => {
61
+ const w = mount(RcaPanel, { props: { incident } });
62
+ await w.get('[data-testid="rca-tab-impact"]').trigger("click");
63
+ const impact = w.get('[data-testid="rca-impact"]');
64
+ expect(impact.text()).toContain("billing");
65
+ expect(impact.text()).toContain("Yes"); // dead-lettered
66
+ });
67
+
68
+ it("shows the root error + stack on the evidence tab", async () => {
69
+ const w = mount(RcaPanel, { props: { incident } });
70
+ await w.get('[data-testid="rca-tab-evidence"]').trigger("click");
71
+ expect(w.get('[data-testid="rca-evidence"]').text()).toContain("TimeoutError");
72
+ expect(w.get('[data-testid="rca-stack"]').text()).toContain("chargeCard");
73
+ });
74
+
75
+ it("emits open-source when the source pill is clicked", async () => {
76
+ const source = { file: "apps/billing/orders.charge.ts", line: 42 };
77
+ const w = mount(RcaPanel, { props: { incident, source } });
78
+ await w.get('[data-testid="source-pill"]').trigger("click");
79
+ expect(w.emitted("open-source")?.[0]?.[0]).toEqual(source);
80
+ });
81
+ });
@@ -0,0 +1,23 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { mount } from "@vue/test-utils";
3
+ import StatusBadge from "../StatusBadge.vue";
4
+
5
+ describe("StatusBadge", () => {
6
+ it("renders the label", () => {
7
+ const w = mount(StatusBadge, { props: { status: "ok", label: "Healthy" } });
8
+ expect(w.text()).toContain("Healthy");
9
+ });
10
+
11
+ it("colours the dot by status", () => {
12
+ expect(mount(StatusBadge, { props: { status: "error" } }).html()).toContain("bg-rose-400");
13
+ expect(mount(StatusBadge, { props: { status: "warn" } }).html()).toContain("bg-amber-400");
14
+ expect(mount(StatusBadge, { props: { status: "idle" } }).html()).toContain("bg-zinc-600");
15
+ });
16
+
17
+ it("shows the pulse ring only when pulse is set", () => {
18
+ const off = mount(StatusBadge, { props: { status: "live" } });
19
+ expect(off.find('[data-testid="status-pulse"]').exists()).toBe(false);
20
+ const on = mount(StatusBadge, { props: { status: "live", pulse: true } });
21
+ expect(on.find('[data-testid="status-pulse"]').exists()).toBe(true);
22
+ });
23
+ });
@@ -0,0 +1,54 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { mount } from "@vue/test-utils";
3
+ import Waterfall from "../Waterfall.vue";
4
+ import type { TelemetryRecord, TraceNode } from "@/lib/telemetry";
5
+
6
+ const env = (messageId: string, causationId: string) => ({
7
+ messageId,
8
+ causationId,
9
+ correlationId: "c",
10
+ });
11
+ function node(record: TelemetryRecord, children: TraceNode[] = []): TraceNode {
12
+ return { record, children };
13
+ }
14
+
15
+ const forest: TraceNode[] = [
16
+ node({ kind: "action.dispatched", name: "place", durationMs: 10, envelope: env("m1", "m1") }, [
17
+ node({ kind: "query.executed", name: "lookup", durationMs: 90, envelope: env("m2", "m1") }),
18
+ node({
19
+ kind: "action.failed",
20
+ name: "charge",
21
+ error: { name: "E", message: "x" },
22
+ envelope: env("m3", "m1"),
23
+ }),
24
+ ]),
25
+ ];
26
+
27
+ describe("Waterfall", () => {
28
+ it("renders a row per node", () => {
29
+ const w = mount(Waterfall, { props: { forest } });
30
+ expect(w.findAll('[data-testid="waterfall-row"]')).toHaveLength(3);
31
+ });
32
+
33
+ it("marks the critical path and failed rows", () => {
34
+ const w = mount(Waterfall, { props: { forest } });
35
+ expect(w.findAll('[data-critical="true"]').length).toBeGreaterThan(0);
36
+ expect(w.find('[data-failed="true"]').exists()).toBe(true);
37
+ });
38
+
39
+ it("emits select with the clicked row's record", async () => {
40
+ const w = mount(Waterfall, { props: { forest } });
41
+ await w.findAll('[data-testid="waterfall-row"]')[1]!.trigger("click");
42
+ const ev = w.emitted("select");
43
+ expect(ev).toBeTruthy();
44
+ expect((ev![0]![0] as { record: TelemetryRecord }).record.name).toBe("lookup");
45
+ });
46
+
47
+ it("dims rows beyond the replay cursor", () => {
48
+ const w = mount(Waterfall, { props: { forest, reveal: 1 } });
49
+ const rows = w.findAll('[data-testid="waterfall-row"]');
50
+ expect(rows[0]!.classes()).not.toContain("opacity-30");
51
+ expect(rows[1]!.classes()).toContain("opacity-30");
52
+ expect(rows[2]!.classes()).toContain("opacity-30");
53
+ });
54
+ });