60d37aa24a
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
675 lines
24 KiB
TypeScript
675 lines
24 KiB
TypeScript
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
import type { Window as HappyDOMWindow } from "happy-dom";
|
|
import { loadPlugins, unloadPlugin } from "./host";
|
|
import { pluginModalManager } from "./modal-manager";
|
|
import { pluginRegistry } from "./registry";
|
|
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 {
|
|
return {
|
|
pluginId,
|
|
React: {} as PluginHostApi["React"],
|
|
jsx: {} as PluginHostApi["jsx"],
|
|
store: {
|
|
getState: () => ({}) as never,
|
|
setState: () => {},
|
|
subscribe: () => () => {},
|
|
},
|
|
context: {
|
|
getActiveWorkspaceId: () => undefined,
|
|
subscribeActiveWorkspace: () => () => {},
|
|
getTaskCreationContext: () => null,
|
|
subscribeTaskCreationContext: () => () => {},
|
|
resolveRepositoryId: () => undefined,
|
|
},
|
|
api: {
|
|
fetch: async () => new Response(),
|
|
invokeAction: async <TResponse>() => 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: () => "" },
|
|
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<string, (win: Window) => void>,
|
|
): (url: string) => Promise<unknown> {
|
|
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> = {}): 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 <link> elements before importing the bundle", async () => {
|
|
// happy-dom eagerly loads real <link rel="stylesheet"> hrefs over the network;
|
|
// disable that for this test so it doesn't attempt (and 404-log) a real fetch.
|
|
const happyDOMWindow = window as unknown as HappyDOMWindow;
|
|
happyDOMWindow.happyDOM.settings.disableCSSFileLoading = true;
|
|
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();
|
|
happyDOMWindow.happyDOM.settings.disableCSSFileLoading = false;
|
|
});
|
|
|
|
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 happyDOMWindow = window as unknown as HappyDOMWindow;
|
|
happyDOMWindow.happyDOM.settings.disableCSSFileLoading = true;
|
|
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();
|
|
happyDOMWindow.happyDOM.settings.disableCSSFileLoading = false;
|
|
});
|
|
|
|
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 <link> stylesheet tags so disable/enable cycles don't accumulate duplicates", async () => {
|
|
const happyDOMWindow = window as unknown as HappyDOMWindow;
|
|
happyDOMWindow.happyDOM.settings.disableCSSFileLoading = true;
|
|
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();
|
|
happyDOMWindow.happyDOM.settings.disableCSSFileLoading = false;
|
|
});
|
|
});
|
|
|
|
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<T = void>() {
|
|
let resolve!: (value: T) => void;
|
|
const promise = new Promise<T>((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);
|
|
});
|
|
});
|