import { describe, it, expect } from "vitest"; import { manifestView, type Manifest } from "../manifest"; import { buildAppTopology, buildAppFlowEdges, layoutAppTopology, buildWiringGraph, layoutWiringGraph, buildEndpoints, } from "../topology-graph"; /** A tolerant view over a flat (model-less) manifest — the common shipped shape. */ function viewOf(partial: Record) { return manifestView({ ...partial } as unknown as Manifest); } describe("topology-graph: buildAppTopology", () => { it("clusters per app with merged plugins, sinks, and per-app counts", () => { const view = viewOf({ apps: [ { name: "billing", plugins: ["forge"] }, { name: "moderation", plugins: [] }, ], plugins: [ { name: "auth", app: "billing" }, { name: "forge", app: "billing" }, // dup of the inline list — de-duped ], sinks: [ { name: "queue", app: "billing", position: "terminal", kind: "bullmq" }, { name: "tap", app: "billing", position: "early", kind: "capture" }, ], actions: [ { name: "billing.charge", app: "billing" }, { name: "moderation.submit", app: "moderation" }, ], events: [{ name: "billing.charged", app: "billing" }], projections: [{ name: "moderation.queue", app: "moderation" }], }); const apps = buildAppTopology(view); expect(apps.map((a) => a.name)).toEqual(["billing", "moderation"]); // name-sorted const billing = apps[0]!; expect(billing.plugins).toEqual(["auth", "forge"]); // merged + de-duped + sorted expect(billing.actions).toBe(1); expect(billing.events).toBe(1); expect(billing.projections).toBe(0); // Sinks ordered early → terminal. expect(billing.sinks.map((s) => s.position)).toEqual(["early", "terminal"]); expect(billing.sinks[0]!.label).toBe("early · capture"); const moderation = apps[1]!; expect(moderation.plugins).toEqual([]); expect(moderation.actions).toBe(1); expect(moderation.projections).toBe(1); }); it("buckets unscoped primitives under (unscoped)", () => { const view = viewOf({ apps: [], actions: [{ name: "orphan" }] }); const apps = buildAppTopology(view); expect(apps.map((a) => a.name)).toEqual(["(unscoped)"]); expect(apps[0]!.actions).toBe(1); }); it("returns [] for a null view", () => { expect(buildAppTopology(null)).toEqual([]); }); }); describe("topology-graph: buildAppFlowEdges", () => { it("collapses the flat event graph into one cross-app edge per pair, dropping in-app edges", () => { const view = viewOf({ actions: [ { name: "orders.place", app: "orders" }, { name: "billing.charge", app: "billing" }, ], events: [ { name: "orders.placed", app: "orders" }, { name: "billing.charged", app: "billing" }, ], graph: { events: [ // in-app: action → its own event — dropped. { from: "orders.place", to: "orders.placed", via: "emits" }, // cross-app: orders' event consumed in billing. { from: "orders.placed", to: "billing.charge", via: "listens" }, ], }, }); const edges = buildAppFlowEdges(view); expect(edges).toHaveLength(1); expect(edges[0]).toMatchObject({ from: "orders", to: "billing", label: "orders.placed" }); }); it("prefers the deep model edges (resolving node ids) over the flat graph", () => { const view = viewOf({ actions: [{ name: "billing.charge", app: "billing" }], events: [{ name: "orders.placed", app: "orders" }], model: { nodes: [ { id: "event:orders.placed", kind: "event", name: "orders.placed" }, { id: "action:billing.charge", kind: "action", name: "billing.charge" }, ], edges: [{ from: "event:orders.placed", to: "action:billing.charge", type: "listens" }], }, // A flat graph that, if used, would yield a different edge — proves precedence. graph: { events: [{ from: "noise", to: "noise", via: "x" }] }, }); const edges = buildAppFlowEdges(view); expect(edges).toEqual([{ from: "orders", to: "billing", label: "orders.placed" }]); }); it("returns [] for a null view or no resolvable endpoints", () => { expect(buildAppFlowEdges(null)).toEqual([]); expect(buildAppFlowEdges(viewOf({ graph: { events: [{ from: "a", to: "b" }] } }))).toEqual([]); }); }); describe("topology-graph: layoutAppTopology", () => { const app = (name: string, plugins: string[] = [], sinks = 0) => ({ name, plugins, sinks: Array.from({ length: sinks }, (_, i) => ({ label: `s${i}` })), actions: 0, events: 0, projections: 0, }); it("places cards in a grid with deterministic positions", () => { const nodes = layoutAppTopology([app("a"), app("b"), app("c"), app("d")], { columns: 3, width: 200, gapX: 50, originX: 0, originY: 0, }); // First row: three columns. expect(nodes[0]!).toMatchObject({ x: 0, y: 0 }); expect(nodes[1]!).toMatchObject({ x: 250 }); expect(nodes[2]!).toMatchObject({ x: 500 }); // Fourth card wraps to the next row. expect(nodes[3]!.x).toBe(0); expect(nodes[3]!.y).toBeGreaterThan(0); }); it("grows card height with plugin + sink count", () => { const [small, tall] = layoutAppTopology([app("a"), app("b", ["p1", "p2"], 2)]); expect(tall!.height).toBeGreaterThan(small!.height); }); it("returns [] for no apps", () => { expect(layoutAppTopology([])).toEqual([]); }); }); /** A deep-model view — the wiring selectors read `model` directly. */ function modelView(nodes: unknown[], edges: unknown[], extra: Record = {}) { return manifestView({ ...extra, model: { nodes, edges } } as unknown as Manifest); } describe("topology-graph: buildWiringGraph", () => { // route → action → event → projection/workflow, workflow → action (back-edge). const nodes = [ { id: "route:GET /posts", kind: "route", name: "GET /posts", data: { app: "moderation" } }, { id: "action:moderation.submit", kind: "action", name: "moderation.submit" }, { id: "query:moderation.list", kind: "query", name: "moderation.list" }, { id: "event:moderation.submitted", kind: "event", name: "moderation.submitted" }, { id: "projection:queue", kind: "projection", name: "queue" }, { id: "workflow:auto", kind: "workflow", name: "auto" }, { id: "sinkStage:outbox", kind: "sinkStage", name: "outbox" }, // not a pipeline kind — excluded. { id: "plugin:forge", kind: "plugin", name: "forge" }, ]; const edges = [ { from: "route:GET /posts", to: "action:moderation.submit", type: "triggers" }, { from: "action:moderation.submit", to: "event:moderation.submitted", type: "emits" }, { from: "event:moderation.submitted", to: "projection:queue", type: "delivers" }, { from: "event:moderation.submitted", to: "workflow:auto", type: "delivers" }, { from: "query:moderation.list", to: "projection:queue", type: "reads" }, { from: "workflow:auto", to: "action:moderation.submit", type: "dispatches" }, // back-edge { from: "event:moderation.submitted", to: "sinkStage:outbox", type: "delivers" }, // infra edge — excluded. { from: "capability:ctx", to: "action:moderation.submit", type: "provides" }, ]; it("lays nodes into source→handler→effect→sink lanes by kind", () => { const g = buildWiringGraph(modelView(nodes, edges)); const lane = (id: string) => g.nodes.find((n) => n.id === id)?.layer; expect(lane("route:GET /posts")).toBe("source"); expect(lane("action:moderation.submit")).toBe("handler"); expect(lane("query:moderation.list")).toBe("handler"); expect(lane("event:moderation.submitted")).toBe("effect"); expect(lane("projection:queue")).toBe("effect"); expect(lane("workflow:auto")).toBe("effect"); expect(lane("sinkStage:outbox")).toBe("sink"); // The plugin node is not a pipeline kind. expect(g.nodes.find((n) => n.id === "plugin:forge")).toBeUndefined(); }); it("keeps only wiring edge types and flags right→left back-edges", () => { const g = buildWiringGraph(modelView(nodes, edges)); // The `provides` infra edge is dropped. expect(g.edges.some((e) => e.type === "provides")).toBe(false); const dispatch = g.edges.find((e) => e.type === "dispatches"); expect(dispatch?.back).toBe(true); // effect (workflow) → handler (action) const emit = g.edges.find((e) => e.type === "emits"); expect(emit?.back).toBe(false); // handler → effect }); it("derives a node's app from data.app, else the dotted-name head", () => { const g = buildWiringGraph(modelView(nodes, edges)); expect(g.nodes.find((n) => n.id === "route:GET /posts")?.app).toBe("moderation"); // action has no data.app → "moderation" from "moderation.submit". expect(g.nodes.find((n) => n.id === "action:moderation.submit")?.app).toBe("moderation"); // single-word nodes (queue, auto, outbox) fall back to (unscoped). expect(g.apps).toContain("moderation"); }); it("scopes to one app when filtered, dropping cross-app nodes", () => { const two = [ { id: "action:a.one", kind: "action", name: "a.one" }, { id: "action:b.two", kind: "action", name: "b.two" }, ]; const g = buildWiringGraph(modelView(two, []), "a"); expect(g.nodes.map((n) => n.id)).toEqual(["action:a.one"]); }); it("returns an empty graph for a null view", () => { expect(buildWiringGraph(null)).toEqual({ nodes: [], edges: [], apps: [] }); }); }); describe("topology-graph: layoutWiringGraph", () => { const g = (kinds: Array<[string, string]>) => ({ nodes: kinds.map(([id, kind]) => ({ id, kind, name: id, layer: kind === "route" ? ("source" as const) : kind === "action" ? ("handler" as const) : kind === "event" ? ("effect" as const) : ("sink" as const), app: "x", })), edges: [], apps: ["x"], }); it("places lanes left→right by layer, stacking within a lane", () => { const laid = layoutWiringGraph( g([ ["route:r", "route"], ["action:a1", "action"], ["action:a2", "action"], ["event:e", "event"], ]), { nodeWidth: 200, laneGap: 100 }, ); const x = (id: string) => laid.find((n) => n.id === id)!.x; // source lane is left of handler lane is left of effect lane. expect(x("route:r")).toBeLessThan(x("action:a1")); expect(x("action:a1")).toBeLessThan(x("event:e")); // two handlers share a column, stacked vertically. expect(x("action:a1")).toBe(x("action:a2")); const ay = laid.find((n) => n.id === "action:a1")!.y; const a2y = laid.find((n) => n.id === "action:a2")!.y; expect(a2y).toBeGreaterThan(ay); }); it("collapses empty lanes — no gap where a layer has no nodes", () => { // No handler or effect lane: source sits directly beside sink. const laid = layoutWiringGraph( g([ ["route:r", "route"], ["sinkStage:s", "sink"], ]), { nodeWidth: 200, laneGap: 100 }, ); expect(laid.find((n) => n.id === "sinkStage:s")!.x).toBe(300); }); it("returns [] for an empty graph", () => { expect(layoutWiringGraph({ nodes: [], edges: [], apps: [] })).toEqual([]); }); }); describe("topology-graph: buildEndpoints", () => { it("lists routes as METHOD /path → handler, resolving the triggers edge", () => { const nodes = [ { id: "route:GET /posts", kind: "route", name: "GET /posts", data: { method: "get", path: "/posts", app: "moderation" }, }, { id: "action:moderation.submit", kind: "action", name: "moderation.submit" }, ]; const edges = [{ from: "route:GET /posts", to: "action:moderation.submit", type: "triggers" }]; const eps = buildEndpoints(modelView(nodes, edges)); expect(eps).toHaveLength(1); expect(eps[0]).toMatchObject({ method: "GET", path: "/posts", app: "moderation", handler: { id: "action:moderation.submit", kind: "action", name: "moderation.submit" }, }); }); it("parses METHOD + path from the node name when no data fields exist", () => { const eps = buildEndpoints( modelView([{ id: "route:POST /charge", kind: "route", name: "POST /charge" }], []), ); expect(eps[0]).toMatchObject({ method: "POST", path: "/charge", handler: undefined }); }); it("sorts by path then method, and returns [] for a null view", () => { const nodes = [ { id: "route:b", kind: "route", name: "GET /b" }, { id: "route:a", kind: "route", name: "GET /a" }, ]; const eps = buildEndpoints(modelView(nodes, [])); expect(eps.map((e) => e.path)).toEqual(["/a", "/b"]); expect(buildEndpoints(null)).toEqual([]); }); });