import { describe, it, expect, vi, afterEach } from "vitest"; import { loadPlugins, unloadPlugin } from "./host"; import { pluginModalManager } from "./modal-manager"; import { pluginRegistry } from "./registry"; import { buildPluginContextApi } from "./plugin-context-api"; import { createAppStore } from "@/lib/state/store"; import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; import type { ActivePlugin, PluginHostApi, PluginRegistry } from "./types"; /** No-op `host.toast`; these specs exercise lifecycle, never notifications. */ const NOOP_TOAST = new Proxy(() => 0, { get: () => () => 0, }) as unknown as PluginHostApi["toast"]; let mockApiBaseUrl = ""; vi.mock("@/lib/config", () => ({ getBackendConfig: () => ({ apiBaseUrl: mockApiBaseUrl }), })); const PLUGIN_SCOPE_A_ID = "plugin-scope-a"; const BUNDLE_JS_URL = "/bundle.js"; const PLUGIN_UNLOAD_A_ID = "plugin-unload-a"; const PLUGIN_UNLOAD_THROW_A_ID = "plugin-unload-throw-a"; const PLUGIN_UNLOAD_STYLE_A_ID = "plugin-unload-style-a"; function makeHostFactory(pluginId: string): PluginHostApi { const store = createAppStore(); return { pluginId, React: {} as PluginHostApi["React"], jsx: {} as PluginHostApi["jsx"], store, context: buildPluginContextApi(store), api: { fetch: async () => new Response(), invokeAction: async () => undefined as TResponse, baseUrl: "", }, i18n: { locale: "en", t: (key) => key, useTranslation: () => ({ locale: "en", t: (key) => key }), }, ui: {} as PluginHostApi["ui"], useResponsiveBreakpoint, theme: "light", onThemeChange: () => () => {}, navigate: () => {}, openModal: () => ({ close: () => {} }), openTaskLinkDialog: () => ({ close: () => {} }), openTaskReview: () => {}, toast: NOOP_TOAST, utils: { cn: () => "", generateUUID: () => "uuid", formatRelativeTime: () => "", integrationStatusRefreshMs: 90000, }, useSettingsSaveContributor: () => {}, setIntegrationEnabled: () => {}, storage: { get: async () => undefined, set: async () => ({ updatedAt: "" }), delete: async () => {}, list: async () => [], subscribe: () => () => {}, }, }; } type FakeWindow = Window & { registerKandevPlugin: (id: string, plugin: unknown) => void; }; /** Fake importer that synchronously invokes window.registerKandevPlugin, no real dynamic import. */ function fakeImporterFor( bundles: Record void>, ): (url: string) => Promise { return async (url: string) => { const run = bundles[url]; if (!run) throw new Error(`no fake bundle for ${url}`); run(window); return {}; }; } function activePlugin(overrides: Partial = {}): ActivePlugin { return { id: "plugin-a", name: "Plugin A", bundleUrl: "/api/plugins/plugin-a/bundle", ...overrides, }; } function registerFake(id: string, plugin: unknown) { (window as unknown as FakeWindow).registerKandevPlugin(id, plugin); } afterEach(() => { mockApiBaseUrl = ""; }); function cleanupBasePluginLoads() { pluginRegistry.unregisterPlugin(PLUGIN_SCOPE_A_ID); pluginRegistry.unregisterPlugin("plugin-style-a"); pluginRegistry.unregisterPlugin("plugin-throw-a"); pluginRegistry.unregisterPlugin("plugin-throw-b"); pluginRegistry.unregisterPlugin("plugin-silent-a"); document.head.querySelectorAll("link[rel='stylesheet']").forEach((el) => el.remove()); } describe("loadPlugins — initialization", () => { afterEach(cleanupBasePluginLoads); it("imports the bundle, then calls initialize(registry, host) with a registry scoped to the plugin", async () => { const initialize = vi.fn((registry: PluginRegistry, _host: PluginHostApi) => { registry.registerNavItem({ id: "nav-scope-a", label: "A", path: "/plugin-scope-a" }); }); const importer = fakeImporterFor({ "/api/plugins/plugin-scope-a/bundle": (win) => (win as unknown as FakeWindow).registerKandevPlugin(PLUGIN_SCOPE_A_ID, { initialize }), }); await loadPlugins( [activePlugin({ id: PLUGIN_SCOPE_A_ID, bundleUrl: "/api/plugins/plugin-scope-a/bundle" })], makeHostFactory, importer, ); expect(initialize).toHaveBeenCalledTimes(1); const [, host] = initialize.mock.calls[0]; expect(host.pluginId).toBe(PLUGIN_SCOPE_A_ID); expect(pluginRegistry.getNavItems()).toContainEqual({ id: "nav-scope-a", label: "A", path: "/plugin-scope-a", }); }); }); describe("loadPlugins — bundle lifecycle", () => { afterEach(cleanupBasePluginLoads); it("injects styleUrls as elements before importing the bundle", async () => { const importer = fakeImporterFor({ [BUNDLE_JS_URL]: (win) => (win as unknown as FakeWindow).registerKandevPlugin("plugin-style-a", { initialize: () => {}, }), }); await loadPlugins( [ activePlugin({ id: "plugin-style-a", bundleUrl: BUNDLE_JS_URL, styleUrls: ["/plugin-a.css"], }), ], makeHostFactory, importer, ); const link = document.head.querySelector("link[href='/plugin-a.css']"); expect(link).not.toBeNull(); }); it("isolates a throwing plugin: logs and does not stop other plugins from loading", async () => { const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); const goodInitialize = vi.fn(); const importer = fakeImporterFor({ "/bad-bundle.js": (win) => (win as unknown as FakeWindow).registerKandevPlugin("plugin-throw-a", { initialize: () => { throw new Error("boom"); }, }), "/good-bundle.js": (win) => (win as unknown as FakeWindow).registerKandevPlugin("plugin-throw-b", { initialize: goodInitialize, }), }); await loadPlugins( [ activePlugin({ id: "plugin-throw-a", bundleUrl: "/bad-bundle.js" }), activePlugin({ id: "plugin-throw-b", bundleUrl: "/good-bundle.js" }), ], makeHostFactory, importer, ); expect(errorSpy).toHaveBeenCalled(); expect(goodInitialize).toHaveBeenCalledTimes(1); errorSpy.mockRestore(); }); it("keeps registrations made before an initialize failure (spec.md:816)", async () => { const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); const pluginId = "plugin-partial-initialize"; const importer = fakeImporterFor({ "/partial-bundle.js": (win) => (win as unknown as FakeWindow).registerKandevPlugin(pluginId, { initialize: (registry: PluginRegistry) => { registry.registerNavItem({ id: "partial-nav", label: "Partial", path: "/partial" }); throw new Error("initialize failed"); }, }), }); await loadPlugins( [activePlugin({ id: pluginId, bundleUrl: "/partial-bundle.js" })], makeHostFactory, importer, ); expect(pluginRegistry.getNavItems()).toContainEqual({ id: "partial-nav", label: "Partial", path: "/partial", }); pluginRegistry.unregisterPlugin(pluginId); errorSpy.mockRestore(); }); it("logs and continues when a bundle never calls registerKandevPlugin", async () => { const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); const importer = fakeImporterFor({ "/silent-bundle.js": () => {} }); await loadPlugins( [activePlugin({ id: "plugin-silent-a", bundleUrl: "/silent-bundle.js" })], makeHostFactory, importer, ); expect(errorSpy).toHaveBeenCalled(); errorSpy.mockRestore(); }); }); describe("loadPlugins — asset URL prefixing", () => { afterEach(() => { pluginRegistry.unregisterPlugin("plugin-prefix-a"); pluginRegistry.unregisterPlugin("plugin-bare-a"); document.head.querySelectorAll("link[rel='stylesheet']").forEach((el) => el.remove()); }); it("prefixes a root-relative bundleUrl and style href with the backend apiBaseUrl when set (split-origin dev, Tauri)", async () => { mockApiBaseUrl = "http://localhost:38429"; const importedUrls: string[] = []; const importer = async (url: string) => { importedUrls.push(url); registerFake("plugin-prefix-a", { initialize: () => {} }); return {}; }; await loadPlugins( [ activePlugin({ id: "plugin-prefix-a", bundleUrl: "/api/plugins/plugin-prefix-a/bundle", styleUrls: ["/api/plugins/plugin-prefix-a/ui/style.css"], }), ], makeHostFactory, importer, ); expect(importedUrls).toEqual(["http://localhost:38429/api/plugins/plugin-prefix-a/bundle"]); const link = document.head.querySelector( "link[href='http://localhost:38429/api/plugins/plugin-prefix-a/ui/style.css']", ); expect(link).not.toBeNull(); }); it("leaves a root-relative bundleUrl unprefixed when apiBaseUrl is empty (same-origin production)", async () => { mockApiBaseUrl = ""; const importedUrls: string[] = []; const importer = async (url: string) => { importedUrls.push(url); registerFake("plugin-bare-a", { initialize: () => {} }); return {}; }; await loadPlugins( [ activePlugin({ id: "plugin-bare-a", bundleUrl: "/api/plugins/plugin-bare-a/bundle", }), ], makeHostFactory, importer, ); expect(importedUrls).toEqual(["/api/plugins/plugin-bare-a/bundle"]); }); }); describe("unloadPlugin", () => { afterEach(() => { pluginRegistry.unregisterPlugin(PLUGIN_UNLOAD_A_ID); pluginRegistry.unregisterPlugin(PLUGIN_UNLOAD_THROW_A_ID); pluginRegistry.unregisterPlugin(PLUGIN_UNLOAD_STYLE_A_ID); document.head.querySelectorAll("link[rel='stylesheet']").forEach((el) => el.remove()); }); it("calls destroy() and bulk-revokes the plugin's registrations", async () => { const destroy = vi.fn(); const importer = fakeImporterFor({ [BUNDLE_JS_URL]: (win) => (win as unknown as FakeWindow).registerKandevPlugin(PLUGIN_UNLOAD_A_ID, { initialize: (registry: { registerNavItem: (item: unknown) => void }) => { registry.registerNavItem({ id: "nav-a", label: "A", path: "/plugin-a" }); }, destroy, }), }); await loadPlugins( [activePlugin({ id: PLUGIN_UNLOAD_A_ID, bundleUrl: BUNDLE_JS_URL })], makeHostFactory, importer, ); expect(pluginRegistry.getNavItems()).toContainEqual({ id: "nav-a", label: "A", path: "/plugin-a", }); unloadPlugin(PLUGIN_UNLOAD_A_ID); expect(destroy).toHaveBeenCalledTimes(1); expect(pluginRegistry.getNavItems().find((item) => item.id === "nav-a")).toBeUndefined(); }); it("swallows a throwing destroy() and still bulk-revokes registrations", async () => { const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); const importer = fakeImporterFor({ [BUNDLE_JS_URL]: (win) => (win as unknown as FakeWindow).registerKandevPlugin(PLUGIN_UNLOAD_THROW_A_ID, { initialize: (registry: { registerNavItem: (item: unknown) => void }) => { registry.registerNavItem({ id: "nav-a", label: "A", path: "/plugin-a" }); }, destroy: () => { throw new Error("destroy boom"); }, }), }); await loadPlugins( [activePlugin({ id: PLUGIN_UNLOAD_THROW_A_ID, bundleUrl: BUNDLE_JS_URL })], makeHostFactory, importer, ); expect(() => unloadPlugin(PLUGIN_UNLOAD_THROW_A_ID)).not.toThrow(); expect(errorSpy).toHaveBeenCalled(); expect(pluginRegistry.getNavItems().find((item) => item.id === "nav-a")).toBeUndefined(); errorSpy.mockRestore(); }); it("removes the plugin's injected stylesheet tags so disable/enable cycles don't accumulate duplicates", async () => { const importer = fakeImporterFor({ [BUNDLE_JS_URL]: (win) => (win as unknown as FakeWindow).registerKandevPlugin(PLUGIN_UNLOAD_STYLE_A_ID, { initialize: () => {}, }), }); await loadPlugins( [ activePlugin({ id: PLUGIN_UNLOAD_STYLE_A_ID, bundleUrl: BUNDLE_JS_URL, styleUrls: ["/plugin-unload-style-a.css"], }), ], makeHostFactory, importer, ); expect(document.head.querySelector("link[href='/plugin-unload-style-a.css']")).not.toBeNull(); unloadPlugin(PLUGIN_UNLOAD_STYLE_A_ID); expect(document.head.querySelector("link[href='/plugin-unload-style-a.css']")).toBeNull(); }); }); describe("unloadPlugin — plugin modal cleanup", () => { afterEach(() => { pluginRegistry.unregisterPlugin(PLUGIN_UNLOAD_A_ID); }); it("closes every modal opened by the unloaded plugin", async () => { const importer = fakeImporterFor({ [BUNDLE_JS_URL]: (win) => (win as unknown as FakeWindow).registerKandevPlugin(PLUGIN_UNLOAD_A_ID, { initialize: () => {}, }), }); await loadPlugins( [activePlugin({ id: PLUGIN_UNLOAD_A_ID, bundleUrl: BUNDLE_JS_URL })], makeHostFactory, importer, ); const before = pluginModalManager.getSnapshot().length; pluginModalManager.openModal(PLUGIN_UNLOAD_A_ID, { content: () => null }); const otherHandle = pluginModalManager.openModal("some-other-plugin", { content: () => null }); expect(pluginModalManager.getSnapshot()).toHaveLength(before + 2); unloadPlugin(PLUGIN_UNLOAD_A_ID); const snapshot = pluginModalManager.getSnapshot(); expect(snapshot).toHaveLength(before + 1); expect(snapshot.some((m) => m.pluginId === PLUGIN_UNLOAD_A_ID)).toBe(false); expect(snapshot.some((m) => m.pluginId === "some-other-plugin")).toBe(true); otherHandle.close(); }); }); describe("update sequence: unload before reload", () => { const PLUGIN_UPDATE_A_ID = "plugin-update-a"; const MAIN_TOP_BAR_SLOT = "main-top-bar"; afterEach(() => { pluginRegistry.unregisterPlugin(PLUGIN_UPDATE_A_ID); }); it("registers the top-bar slot component exactly once when a plugin is unloaded then reloaded (update), not twice", async () => { const Widget = () => null; const importer = fakeImporterFor({ [BUNDLE_JS_URL]: (win) => (win as unknown as FakeWindow).registerKandevPlugin(PLUGIN_UPDATE_A_ID, { initialize: (registry: PluginRegistry) => { registry.registerComponent(MAIN_TOP_BAR_SLOT, Widget); }, }), }); // First install. await loadPlugins( [activePlugin({ id: PLUGIN_UPDATE_A_ID, bundleUrl: BUNDLE_JS_URL })], makeHostFactory, importer, ); expect(pluginRegistry.getSlotComponents(MAIN_TOP_BAR_SLOT)).toEqual([Widget]); // Update: the reused install path must revoke the previous version's // registrations before reloading, exactly like disable/uninstall do. unloadPlugin(PLUGIN_UPDATE_A_ID); await loadPlugins( [activePlugin({ id: PLUGIN_UPDATE_A_ID, bundleUrl: BUNDLE_JS_URL })], makeHostFactory, importer, ); expect(pluginRegistry.getSlotComponents(MAIN_TOP_BAR_SLOT)).toEqual([Widget]); }); }); describe("idempotent reload: no duplicate registrations without an explicit unload", () => { const PLUGIN_REBOOT_A_ID = "plugin-reboot-a"; const CHAT_INPUT_SLOT = "chat-input-actions"; afterEach(() => { pluginRegistry.unregisterPlugin(PLUGIN_REBOOT_A_ID); }); it("registers the slot component exactly once when loadPlugins runs twice for the same plugin (boot race / HMR re-boot / new store), not twice", async () => { const Widget = () => null; // The real browser only runs a module's top-level registerKandevPlugin on // the first import of a specifier; a second import resolves from cache. The // host reuses that cached registration and re-runs initialize(), so without // idempotent (re)load this second pass would append a duplicate slot entry. let importCount = 0; const importer = vi.fn(async (_url: string) => { importCount += 1; if (importCount === 1) { registerFake(PLUGIN_REBOOT_A_ID, { initialize: (registry: PluginRegistry) => { registry.registerComponent(CHAT_INPUT_SLOT, Widget); }, }); } return {}; }); const plugin = activePlugin({ id: PLUGIN_REBOOT_A_ID }); await loadPlugins([plugin], makeHostFactory, importer); // No unloadPlugin() between the two loads — this is the boot re-entry path. await loadPlugins([plugin], makeHostFactory, importer); expect(pluginRegistry.getSlotComponents(CHAT_INPUT_SLOT)).toEqual([Widget]); }); }); describe("generation fence forwards every registry method, not just the slot ones", () => { const PLUGIN_KEYBIND_ID = "plugin-keybind-a"; afterEach(() => { pluginRegistry.unregisterPlugin(PLUGIN_KEYBIND_ID); }); it("forwards registerKeybinding (and any non-hardcoded register* method) through the fence so it is not dropped", async () => { const handler = () => {}; const importer = fakeImporterFor({ [BUNDLE_JS_URL]: (win) => (win as unknown as FakeWindow).registerKandevPlugin(PLUGIN_KEYBIND_ID, { initialize: (registry: PluginRegistry) => { registry.registerKeybinding("open-modal", handler); }, }), }); await loadPlugins( [activePlugin({ id: PLUGIN_KEYBIND_ID, bundleUrl: BUNDLE_JS_URL })], makeHostFactory, importer, ); expect(pluginRegistry.getKeybindingHandlers().map((entry) => entry.id)).toContain("open-modal"); }); }); describe("overlapping loads for the same plugin: newest-initiated load wins", () => { const PLUGIN_CONC_A_ID = "plugin-conc-a"; const PLUGIN_CONC_B_ID = "plugin-conc-b"; const SLOT = "chat-input-actions"; function deferred() { let resolve!: (value: T) => void; const promise = new Promise((r) => { resolve = r; }); return { promise, resolve }; } afterEach(() => { // evictCache clears both the registry and the module-level registeredPlugins // cache so one test's bundle can't leak into the next (they'd otherwise reuse // a stale cached registration). unloadPlugin(PLUGIN_CONC_A_ID, { evictCache: true }); unloadPlugin(PLUGIN_CONC_B_ID, { evictCache: true }); }); it("a superseded (older) load whose import resolves last does not revoke the newer load's registration (Codex P1: boot v1 vs update v2)", async () => { const OldWidget = () => null; const NewWidget = () => null; const oldImportGate = deferred(); // The older boot import resolves only after the gate — i.e. last. const oldImporter = async (_url: string) => { await oldImportGate.promise; registerFake(PLUGIN_CONC_A_ID, { initialize: (registry: PluginRegistry) => registry.registerComponent(SLOT, OldWidget), }); return {}; }; const newImporter = async (_url: string) => { registerFake(PLUGIN_CONC_A_ID, { initialize: (registry: PluginRegistry) => registry.registerComponent(SLOT, NewWidget), }); return {}; }; // Older load starts first (claims the earlier generation) but parks on its // import; the newer load then runs to completion. const oldLoad = loadPlugins( [activePlugin({ id: PLUGIN_CONC_A_ID })], makeHostFactory, oldImporter, ); await loadPlugins([activePlugin({ id: PLUGIN_CONC_A_ID })], makeHostFactory, newImporter); expect(pluginRegistry.getSlotComponents(SLOT)).toEqual([NewWidget]); expect(pluginRegistry.getPluginLifecycle(PLUGIN_CONC_A_ID)?.status).toBe("ready"); // The stale import finally resolves — it must bail before touching the // registry, leaving the newer registration intact (no unregister, no OldWidget). oldImportGate.resolve(); await oldLoad; expect(pluginRegistry.getSlotComponents(SLOT)).toEqual([NewWidget]); expect(pluginRegistry.getPluginLifecycle(PLUGIN_CONC_A_ID)?.status).toBe("ready"); }); it("a superseded load whose initialize() registers after an await does not append a duplicate slot entry", async () => { const OldWidget = () => null; const NewWidget = () => null; const oldInitGate = deferred(); let importCount = 0; const importer = async (_url: string) => { importCount += 1; const isOld = importCount === 1; registerFake(PLUGIN_CONC_B_ID, { initialize: async (registry: PluginRegistry) => { if (isOld) { await oldInitGate.promise; // registers post-await, after being superseded registry.registerComponent(SLOT, OldWidget); } else { registry.registerComponent(SLOT, NewWidget); } }, }); return {}; }; // Older load reaches its awaiting initialize() and parks there. Flush a // full macrotask so it is guaranteed past the import fence and parked at the // gate before we supersede it — exercising the post-await register path. const oldLoad = loadPlugins( [activePlugin({ id: PLUGIN_CONC_B_ID })], makeHostFactory, importer, ); await new Promise((resolve) => setTimeout(resolve, 0)); // The update path evicts the cached bundle then reloads — the newer load // registers NewWidget and becomes the current generation. unloadPlugin(PLUGIN_CONC_B_ID, { evictCache: true }); await loadPlugins([activePlugin({ id: PLUGIN_CONC_B_ID })], makeHostFactory, importer); expect(pluginRegistry.getSlotComponents(SLOT)).toEqual([NewWidget]); // The stale initialize resumes and tries to register — the generation fence // must drop it so exactly one slot registration remains. oldInitGate.resolve(); await oldLoad; expect(pluginRegistry.getSlotComponents(SLOT)).toEqual([NewWidget]); expect(pluginRegistry.getPluginLifecycle(PLUGIN_CONC_B_ID)?.status).toBe("ready"); }); }); describe("unloadPlugin — evictCache option", () => { const PLUGIN_EVICT_A_ID = "plugin-evict-a"; afterEach(() => { pluginRegistry.unregisterPlugin(PLUGIN_EVICT_A_ID); }); it("re-imports the bundle on the next load when evictCache is true, unlike the default reuse-cached-registration behavior", async () => { const firstInitialize = vi.fn(); const secondInitialize = vi.fn(); let importCount = 0; const importer = vi.fn(async (_url: string) => { importCount += 1; registerFake(PLUGIN_EVICT_A_ID, { initialize: importCount === 1 ? firstInitialize : secondInitialize, }); return {}; }); await loadPlugins([activePlugin({ id: PLUGIN_EVICT_A_ID })], makeHostFactory, importer); expect(importCount).toBe(1); expect(firstInitialize).toHaveBeenCalledTimes(1); unloadPlugin(PLUGIN_EVICT_A_ID, { evictCache: true }); await loadPlugins([activePlugin({ id: PLUGIN_EVICT_A_ID })], makeHostFactory, importer); expect(importCount).toBe(2); expect(secondInitialize).toHaveBeenCalledTimes(1); }); });