import { describe, it, expect, afterEach, vi } from "vitest"; import { pluginRegistry } from "./registry"; const TASK_SIDEBAR_SLOT = "task-sidebar"; const TASK_CREATED_ACTION = "task.created"; const APP_STATUS_LEFT_SLOT = "app-status-bar-left"; function cleanup(...pluginIds: string[]) { pluginIds.forEach((id) => pluginRegistry.unregisterPlugin(id)); } describe("pluginRegistry", () => { afterEach(() => { cleanup("plugin-a", "plugin-b"); }); it("registers and returns a route via the scoped registry view", () => { const scoped = pluginRegistry.forPlugin("plugin-a"); function Page() { return null; } scoped.registerRoute("/plugin-a", Page); const routes = pluginRegistry.getRoutes(); expect(routes).toContainEqual({ pluginId: "plugin-a", path: "/plugin-a", Component: Page, options: undefined, }); }); it("registers and returns a nav item", () => { const scoped = pluginRegistry.forPlugin("plugin-a"); scoped.registerNavItem({ id: "nav-a", label: "A", path: "/plugin-a" }); expect(pluginRegistry.getNavItems()).toContainEqual({ id: "nav-a", label: "A", path: "/plugin-a", }); }); it("reports the owning plugin for each nav item", () => { // Nav item ids are plugin-local, so navigation needs the owner to build a // unique identity (`lib/navigation/plugin-destinations.ts`). pluginRegistry.forPlugin("plugin-a").registerNavItem({ id: "nav", label: "A", path: "/a" }); pluginRegistry.forPlugin("plugin-b").registerNavItem({ id: "nav", label: "B", path: "/b" }); expect(pluginRegistry.getNavRegistrations()).toEqual([ { pluginId: "plugin-a", id: "nav", label: "A", path: "/a" }, { pluginId: "plugin-b", id: "nav", label: "B", path: "/b" }, ]); }); it("registers and returns a settings route", () => { const scoped = pluginRegistry.forPlugin("plugin-a"); function Settings() { return null; } scoped.registerSettingsRoute("/settings/plugins/plugin-a", Settings); expect(pluginRegistry.getSettingsRoutes()).toContainEqual({ path: "/settings/plugins/plugin-a", Component: Settings, }); }); it("registers a slot component and only returns it for the matching slot", () => { const scoped = pluginRegistry.forPlugin("plugin-a"); function Sidebar() { return null; } scoped.registerComponent(TASK_SIDEBAR_SLOT, Sidebar); expect(pluginRegistry.getSlotComponents(TASK_SIDEBAR_SLOT)).toEqual([Sidebar]); expect(pluginRegistry.getSlotComponents("settings-nav")).toEqual([]); }); it("returns slot registrations with stable owner and registration identity", () => { const scopedA = pluginRegistry.forPlugin("plugin-a"); function Sidebar() { return null; } scopedA.registerComponent(TASK_SIDEBAR_SLOT, Sidebar); const [registration] = pluginRegistry.getSlotRegistrations(TASK_SIDEBAR_SLOT); expect(registration).toEqual({ registrationId: expect.any(String), orderingId: expect.any(String), pluginId: "plugin-a", Component: Sidebar, }); scopedA.registerComponent(TASK_SIDEBAR_SLOT, () => null); expect(pluginRegistry.getSlotRegistrations(TASK_SIDEBAR_SLOT)[0]?.registrationId).toBe( registration?.registrationId, ); }); it("restores deterministic ordering identities after plugin re-enable", () => { function First() { return null; } function Second() { return null; } const scoped = pluginRegistry.forPlugin("plugin-a"); scoped.registerComponent(APP_STATUS_LEFT_SLOT, First); scoped.registerComponent(APP_STATUS_LEFT_SLOT, Second); const before = pluginRegistry .getSlotRegistrations(APP_STATUS_LEFT_SLOT) .map((registration) => registration.orderingId); pluginRegistry.unregisterPlugin("plugin-a"); const reenabled = pluginRegistry.forPlugin("plugin-a"); reenabled.registerComponent(APP_STATUS_LEFT_SLOT, First); reenabled.registerComponent(APP_STATUS_LEFT_SLOT, Second); expect(pluginRegistry.getSlotRegistrations(APP_STATUS_LEFT_SLOT)).toMatchObject([ { orderingId: before[0], pluginId: "plugin-a", Component: First }, { orderingId: before[1], pluginId: "plugin-a", Component: Second }, ]); expect(before[0]).not.toBe(before[1]); }); }); describe("pluginRegistry — lifecycle", () => { afterEach(() => { cleanup("plugin-a", "plugin-b"); }); it("tracks host lifecycle snapshots without extending the plugin-facing registry", () => { pluginRegistry.markPluginLoading("plugin-a", 3); expect(pluginRegistry.getPluginLifecycle("plugin-a")).toEqual({ status: "loading", generation: 3, }); expect("markPluginLoading" in pluginRegistry.forPlugin("plugin-a")).toBe(false); pluginRegistry.markPluginReady("plugin-a", 3); expect(pluginRegistry.getPluginLifecycle("plugin-a")).toEqual({ status: "ready", generation: 3, }); }); it("does not let an older generation overwrite the current lifecycle", () => { pluginRegistry.markPluginLoading("plugin-a", 4); pluginRegistry.markPluginReady("plugin-a", 3); pluginRegistry.markPluginFailed("plugin-a", 3); expect(pluginRegistry.getPluginLifecycle("plugin-a")).toEqual({ status: "loading", generation: 4, }); }); it("registers a WS handler and only returns it for the matching action", () => { const scoped = pluginRegistry.forPlugin("plugin-a"); const handler = () => {}; scoped.registerWsHandler(TASK_CREATED_ACTION, handler); expect(pluginRegistry.getWsHandlers(TASK_CREATED_ACTION)).toEqual([handler]); expect(pluginRegistry.getWsHandlers("task.deleted")).toEqual([]); }); it("bulk-revokes every registration owned by a plugin on unregisterPlugin", () => { const scopedA = pluginRegistry.forPlugin("plugin-a"); const scopedB = pluginRegistry.forPlugin("plugin-b"); function PageA() { return null; } function PageB() { return null; } scopedA.registerRoute("/plugin-a", PageA); scopedA.registerNavItem({ id: "nav-a", label: "A", path: "/plugin-a" }); scopedA.registerComponent(TASK_SIDEBAR_SLOT, PageA); scopedA.registerWsHandler(TASK_CREATED_ACTION, () => {}); scopedB.registerRoute("/plugin-b", PageB); pluginRegistry.unregisterPlugin("plugin-a"); expect(pluginRegistry.getRoutes()).toEqual([ { pluginId: "plugin-b", path: "/plugin-b", Component: PageB, options: undefined }, ]); expect(pluginRegistry.getNavItems().find((item) => item.id === "nav-a")).toBeUndefined(); expect(pluginRegistry.getSlotComponents(TASK_SIDEBAR_SLOT)).toEqual([]); expect(pluginRegistry.getWsHandlers(TASK_CREATED_ACTION)).toEqual([]); }); it("notifies subscribers when a registration is added", () => { const scoped = pluginRegistry.forPlugin("plugin-a"); let notified = 0; const unsubscribe = pluginRegistry.subscribe(() => { notified += 1; }); scoped.registerNavItem({ id: "nav-a", label: "A", path: "/plugin-a" }); unsubscribe(); expect(notified).toBe(1); }); it("does not notify subscribers when unregistering a plugin with no registrations", () => { let notified = 0; const unsubscribe = pluginRegistry.subscribe(() => { notified += 1; }); pluginRegistry.unregisterPlugin("plugin-with-nothing-registered"); unsubscribe(); expect(notified).toBe(0); }); }); describe("pluginRegistry — task panels and task menu actions", () => { afterEach(() => { cleanup("plugin-a", "plugin-b"); }); it("registers a task panel and returns it with its owning pluginId", () => { const scoped = pluginRegistry.forPlugin("plugin-a"); function Notes() { return null; } scoped.registerTaskPanel({ id: "notes", title: "Notes", icon: "file-text", Component: Notes }); expect(pluginRegistry.getTaskPanels()).toEqual([ { pluginId: "plugin-a", id: "notes", title: "Notes", icon: "file-text", Component: Notes }, ]); expect(pluginRegistry.getTaskPanel("plugin-a", "notes")).toMatchObject({ pluginId: "plugin-a", id: "notes", }); expect(pluginRegistry.getTaskPanel("plugin-a", "missing")).toBeUndefined(); }); it("registers a task menu action and filters by group", () => { const scoped = pluginRegistry.forPlugin("plugin-a"); const run = vi.fn(); scoped.registerTaskMenuAction({ id: "enhance", label: "Enhance", group: "edit", run }); expect(pluginRegistry.getTaskMenuActions("edit")).toEqual([ { pluginId: "plugin-a", id: "enhance", label: "Enhance", group: "edit", run }, ]); expect(pluginRegistry.getTaskMenuActions()).toHaveLength(1); }); it("registers a 'primary' group task menu action separately from 'edit'", () => { const scoped = pluginRegistry.forPlugin("plugin-a"); const editRun = vi.fn(); const primaryRun = vi.fn(); scoped.registerTaskMenuAction({ id: "enhance", label: "Enhance", group: "edit", run: editRun }); scoped.registerTaskMenuAction({ id: "quick-tag", label: "Tag", group: "primary", run: primaryRun, }); expect(pluginRegistry.getTaskMenuActions("edit")).toEqual([ { pluginId: "plugin-a", id: "enhance", label: "Enhance", group: "edit", run: editRun }, ]); expect(pluginRegistry.getTaskMenuActions("primary")).toEqual([ { pluginId: "plugin-a", id: "quick-tag", label: "Tag", group: "primary", run: primaryRun }, ]); expect(pluginRegistry.getTaskMenuActions()).toHaveLength(2); }); it("bulk-revokes task panels and task menu actions on unregisterPlugin", () => { const scopedA = pluginRegistry.forPlugin("plugin-a"); const scopedB = pluginRegistry.forPlugin("plugin-b"); function Notes() { return null; } scopedA.registerTaskPanel({ id: "notes", title: "Notes", Component: Notes }); scopedA.registerTaskMenuAction({ id: "enhance", label: "Enhance", group: "edit", run: () => {}, }); scopedB.registerTaskPanel({ id: "notes", title: "Notes", Component: Notes }); pluginRegistry.unregisterPlugin("plugin-a"); expect(pluginRegistry.getTaskPanels()).toEqual([ { pluginId: "plugin-b", id: "notes", title: "Notes", icon: undefined, Component: Notes }, ]); expect(pluginRegistry.getTaskMenuActions()).toEqual([]); }); }); describe("pluginRegistry — task filters", () => { afterEach(() => { cleanup("plugin-a", "plugin-b"); }); it("registers a task filter and returns it with its owning pluginId", () => { const scoped = pluginRegistry.forPlugin("plugin-a"); const getOptions = () => [{ value: "bug", label: "Bug" }]; const matches = () => true; scoped.registerTaskFilter({ id: "tags", label: "Tags", getOptions, matches }); expect(pluginRegistry.getTaskFilters()).toEqual([ { pluginId: "plugin-a", id: "tags", label: "Tags", getOptions, matches }, ]); }); it("returns task filters from multiple plugins in registration order", () => { const scopedA = pluginRegistry.forPlugin("plugin-a"); const scopedB = pluginRegistry.forPlugin("plugin-b"); scopedA.registerTaskFilter({ id: "tags", label: "Tags", getOptions: () => [], matches: () => true, }); scopedB.registerTaskFilter({ id: "priority", label: "Priority", getOptions: () => [], matches: () => true, }); expect(pluginRegistry.getTaskFilters().map((filter) => filter.id)).toEqual([ "tags", "priority", ]); }); it("bulk-revokes task filters on unregisterPlugin", () => { const scopedA = pluginRegistry.forPlugin("plugin-a"); const scopedB = pluginRegistry.forPlugin("plugin-b"); scopedA.registerTaskFilter({ id: "tags", label: "Tags", getOptions: () => [], matches: () => true, }); scopedB.registerTaskFilter({ id: "priority", label: "Priority", getOptions: () => [], matches: () => true, }); pluginRegistry.unregisterPlugin("plugin-a"); expect(pluginRegistry.getTaskFilters().map((filter) => filter.id)).toEqual(["priority"]); }); }); describe("pluginRegistry — route options and plugin names", () => { afterEach(() => { cleanup("plugin-a"); }); it("stores route options and the plugin display name for page chrome", () => { const scoped = pluginRegistry.forPlugin("plugin-a", "Plugin A"); function Page() { return null; } scoped.registerRoute("/plugin-a", Page, { topbar: { title: "Custom", icon: "ticket" } }); const route = pluginRegistry.getRoutes().find((entry) => entry.path === "/plugin-a"); expect(route?.options).toEqual({ topbar: { title: "Custom", icon: "ticket" } }); expect(pluginRegistry.getPluginName("plugin-a")).toBe("Plugin A"); }); it("clears the plugin display name on unregisterPlugin", () => { pluginRegistry.forPlugin("plugin-a", "Plugin A"); expect(pluginRegistry.getPluginName("plugin-a")).toBe("Plugin A"); pluginRegistry.unregisterPlugin("plugin-a"); expect(pluginRegistry.getPluginName("plugin-a")).toBeUndefined(); }); }); describe("pluginRegistry — keybinding handlers", () => { afterEach(() => { cleanup("plugin-a", "plugin-b"); }); it("registers a keybinding handler scoped to the owning plugin", () => { const scopedA = pluginRegistry.forPlugin("plugin-a"); const scopedB = pluginRegistry.forPlugin("plugin-b"); const handlerA = () => {}; const handlerB = () => {}; scopedA.registerKeybinding("open", handlerA); scopedB.registerKeybinding("open", handlerB); expect(pluginRegistry.getKeybindingHandler("plugin-a", "open")).toBe(handlerA); expect(pluginRegistry.getKeybindingHandler("plugin-b", "open")).toBe(handlerB); expect(pluginRegistry.getKeybindingHandlers()).toEqual([ { id: "open", handler: handlerA, pluginId: "plugin-a" }, { id: "open", handler: handlerB, pluginId: "plugin-b" }, ]); }); it("revokes keybinding handlers on unregisterPlugin", () => { const scoped = pluginRegistry.forPlugin("plugin-a"); scoped.registerKeybinding("open", () => {}); pluginRegistry.unregisterPlugin("plugin-a"); expect(pluginRegistry.getKeybindingHandlers()).toEqual([]); expect(pluginRegistry.getKeybindingHandler("plugin-a", "open")).toBeUndefined(); }); it("warns when registering a handler for an id not declared in the manifest", () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); pluginRegistry.setDeclaredKeybindingIds("plugin-a", ["open"]); const scoped = pluginRegistry.forPlugin("plugin-a"); scoped.registerKeybinding("not-declared", () => {}); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("not-declared")); // The handler is still stored despite the warning. expect(pluginRegistry.getKeybindingHandler("plugin-a", "not-declared")).toBeDefined(); warnSpy.mockRestore(); }); it("does not warn when the declared id list has not been synced yet", () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const scoped = pluginRegistry.forPlugin("plugin-a"); scoped.registerKeybinding("anything", () => {}); expect(warnSpy).not.toHaveBeenCalled(); warnSpy.mockRestore(); }); it("does not warn when the id is declared in the manifest", () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); pluginRegistry.setDeclaredKeybindingIds("plugin-a", ["open"]); const scoped = pluginRegistry.forPlugin("plugin-a"); scoped.registerKeybinding("open", () => {}); expect(warnSpy).not.toHaveBeenCalled(); warnSpy.mockRestore(); }); });