/** * Plugins page tests — native topology. Verifies: * 1. The empty-state renders when the topology has zero plugins. * 2. A populated topology lists plugins, and /plugins?name= preselects one, * surfacing its lifecycle hooks with a cross-link to /hooks?name=. * The SSE stream is stubbed (FakeEventSource) so jsdom opens no real connection. */ import { describe, it, expect, beforeEach, vi } from "vitest"; import { mount, flushPromises } from "@vue/test-utils"; import { createRouter, createMemoryHistory } from "vue-router"; import { VueQueryPlugin } from "@tanstack/vue-query"; import Plugins from "../Plugins.vue"; class FakeEventSource { onopen: (() => void) | null = null; onerror: (() => void) | null = null; onmessage: ((m: { data: string }) => void) | null = null; constructor(public readonly url: string) { queueMicrotask(() => this.onerror?.()); } close(): void {} } const EMPTY_TOPOLOGY = { hooks: [], plugins: [] }; const POPULATED_TOPOLOGY = { plugins: [{ name: "auth" }, { name: "storage" }], hooks: [ { name: "plugin.boot:auth", chain: 2, listeners: 1 }, { name: "plugin.shutdown:auth", chain: 1, listeners: 0 }, { name: "plugin.boot:storage", chain: 1, listeners: 0 }, ], capabilities: [], ctxByKind: {}, }; function manifestWith(topology: object) { return { version: 3, generatedAt: new Date().toISOString(), apps: [], actions: [], events: [], actors: [], projections: [], queries: [], resolvers: [], routes: [], workflows: [], externalCalls: [], inboundWebhooks: [], outboxes: [], inboxes: [], crons: [], graph: { events: [] }, topology, model: { nodes: [], edges: [] }, }; } function stubManifest(topology: object): void { (globalThis as unknown as { EventSource: typeof FakeEventSource }).EventSource = FakeEventSource; globalThis.fetch = vi.fn((url: string | URL) => { if (String(url).includes("/__nwire/manifest.json")) { return Promise.resolve(new Response(JSON.stringify(manifestWith(topology)), { status: 200 })); } return Promise.resolve(new Response("", { status: 404 })); }) as typeof fetch; } beforeEach(() => { stubManifest(EMPTY_TOPOLOGY); }); function makeRouter() { return createRouter({ history: createMemoryHistory(), routes: [ { path: "/plugins", name: "plugins", component: Plugins }, { path: "/hooks", name: "hooks", component: { template: "
" } }, ], }); } describe("Plugins", () => { it("renders the empty state when the topology has no plugins", async () => { const router = makeRouter(); const wrapper = mount(Plugins, { global: { plugins: [router, VueQueryPlugin] } }); await flushPromises(); await flushPromises(); expect(wrapper.find("[data-testid=plugins-page]").exists()).toBe(true); expect(wrapper.find("[data-testid=plugins-empty]").exists()).toBe(true); // No list / detail / tap-list when empty. expect(wrapper.find("[data-testid=plugins-list]").exists()).toBe(false); expect(wrapper.find("[data-testid=plugins-detail]").exists()).toBe(false); }); it("lists plugins from native topology", async () => { stubManifest(POPULATED_TOPOLOGY); const router = makeRouter(); const wrapper = mount(Plugins, { global: { plugins: [router, VueQueryPlugin] } }); await flushPromises(); await flushPromises(); expect(wrapper.find("[data-testid=plugins-empty]").exists()).toBe(false); expect(wrapper.find("[data-testid=plugins-list]").exists()).toBe(true); expect(wrapper.text()).toContain("auth"); expect(wrapper.text()).toContain("storage"); }); it("preselects via /plugins?name= and shows lifecycle hooks linking to /hooks", async () => { stubManifest(POPULATED_TOPOLOGY); const router = makeRouter(); await router.push("/plugins?name=auth"); const wrapper = mount(Plugins, { global: { plugins: [router, VueQueryPlugin] } }); await flushPromises(); await flushPromises(); const detail = wrapper.find("[data-testid=plugins-detail]"); expect(detail.exists()).toBe(true); // The two auth lifecycle hooks render as cross-links to /hooks. expect(wrapper.find("[data-testid='hook-link-plugin.boot:auth']").exists()).toBe(true); expect(wrapper.find("[data-testid='hook-link-plugin.shutdown:auth']").exists()).toBe(true); await wrapper.find("[data-testid='hook-link-plugin.boot:auth']").trigger("click"); await flushPromises(); expect(router.currentRoute.value.path).toBe("/hooks"); expect(router.currentRoute.value.query.name).toBe("plugin.boot:auth"); }); });