@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
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { missingFields } from "../manifest-health";
|
|
3
|
+
import type { Manifest } from "../manifest";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A current-shape manifest carries every kind as a top-level array (empty when
|
|
7
|
+
* unused) plus `graph.events`. Mirrors the real `@nwire/scan` `buildManifest`
|
|
8
|
+
* output — apps/resolvers/routes/hooks/plugins/sinks/bindings live under
|
|
9
|
+
* `topology`/`model`, not at the top level, so they're NOT health-checked.
|
|
10
|
+
*/
|
|
11
|
+
function fullManifest(): Record<string, unknown> {
|
|
12
|
+
return {
|
|
13
|
+
version: 3,
|
|
14
|
+
events: [],
|
|
15
|
+
actions: [],
|
|
16
|
+
actors: [],
|
|
17
|
+
projections: [],
|
|
18
|
+
queries: [],
|
|
19
|
+
workflows: [],
|
|
20
|
+
commands: [],
|
|
21
|
+
crons: [],
|
|
22
|
+
externalCalls: [],
|
|
23
|
+
inboundWebhooks: [],
|
|
24
|
+
outboxes: [],
|
|
25
|
+
inboxes: [],
|
|
26
|
+
resources: [],
|
|
27
|
+
errors: [],
|
|
28
|
+
schemas: [],
|
|
29
|
+
graph: { events: [] },
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("missingFields", () => {
|
|
34
|
+
it("reports nothing for a current-shape manifest", () => {
|
|
35
|
+
expect(missingFields(fullManifest() as unknown as Manifest)).toEqual([]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("does not flag the deep-manifest fields kept under topology/model", () => {
|
|
39
|
+
// Regression: these are NOT top-level arrays in the real manifest; flagging
|
|
40
|
+
// them false-fired the rebuild banner on every healthy project.
|
|
41
|
+
const out = missingFields(fullManifest() as unknown as Manifest);
|
|
42
|
+
for (const f of [
|
|
43
|
+
"apps",
|
|
44
|
+
"resolvers",
|
|
45
|
+
"routes",
|
|
46
|
+
"hooks",
|
|
47
|
+
"plugins",
|
|
48
|
+
"sinks",
|
|
49
|
+
"bindings",
|
|
50
|
+
"generatedAt",
|
|
51
|
+
]) {
|
|
52
|
+
expect(out).not.toContain(f);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("reports every expected field when the manifest is empty", () => {
|
|
57
|
+
const out = missingFields({} as unknown as Manifest);
|
|
58
|
+
expect(out).toContain("actions");
|
|
59
|
+
expect(out).toContain("events");
|
|
60
|
+
expect(out).toContain("workflows");
|
|
61
|
+
expect(out).toContain("commands");
|
|
62
|
+
expect(out).toContain("resources");
|
|
63
|
+
expect(out).toContain("graph");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("names the genuinely-missing kinds on a legacy manifest (the banner contract)", () => {
|
|
67
|
+
const legacy = fullManifest();
|
|
68
|
+
delete legacy.workflows;
|
|
69
|
+
delete legacy.commands;
|
|
70
|
+
const out = missingFields(legacy as unknown as Manifest);
|
|
71
|
+
expect(out).toContain("workflows");
|
|
72
|
+
expect(out).toContain("commands");
|
|
73
|
+
expect(out).not.toContain("actions");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("reports the exact list of missing array fields", () => {
|
|
77
|
+
const partial = {
|
|
78
|
+
version: 3,
|
|
79
|
+
events: [],
|
|
80
|
+
actions: [],
|
|
81
|
+
actors: [],
|
|
82
|
+
projections: [],
|
|
83
|
+
queries: [],
|
|
84
|
+
// workflows + commands + resources + errors + schemas + graph missing
|
|
85
|
+
crons: [],
|
|
86
|
+
externalCalls: [],
|
|
87
|
+
inboundWebhooks: [],
|
|
88
|
+
outboxes: [],
|
|
89
|
+
inboxes: [],
|
|
90
|
+
};
|
|
91
|
+
expect([...missingFields(partial as unknown as Manifest)].sort()).toEqual([
|
|
92
|
+
"commands",
|
|
93
|
+
"errors",
|
|
94
|
+
"graph",
|
|
95
|
+
"resources",
|
|
96
|
+
"schemas",
|
|
97
|
+
"workflows",
|
|
98
|
+
]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("treats a non-array masquerading as the field as missing", () => {
|
|
102
|
+
const m = fullManifest();
|
|
103
|
+
m.workflows = "not an array";
|
|
104
|
+
expect(missingFields(m as unknown as Manifest)).toContain("workflows");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("reports graph.events missing while graph exists", () => {
|
|
108
|
+
const m = fullManifest();
|
|
109
|
+
m.graph = {};
|
|
110
|
+
expect(missingFields(m as unknown as Manifest)).toContain("graph.events");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("returns [] for unusable (non-object) input — App renders the error banner instead", () => {
|
|
114
|
+
expect(missingFields(null)).toEqual([]);
|
|
115
|
+
expect(missingFields(undefined)).toEqual([]);
|
|
116
|
+
expect(missingFields([] as unknown as Manifest)).toEqual([]);
|
|
117
|
+
expect(missingFields("oops" as unknown as Manifest)).toEqual([]);
|
|
118
|
+
expect(missingFields(42 as unknown as Manifest)).toEqual([]);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { manifestView } from "../manifest";
|
|
3
|
+
import type { Manifest } from "../manifest";
|
|
4
|
+
|
|
5
|
+
function fixture(): Manifest {
|
|
6
|
+
return {
|
|
7
|
+
version: 3,
|
|
8
|
+
model: {
|
|
9
|
+
nodes: [
|
|
10
|
+
{ id: "action:orders.place", kind: "action", name: "orders.place" },
|
|
11
|
+
{ id: "event:OrderPlaced", kind: "event", name: "OrderPlaced" },
|
|
12
|
+
{ id: "app:orders", kind: "app", name: "orders" },
|
|
13
|
+
],
|
|
14
|
+
edges: [
|
|
15
|
+
{ from: "action:orders.place", to: "event:OrderPlaced", type: "emits" },
|
|
16
|
+
{ from: "app:orders", to: "route:POST /orders", type: "mounts" },
|
|
17
|
+
],
|
|
18
|
+
},
|
|
19
|
+
} as unknown as Manifest;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("manifestView", () => {
|
|
23
|
+
it("selects nodes by kind", () => {
|
|
24
|
+
const v = manifestView(fixture());
|
|
25
|
+
expect(v.byKind("action").map((n) => n.name)).toEqual(["orders.place"]);
|
|
26
|
+
expect(v.byKind("app")).toHaveLength(1);
|
|
27
|
+
expect(v.byKind("nope")).toEqual([]);
|
|
28
|
+
});
|
|
29
|
+
it("looks up a node by id", () => {
|
|
30
|
+
const v = manifestView(fixture());
|
|
31
|
+
expect(v.node("event:OrderPlaced")?.name).toBe("OrderPlaced");
|
|
32
|
+
expect(v.node("missing")).toBeUndefined();
|
|
33
|
+
});
|
|
34
|
+
it("filters edges by direction and type", () => {
|
|
35
|
+
const v = manifestView(fixture());
|
|
36
|
+
expect(v.edgesOf("action:orders.place", "out")).toHaveLength(1);
|
|
37
|
+
expect(v.edgesOf("event:OrderPlaced", "in")).toHaveLength(1);
|
|
38
|
+
expect(v.edgesOf("event:OrderPlaced", "out")).toHaveLength(0);
|
|
39
|
+
expect(v.edgesOf("action:orders.place", "both")).toHaveLength(1);
|
|
40
|
+
expect(v.edgesByType("emits")).toHaveLength(1);
|
|
41
|
+
expect(v.edgesByType("mounts")).toHaveLength(1);
|
|
42
|
+
});
|
|
43
|
+
it("flags a version mismatch", () => {
|
|
44
|
+
expect(manifestView(fixture()).versionMismatch).toBe(false);
|
|
45
|
+
expect(
|
|
46
|
+
manifestView({ version: 99, model: { nodes: [], edges: [] } } as unknown as Manifest)
|
|
47
|
+
.versionMismatch,
|
|
48
|
+
).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
it("tolerates a manifest with no model (empty graph, no throw)", () => {
|
|
51
|
+
const v = manifestView({} as Manifest);
|
|
52
|
+
expect(v.model.nodes).toEqual([]);
|
|
53
|
+
expect(v.byKind("action")).toEqual([]);
|
|
54
|
+
expect(v.edgesOf("x")).toEqual([]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("synthesizes a model from flat arrays when `model` is absent", () => {
|
|
58
|
+
const flat = {
|
|
59
|
+
actions: [
|
|
60
|
+
{ name: "orders.place", app: "orders", emits: ["orders.placed"], description: "Place" },
|
|
61
|
+
],
|
|
62
|
+
events: [{ name: "orders.placed", app: "orders" }],
|
|
63
|
+
queries: [{ name: "orders.by-id", projection: "orders" }],
|
|
64
|
+
plugins: [{ name: "auth" }],
|
|
65
|
+
} as unknown as Manifest;
|
|
66
|
+
const v = manifestView(flat);
|
|
67
|
+
expect(v.byKind("action").map((n) => n.name)).toEqual(["orders.place"]);
|
|
68
|
+
expect(v.byKind("plugin")).toHaveLength(1);
|
|
69
|
+
// action→event emits + query→projection reads edges are derived.
|
|
70
|
+
expect(v.edgesOf("action:orders.place", "out")).toContainEqual({
|
|
71
|
+
from: "action:orders.place",
|
|
72
|
+
to: "event:orders.placed",
|
|
73
|
+
type: "emits",
|
|
74
|
+
});
|
|
75
|
+
expect(v.edgesOf("query:orders.by-id", "out")).toContainEqual({
|
|
76
|
+
from: "query:orders.by-id",
|
|
77
|
+
to: "projection:orders",
|
|
78
|
+
type: "reads",
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("prefers the deep model when present (does not synthesize)", () => {
|
|
83
|
+
const v = manifestView(fixture());
|
|
84
|
+
// fixture's model has app + action nodes only — no synthesized 'plugin'.
|
|
85
|
+
expect(v.byKind("query")).toEqual([]);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { flattenMetadata } from "../metadata";
|
|
3
|
+
|
|
4
|
+
describe("flattenMetadata", () => {
|
|
5
|
+
it("flattens nested objects to dot-notation entries", () => {
|
|
6
|
+
const entries = flattenMetadata({
|
|
7
|
+
kind: "action.completed",
|
|
8
|
+
envelope: { messageId: "m1", correlationId: "c1" },
|
|
9
|
+
});
|
|
10
|
+
const map = Object.fromEntries(entries.map((e) => [e.path, e.value]));
|
|
11
|
+
expect(map.kind).toBe("action.completed");
|
|
12
|
+
expect(map["envelope.messageId"]).toBe("m1");
|
|
13
|
+
expect(map["envelope.correlationId"]).toBe("c1");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("indexes arrays by position", () => {
|
|
17
|
+
const entries = flattenMetadata({ items: [{ id: "a" }, { id: "b" }] });
|
|
18
|
+
const paths = entries.map((e) => e.path);
|
|
19
|
+
expect(paths).toContain("items.0.id");
|
|
20
|
+
expect(paths).toContain("items.1.id");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("tags each entry with its value type", () => {
|
|
24
|
+
const entries = flattenMetadata({ s: "x", n: 3, b: true, z: null });
|
|
25
|
+
const byPath = Object.fromEntries(entries.map((e) => [e.path, e.type]));
|
|
26
|
+
expect(byPath.s).toBe("string");
|
|
27
|
+
expect(byPath.n).toBe("number");
|
|
28
|
+
expect(byPath.b).toBe("boolean");
|
|
29
|
+
expect(byPath.z).toBe("null");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("drops undefined and functions", () => {
|
|
33
|
+
const entries = flattenMetadata({ keep: 1, gone: undefined, fn: () => 0 });
|
|
34
|
+
expect(entries.map((e) => e.path)).toEqual(["keep"]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("guards against cycles", () => {
|
|
38
|
+
const a: Record<string, unknown> = { name: "a" };
|
|
39
|
+
a.self = a;
|
|
40
|
+
const entries = flattenMetadata(a);
|
|
41
|
+
expect(entries.some((e) => e.value === "[circular]")).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("wraps a bare primitive under `value`", () => {
|
|
45
|
+
expect(flattenMetadata("hi")).toEqual([{ path: "value", value: "hi", type: "string" }]);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { nodeIdOf, rollupMetrics, edgeActivity, fmtCount, fmtLatency } from "../node-metrics";
|
|
3
|
+
import type { TelemetryRecord } from "../telemetry";
|
|
4
|
+
|
|
5
|
+
const rec = (r: Partial<TelemetryRecord> & { kind: string }): TelemetryRecord =>
|
|
6
|
+
r as TelemetryRecord;
|
|
7
|
+
|
|
8
|
+
describe("node-metrics: nodeIdOf", () => {
|
|
9
|
+
it("maps each kind to its graph node id via the kind-specific name field", () => {
|
|
10
|
+
expect(nodeIdOf(rec({ kind: "action.completed", action: "moderation.submit-post" }))).toBe(
|
|
11
|
+
"action:moderation.submit-post",
|
|
12
|
+
);
|
|
13
|
+
expect(nodeIdOf(rec({ kind: "query.executed", query: "moderation.list-pending" }))).toBe(
|
|
14
|
+
"query:moderation.list-pending",
|
|
15
|
+
);
|
|
16
|
+
expect(nodeIdOf(rec({ kind: "projection.folded", projection: "queue-dashboard" }))).toBe(
|
|
17
|
+
"projection:queue-dashboard",
|
|
18
|
+
);
|
|
19
|
+
expect(nodeIdOf(rec({ kind: "actor.transitioned", actor: "post" }))).toBe("actor:post");
|
|
20
|
+
expect(nodeIdOf(rec({ kind: "reaction.fired", workflow: "auto-moderate" }))).toBe(
|
|
21
|
+
"workflow:auto-moderate",
|
|
22
|
+
);
|
|
23
|
+
expect(nodeIdOf(rec({ kind: "external.call.completed", call: "stripe.charge" }))).toBe(
|
|
24
|
+
"externalCall:stripe.charge",
|
|
25
|
+
);
|
|
26
|
+
expect(nodeIdOf(rec({ kind: "dlq.recorded", action: "moderation.submit-post" }))).toBe(
|
|
27
|
+
"action:moderation.submit-post",
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("reads the event name from a string or an EventMessage object", () => {
|
|
32
|
+
// event.emitted / listener.fired stamp a string name.
|
|
33
|
+
expect(nodeIdOf(rec({ kind: "event.emitted", event: "moderation.post-was-submitted" }))).toBe(
|
|
34
|
+
"event:moderation.post-was-submitted",
|
|
35
|
+
);
|
|
36
|
+
expect(nodeIdOf(rec({ kind: "listener.fired", event: "moderation.post-was-approved" }))).toBe(
|
|
37
|
+
"event:moderation.post-was-approved",
|
|
38
|
+
);
|
|
39
|
+
// event.published stamps an EventMessage object carrying `eventName`
|
|
40
|
+
// (forge's wire shape) — or `name` on other event-message shapes.
|
|
41
|
+
expect(
|
|
42
|
+
nodeIdOf(
|
|
43
|
+
rec({ kind: "event.published", event: { eventName: "moderation.post-was-rejected" } }),
|
|
44
|
+
),
|
|
45
|
+
).toBe("event:moderation.post-was-rejected");
|
|
46
|
+
expect(
|
|
47
|
+
nodeIdOf(rec({ kind: "event.deduped", event: { name: "moderation.post-was-rejected" } })),
|
|
48
|
+
).toBe("event:moderation.post-was-rejected");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("returns null for stage/hook records that name no primitive", () => {
|
|
52
|
+
expect(nodeIdOf(rec({ kind: "hook.step", hookName: "publish" }))).toBeNull();
|
|
53
|
+
expect(nodeIdOf(rec({ kind: "source.stage", stage: "auth" }))).toBeNull();
|
|
54
|
+
expect(nodeIdOf(rec({ kind: "sink.stage", stage: "outbox" }))).toBeNull();
|
|
55
|
+
expect(nodeIdOf(rec({ kind: "action.completed" }))).toBeNull(); // no name field
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("node-metrics: rollupMetrics", () => {
|
|
60
|
+
it("returns an empty rollup for no records (neutral-placeholder state)", () => {
|
|
61
|
+
const r = rollupMetrics([]);
|
|
62
|
+
expect(r.total).toBe(0);
|
|
63
|
+
expect(r.byNode.size).toBe(0);
|
|
64
|
+
expect(r.byApp.size).toBe(0);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("counts throughput, errors, and p50 latency per node", () => {
|
|
68
|
+
const r = rollupMetrics([
|
|
69
|
+
rec({ kind: "action.completed", action: "a.x", durationMs: 10, appName: "a", ts: "1" }),
|
|
70
|
+
rec({ kind: "action.completed", action: "a.x", durationMs: 30, appName: "a", ts: "2" }),
|
|
71
|
+
rec({
|
|
72
|
+
kind: "action.failed",
|
|
73
|
+
action: "a.x",
|
|
74
|
+
durationMs: 20,
|
|
75
|
+
appName: "a",
|
|
76
|
+
ts: "3",
|
|
77
|
+
error: { name: "E", message: "boom" },
|
|
78
|
+
}),
|
|
79
|
+
]);
|
|
80
|
+
const m = r.byNode.get("action:a.x")!;
|
|
81
|
+
expect(m.count).toBe(3);
|
|
82
|
+
expect(m.errors).toBe(1);
|
|
83
|
+
expect(m.p50).toBe(20); // median of [10,20,30]
|
|
84
|
+
expect(m.lastTs).toBe("3");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("rolls dispatches/errors/total up per app", () => {
|
|
88
|
+
const r = rollupMetrics([
|
|
89
|
+
rec({ kind: "action.dispatched", action: "a.x", appName: "alpha", ts: "1" }),
|
|
90
|
+
rec({ kind: "query.executed", query: "a.q", appName: "alpha", ts: "2" }),
|
|
91
|
+
rec({ kind: "event.emitted", event: "a.e", appName: "alpha", ts: "3" }),
|
|
92
|
+
rec({
|
|
93
|
+
kind: "action.failed",
|
|
94
|
+
action: "a.x",
|
|
95
|
+
appName: "alpha",
|
|
96
|
+
ts: "4",
|
|
97
|
+
error: { name: "E", message: "x" },
|
|
98
|
+
}),
|
|
99
|
+
]);
|
|
100
|
+
const m = r.byApp.get("alpha")!;
|
|
101
|
+
expect(m.dispatches).toBe(2); // action.dispatched + query.executed
|
|
102
|
+
expect(m.errors).toBe(1);
|
|
103
|
+
expect(m.total).toBe(4);
|
|
104
|
+
expect(m.lastTs).toBe("4");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("is deterministic — same records yield the same rollup", () => {
|
|
108
|
+
const recs = [
|
|
109
|
+
rec({ kind: "action.completed", action: "a.x", durationMs: 5, appName: "a", ts: "1" }),
|
|
110
|
+
rec({ kind: "query.executed", query: "a.q", durationMs: 7, appName: "a", ts: "2" }),
|
|
111
|
+
];
|
|
112
|
+
expect(rollupMetrics(recs)).toEqual(rollupMetrics([...recs]));
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("node-metrics: edgeActivity", () => {
|
|
117
|
+
it("attributes the destination node's volume to the edge and flags it active", () => {
|
|
118
|
+
const byNode = new Map([
|
|
119
|
+
["event:a.e", { count: 4, errors: 0 }],
|
|
120
|
+
["action:a.x", { count: 0, errors: 0 }],
|
|
121
|
+
]);
|
|
122
|
+
const act = edgeActivity(byNode, [
|
|
123
|
+
{ id: "action:a.x=>event:a.e", from: "action:a.x", to: "event:a.e" },
|
|
124
|
+
{ id: "event:a.e=>action:a.x", from: "event:a.e", to: "action:a.x" },
|
|
125
|
+
]);
|
|
126
|
+
expect(act.get("action:a.x=>event:a.e")).toEqual({ volume: 4, active: true });
|
|
127
|
+
expect(act.get("event:a.e=>action:a.x")).toEqual({ volume: 0, active: false });
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe("node-metrics: formatters", () => {
|
|
132
|
+
it("fmtCount shows a dim em dash for zero/undefined, the number otherwise", () => {
|
|
133
|
+
expect(fmtCount(undefined)).toBe("—");
|
|
134
|
+
expect(fmtCount(0)).toBe("—");
|
|
135
|
+
expect(fmtCount(7)).toBe("7");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("fmtLatency formats sub-ms, ms, and seconds; '—' when none", () => {
|
|
139
|
+
expect(fmtLatency(undefined)).toBe("—");
|
|
140
|
+
expect(fmtLatency(0.4)).toBe("<1ms");
|
|
141
|
+
expect(fmtLatency(12)).toBe("12ms");
|
|
142
|
+
expect(fmtLatency(1234)).toBe("1.2s");
|
|
143
|
+
});
|
|
144
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
parseEnvInput,
|
|
4
|
+
sortScripts,
|
|
5
|
+
statusTone,
|
|
6
|
+
shortId,
|
|
7
|
+
timeAgo,
|
|
8
|
+
isActive,
|
|
9
|
+
activeProcess,
|
|
10
|
+
type ManagedProcess,
|
|
11
|
+
} from "../operate";
|
|
12
|
+
|
|
13
|
+
describe("parseEnvInput", () => {
|
|
14
|
+
it("parses KEY=value lines, skipping blanks + comments", () => {
|
|
15
|
+
expect(parseEnvInput("A=1\n\n# c\nB=two")).toEqual({ A: "1", B: "two" });
|
|
16
|
+
});
|
|
17
|
+
it("strips matching surrounding quotes", () => {
|
|
18
|
+
expect(parseEnvInput(`A="x y"\nB='z'`)).toEqual({ A: "x y", B: "z" });
|
|
19
|
+
});
|
|
20
|
+
it("rejects malformed keys", () => {
|
|
21
|
+
expect(parseEnvInput("1BAD=x\n=y\nGOOD=ok")).toEqual({ GOOD: "ok" });
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("sortScripts", () => {
|
|
26
|
+
it("puts nwire-prefixed first, each group alphabetical", () => {
|
|
27
|
+
const out = sortScripts([
|
|
28
|
+
{ name: "test", command: "vitest" },
|
|
29
|
+
{ name: "nwire:dev", command: "nwire dev" },
|
|
30
|
+
{ name: "build", command: "tsc" },
|
|
31
|
+
{ name: "nwire:cache", command: "nwire cache" },
|
|
32
|
+
]);
|
|
33
|
+
expect(out.map((s) => s.name)).toEqual(["nwire:cache", "nwire:dev", "build", "test"]);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("statusTone", () => {
|
|
38
|
+
it("maps process status to a StatusBadge tone", () => {
|
|
39
|
+
expect(statusTone("running")).toBe("live");
|
|
40
|
+
expect(statusTone("starting")).toBe("warn");
|
|
41
|
+
expect(statusTone("stopping")).toBe("warn");
|
|
42
|
+
expect(statusTone("crashed")).toBe("error");
|
|
43
|
+
expect(statusTone("exited")).toBe("idle");
|
|
44
|
+
expect(statusTone("idle")).toBe("idle");
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("shortId", () => {
|
|
49
|
+
it("takes the first uuid segment", () => {
|
|
50
|
+
expect(shortId("abc12345-de-fg")).toBe("abc12345");
|
|
51
|
+
expect(shortId("plainid")).toBe("plainid");
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("timeAgo", () => {
|
|
56
|
+
const base = Date.parse("2026-06-18T12:00:00.000Z");
|
|
57
|
+
it("formats seconds / minutes / hours", () => {
|
|
58
|
+
expect(timeAgo("2026-06-18T11:59:50.000Z", base)).toBe("10s ago");
|
|
59
|
+
expect(timeAgo("2026-06-18T11:55:00.000Z", base)).toBe("5m ago");
|
|
60
|
+
expect(timeAgo("2026-06-18T10:00:00.000Z", base)).toBe("2h ago");
|
|
61
|
+
});
|
|
62
|
+
it("never goes negative", () => {
|
|
63
|
+
expect(timeAgo("2026-06-18T12:00:30.000Z", base)).toBe("0s ago");
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const proc = (over: Partial<ManagedProcess>): ManagedProcess => ({
|
|
68
|
+
id: "x",
|
|
69
|
+
topology: "t",
|
|
70
|
+
startedAt: "2026-01-01T00:00:00Z",
|
|
71
|
+
status: "idle",
|
|
72
|
+
...over,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("isActive", () => {
|
|
76
|
+
it("is true for running / starting / stopping", () => {
|
|
77
|
+
expect(isActive("running")).toBe(true);
|
|
78
|
+
expect(isActive("starting")).toBe(true);
|
|
79
|
+
expect(isActive("stopping")).toBe(true);
|
|
80
|
+
expect(isActive("exited")).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("activeProcess", () => {
|
|
85
|
+
it("returns the most-recently-started running process", () => {
|
|
86
|
+
const list = [
|
|
87
|
+
proc({ id: "a", status: "running", startedAt: "2026-01-01T00:00:00Z" }),
|
|
88
|
+
proc({ id: "b", status: "running", startedAt: "2026-01-02T00:00:00Z" }),
|
|
89
|
+
proc({ id: "c", status: "exited", startedAt: "2026-01-03T00:00:00Z" }),
|
|
90
|
+
];
|
|
91
|
+
expect(activeProcess(list)?.id).toBe("b");
|
|
92
|
+
});
|
|
93
|
+
it("returns undefined when nothing is running", () => {
|
|
94
|
+
expect(activeProcess([proc({ status: "exited" })])).toBeUndefined();
|
|
95
|
+
expect(activeProcess([])).toBeUndefined();
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { pipelineFlow, isStageRecord, type StageRecord } from "../pipeline-flow";
|
|
3
|
+
|
|
4
|
+
const src = (over: Partial<StageRecord> = {}): StageRecord =>
|
|
5
|
+
({
|
|
6
|
+
kind: "source.stage",
|
|
7
|
+
stage: "s",
|
|
8
|
+
position: "early",
|
|
9
|
+
message: { name: "orders.place", kind: "command" },
|
|
10
|
+
correlationId: "c1",
|
|
11
|
+
shortCircuited: false,
|
|
12
|
+
durationMs: 1,
|
|
13
|
+
appName: "app",
|
|
14
|
+
ts: "2026-06-16T00:00:00.000Z",
|
|
15
|
+
...(over as object),
|
|
16
|
+
}) as StageRecord;
|
|
17
|
+
|
|
18
|
+
const sink = (over: Partial<StageRecord> = {}): StageRecord =>
|
|
19
|
+
({
|
|
20
|
+
kind: "sink.stage",
|
|
21
|
+
stage: "k",
|
|
22
|
+
position: "terminal",
|
|
23
|
+
event: "order.placed",
|
|
24
|
+
correlationId: "c1",
|
|
25
|
+
shortCircuited: false,
|
|
26
|
+
durationMs: 1,
|
|
27
|
+
appName: "app",
|
|
28
|
+
ts: "2026-06-16T00:00:00.000Z",
|
|
29
|
+
...(over as object),
|
|
30
|
+
}) as StageRecord;
|
|
31
|
+
|
|
32
|
+
describe("isStageRecord", () => {
|
|
33
|
+
it("matches only the two stage kinds", () => {
|
|
34
|
+
expect(isStageRecord({ kind: "source.stage" })).toBe(true);
|
|
35
|
+
expect(isStageRecord({ kind: "sink.stage" })).toBe(true);
|
|
36
|
+
expect(isStageRecord({ kind: "action.dispatched" })).toBe(false);
|
|
37
|
+
expect(isStageRecord({ kind: "hook.step" })).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("pipelineFlow", () => {
|
|
42
|
+
it("returns empty flows when no correlation is selected", () => {
|
|
43
|
+
expect(pipelineFlow([src()], undefined)).toEqual({ inbound: [], outbound: [] });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("splits inbound (source) from outbound (sink) for the correlation", () => {
|
|
47
|
+
const flow = pipelineFlow([src(), sink()], "c1");
|
|
48
|
+
expect(flow.inbound.map((r) => r.stage)).toEqual(["s"]);
|
|
49
|
+
expect(flow.outbound.map((r) => r.stage)).toEqual(["k"]);
|
|
50
|
+
expect(flow.inbound[0]!.label).toBe("orders.place");
|
|
51
|
+
expect(flow.outbound[0]!.label).toBe("order.placed");
|
|
52
|
+
expect(flow.inbound[0]!.direction).toBe("inbound");
|
|
53
|
+
expect(flow.outbound[0]!.direction).toBe("outbound");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("excludes records from other correlations", () => {
|
|
57
|
+
const flow = pipelineFlow([src(), src({ correlationId: "other" })], "c1");
|
|
58
|
+
expect(flow.inbound).toHaveLength(1);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("orders inbound by position (early → middle → terminal) then ts", () => {
|
|
62
|
+
const flow = pipelineFlow(
|
|
63
|
+
[
|
|
64
|
+
src({ stage: "term", position: "terminal", ts: "2026-06-16T00:00:01.000Z" }),
|
|
65
|
+
src({ stage: "early-b", position: "early", ts: "2026-06-16T00:00:00.500Z" }),
|
|
66
|
+
src({ stage: "early-a", position: "early", ts: "2026-06-16T00:00:00.100Z" }),
|
|
67
|
+
src({ stage: "mid", position: "middle", ts: "2026-06-16T00:00:00.800Z" }),
|
|
68
|
+
],
|
|
69
|
+
"c1",
|
|
70
|
+
);
|
|
71
|
+
expect(flow.inbound.map((r) => r.stage)).toEqual(["early-a", "early-b", "mid", "term"]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("carries the short-circuit flag and duration through", () => {
|
|
75
|
+
const flow = pipelineFlow([sink({ shortCircuited: true, durationMs: 12 })], "c1");
|
|
76
|
+
expect(flow.outbound[0]!.shortCircuited).toBe(true);
|
|
77
|
+
expect(flow.outbound[0]!.durationMs).toBe(12);
|
|
78
|
+
});
|
|
79
|
+
});
|