import { describe, it, expect, afterEach, vi } from "vitest"; import { pluginRegistry } from "./registry"; import type { RepositoryProviderRegistration } from "./types"; import { i18n } from "@/lib/i18n"; const TASK_SIDEBAR_SLOT = "task-sidebar"; const TASK_CREATED_ACTION = "task.created"; const APP_STATUS_LEFT_SLOT = "app-status-bar-left"; const PRIMARY_PLUGIN_ID = "plugin-a"; const SECONDARY_PLUGIN_ID = "plugin-b"; const SOURCE_CONTROL_PROVIDER_ID = "source-control"; function cleanup(...pluginIds: string[]) { pluginIds.forEach((id) => pluginRegistry.unregisterPlugin(id)); } function repositoryProvider( id: string, overrides: Partial = {}, ): RepositoryProviderRegistration { return { id, label: id, listRepositories: async () => [], matchesURL: () => false, listBranches: async () => [], inspectURL: async () => null, ...overrides, }; } 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("invalidates registration consumers when the host locale changes", async () => { const originalLocale = i18n.language; const listener = vi.fn(); const unsubscribe = pluginRegistry.subscribe(listener); try { await i18n.changeLanguage(originalLocale === "pt-pt" ? "en" : "pt-pt"); expect(listener).toHaveBeenCalled(); } finally { unsubscribe(); await i18n.changeLanguage(originalLocale); } }); it("invalidates consumers when a plugin replaces or removes its translation catalog", () => { const listener = vi.fn(); const unsubscribe = pluginRegistry.subscribe(listener); const scoped = pluginRegistry.forPlugin("plugin-a"); try { scoped.registerTranslations({ en: { greeting: "Hello" } }); expect(listener).toHaveBeenCalledTimes(1); scoped.registerTranslations({ en: { greeting: "Welcome" } }); expect(listener).toHaveBeenCalledTimes(2); pluginRegistry.unregisterPlugin("plugin-a"); expect(listener).toHaveBeenCalledTimes(3); } finally { unsubscribe(); } }); it("rolls back translations and registrations when a staged atomic commit throws", () => { const scoped = pluginRegistry.forPlugin("plugin-a"); scoped.registerTranslations({ en: { greeting: "Hello" } }); scoped.registerNavItem({ id: "nav-rollback", label: "A", path: "/rollback" }); expect(i18n.getResourceBundle("en", "plugin-plugin-a")?.greeting).toBe("Hello"); expect(() => pluginRegistry.runAtomicMutation(() => { pluginRegistry.unregisterPlugin("plugin-a"); scoped.registerNavItem({ id: "nav-committed", label: "A", path: "/committed" }); scoped.registerTranslations({ en: { greeting: "Bye" } }); throw new Error("staged commit failed"); }), ).toThrow("staged commit failed"); expect(i18n.getResourceBundle("en", "plugin-plugin-a")?.greeting).toBe("Hello"); expect(pluginRegistry.getNavItems().map((item) => item.id)).toContain("nav-rollback"); expect(pluginRegistry.getNavItems().map((item) => item.id)).not.toContain("nav-committed"); }); 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, }); }); }); describe("pluginRegistry — slots", () => { afterEach(() => { cleanup("plugin-a", "plugin-b"); }); 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("skips unreadable task menu registrations without hiding healthy actions", () => { const scoped = pluginRegistry.forPlugin("plugin-a"); const error = vi.spyOn(console, "error").mockImplementation(() => {}); const run = vi.fn(); scoped.registerTaskMenuAction({ id: "broken-group", label: "Broken group", get group(): never { throw new Error("group failed"); }, run, }); scoped.registerTaskMenuAction({ id: "broken-label", get label(): never { throw new Error("label failed"); }, group: "primary", run, }); scoped.registerTaskMenuAction({ id: "healthy", label: "Healthy", group: "primary", run }); try { expect(pluginRegistry.getTaskMenuActions("primary")).toEqual([ { pluginId: "plugin-a", id: "healthy", label: "Healthy", group: "primary", run }, ]); expect(pluginRegistry.getTaskMenuActions("primary")).toHaveLength(1); expect(error).toHaveBeenCalledTimes(2); } finally { error.mockRestore(); } }); 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 — task-list facets", () => { afterEach(() => cleanup("plugin-a", "plugin-b")); it("namespaces facet identities and revokes them with their plugin", () => { const scoped = pluginRegistry.forPlugin("plugin-a"); const getValues = () => [{ value: "bug", label: "Bug", color: "#f00" }]; scoped.registerTaskListFacet({ id: "tags", label: "Tag", getValues }); expect(pluginRegistry.getTaskListFacets()).toEqual([ { pluginId: "plugin-a", id: "tags", label: "Tag", getValues }, ]); pluginRegistry.unregisterPlugin("plugin-a"); expect(pluginRegistry.getTaskListFacets()).toEqual([]); }); it("rejects unsafe and duplicate plugin-local facet ids", () => { const scoped = pluginRegistry.forPlugin("plugin-a"); expect(() => scoped.registerTaskListFacet({ id: "Not safe", label: "Tag", getValues: () => [] }), ).toThrow("URL-safe"); scoped.registerTaskListFacet({ id: "tags", label: "Tag", getValues: () => [] }); expect(() => scoped.registerTaskListFacet({ id: "tags", label: "Tag", getValues: () => [] }), ).toThrow("already registered"); }); }); 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(); }); }); function cleanupProviderContracts() { cleanup(PRIMARY_PLUGIN_ID, SECONDARY_PLUGIN_ID); } describe("pluginRegistry — repository provider contracts", () => { afterEach(cleanupProviderContracts); it("keeps repository provider ownership with its registering plugin", () => { const provider = repositoryProvider(SOURCE_CONTROL_PROVIDER_ID); pluginRegistry.forPlugin(PRIMARY_PLUGIN_ID).registerRepositoryProvider(provider); expect(pluginRegistry.getRepositoryProvider(SOURCE_CONTROL_PROVIDER_ID)).toMatchObject({ pluginId: PRIMARY_PLUGIN_ID, id: SOURCE_CONTROL_PROVIDER_ID, label: SOURCE_CONTROL_PROVIDER_ID, }); }); it("preserves lazy provider labels so locale changes do not require plugin reload", () => { let label = "Source control"; const provider = repositoryProvider(SOURCE_CONTROL_PROVIDER_ID); Object.defineProperty(provider, "label", { enumerable: true, get: () => label, }); pluginRegistry.forPlugin(PRIMARY_PLUGIN_ID).registerRepositoryProvider(provider); expect(pluginRegistry.getRepositoryProvider(SOURCE_CONTROL_PROVIDER_ID)?.label).toBe( "Source control", ); label = "Controlo de código-fonte"; expect(pluginRegistry.getRepositoryProvider(SOURCE_CONTROL_PROVIDER_ID)?.label).toBe( "Controlo de código-fonte", ); }); it("rejects a repository provider not declared for its plugin when declarations are available", () => { pluginRegistry.setDeclaredRepositoryProviderIds(PRIMARY_PLUGIN_ID, ["declared-source-control"]); expect(() => pluginRegistry .forPlugin(PRIMARY_PLUGIN_ID) .registerRepositoryProvider(repositoryProvider("other-source-control")), ).toThrow('does not declare repository provider "other-source-control"'); }); it("rejects duplicate active provider ownership deterministically", () => { pluginRegistry .forPlugin(PRIMARY_PLUGIN_ID) .registerRepositoryProvider(repositoryProvider(SOURCE_CONTROL_PROVIDER_ID)); expect(() => pluginRegistry .forPlugin(SECONDARY_PLUGIN_ID) .registerRepositoryProvider(repositoryProvider(SOURCE_CONTROL_PROVIDER_ID)), ).toThrow( `provider "${SOURCE_CONTROL_PROVIDER_ID}" is already owned by "${PRIMARY_PLUGIN_ID}"`, ); expect(pluginRegistry.getRepositoryProvider(SOURCE_CONTROL_PROVIDER_ID)?.pluginId).toBe( PRIMARY_PLUGIN_ID, ); }); it("rejects provider IDs owned by first-party integrations", () => { expect(() => pluginRegistry .forPlugin(PRIMARY_PLUGIN_ID) .registerRepositoryProvider(repositoryProvider("github")), ).toThrow('provider "github" is reserved by the host'); }); it("rejects non-canonical provider IDs", () => { expect(() => pluginRegistry .forPlugin(PRIMARY_PLUGIN_ID) .registerRepositoryProvider(repositoryProvider("Bitbucket")), ).toThrow('provider "Bitbucket" must be a canonical lowercase identifier'); }); }); describe("pluginRegistry — task action contracts", () => { afterEach(cleanupProviderContracts); it("registers placement-aware task actions and revokes them with their owner", () => { const action = { id: "link-change", label: "Link change request", placement: "link" as const, group: "Link", run: async () => {}, }; pluginRegistry.forPlugin(PRIMARY_PLUGIN_ID).registerTaskAction(action); expect(pluginRegistry.getTaskActions("link")).toEqual([ { ...action, pluginId: PRIMARY_PLUGIN_ID }, ]); pluginRegistry.unregisterPlugin(PRIMARY_PLUGIN_ID); expect(pluginRegistry.getTaskActions("link")).toEqual([]); }); });