2b1d0cf7d1
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
192 lines
6.7 KiB
TypeScript
192 lines
6.7 KiB
TypeScript
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
import { loadPlugins, unloadPlugin } from "./host";
|
|
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"];
|
|
|
|
vi.mock("@/lib/config", () => ({
|
|
getBackendConfig: () => ({ apiBaseUrl: "" }),
|
|
}));
|
|
|
|
const PLUGIN_REENABLE_A_ID = "plugin-reenable-a";
|
|
const PLUGIN_REENABLE_A_PATH = "/plugin-reenable-a";
|
|
const NAV_REENABLE_A_ID = "nav-reenable-a";
|
|
const PLUGIN_REENABLE_B_ID = "plugin-reenable-b";
|
|
const PLUGIN_REENABLE_B_PATH = "/plugin-reenable-b";
|
|
const NAV_REENABLE_B_ID = "nav-reenable-b";
|
|
|
|
function makeHostFactory(pluginId: string): PluginHostApi {
|
|
return {
|
|
pluginId,
|
|
React: {} as PluginHostApi["React"],
|
|
jsx: {} as PluginHostApi["jsx"],
|
|
conversation: {} as PluginHostApi["conversation"],
|
|
store: {
|
|
getState: () => ({}) as never,
|
|
setState: () => {},
|
|
subscribe: () => () => {},
|
|
},
|
|
context: {
|
|
getActiveWorkspaceId: () => undefined,
|
|
subscribeActiveWorkspace: () => () => {},
|
|
getWorkspaceIds: () => [],
|
|
subscribeWorkspaces: () => () => {},
|
|
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: () => "",
|
|
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;
|
|
};
|
|
|
|
function registerFake(id: string, plugin: unknown) {
|
|
(window as unknown as FakeWindow).registerKandevPlugin(id, plugin);
|
|
}
|
|
|
|
function activePlugin(overrides: Partial<ActivePlugin> = {}): ActivePlugin {
|
|
return {
|
|
id: "plugin-a",
|
|
name: "Plugin A",
|
|
bundleUrl: "/api/plugins/plugin-a/bundle",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("disable then re-enable in the same session", () => {
|
|
afterEach(() => {
|
|
pluginRegistry.unregisterPlugin(PLUGIN_REENABLE_A_ID);
|
|
pluginRegistry.unregisterPlugin(PLUGIN_REENABLE_B_ID);
|
|
});
|
|
|
|
it("re-initializes from the cached registration when the bundle's module-eval side effect only fires once (ESM import caching)", async () => {
|
|
const initialize = vi.fn((registry: PluginRegistry) => {
|
|
registry.registerNavItem({ id: NAV_REENABLE_A_ID, label: "A", path: PLUGIN_REENABLE_A_PATH });
|
|
});
|
|
let importCount = 0;
|
|
const importer = vi.fn(async (_url: string) => {
|
|
importCount += 1;
|
|
// The real browser only runs a module's top-level side effect on the
|
|
// *first* resolution of a given specifier — a second `import(url)`
|
|
// resolves from the module cache without re-executing
|
|
// `window.registerKandevPlugin(...)`.
|
|
if (importCount === 1) {
|
|
registerFake(PLUGIN_REENABLE_A_ID, { initialize });
|
|
}
|
|
return {};
|
|
});
|
|
|
|
await loadPlugins([activePlugin({ id: PLUGIN_REENABLE_A_ID })], makeHostFactory, importer);
|
|
expect(initialize).toHaveBeenCalledTimes(1);
|
|
expect(pluginRegistry.getNavItems()).toContainEqual({
|
|
id: NAV_REENABLE_A_ID,
|
|
label: "A",
|
|
path: PLUGIN_REENABLE_A_PATH,
|
|
});
|
|
|
|
unloadPlugin(PLUGIN_REENABLE_A_ID);
|
|
expect(
|
|
pluginRegistry.getNavItems().find((item) => item.id === NAV_REENABLE_A_ID),
|
|
).toBeUndefined();
|
|
|
|
await loadPlugins([activePlugin({ id: PLUGIN_REENABLE_A_ID })], makeHostFactory, importer);
|
|
|
|
expect(initialize).toHaveBeenCalledTimes(2);
|
|
expect(pluginRegistry.getNavItems()).toContainEqual({
|
|
id: NAV_REENABLE_A_ID,
|
|
label: "A",
|
|
path: PLUGIN_REENABLE_A_PATH,
|
|
});
|
|
});
|
|
|
|
it("reorders the registry to the end on re-enable, relative to a plugin that stayed enabled throughout", async () => {
|
|
const initializeA = vi.fn((registry: PluginRegistry) => {
|
|
registry.registerNavItem({ id: NAV_REENABLE_A_ID, label: "A", path: PLUGIN_REENABLE_A_PATH });
|
|
});
|
|
const initializeB = vi.fn((registry: PluginRegistry) => {
|
|
registry.registerNavItem({ id: NAV_REENABLE_B_ID, label: "B", path: PLUGIN_REENABLE_B_PATH });
|
|
});
|
|
const importer = vi.fn(async (url: string) => {
|
|
if (url === "/reenable-a-bundle.js") {
|
|
registerFake(PLUGIN_REENABLE_A_ID, { initialize: initializeA });
|
|
} else if (url === "/reenable-b-bundle.js") {
|
|
registerFake(PLUGIN_REENABLE_B_ID, { initialize: initializeB });
|
|
}
|
|
return {};
|
|
});
|
|
|
|
await loadPlugins(
|
|
[
|
|
activePlugin({ id: PLUGIN_REENABLE_A_ID, bundleUrl: "/reenable-a-bundle.js" }),
|
|
activePlugin({ id: PLUGIN_REENABLE_B_ID, bundleUrl: "/reenable-b-bundle.js" }),
|
|
],
|
|
makeHostFactory,
|
|
importer,
|
|
);
|
|
|
|
expect(
|
|
pluginRegistry.getNavRegistrations().map((registration) => registration.pluginId),
|
|
).toEqual([PLUGIN_REENABLE_A_ID, PLUGIN_REENABLE_B_ID]);
|
|
|
|
unloadPlugin(PLUGIN_REENABLE_A_ID);
|
|
await loadPlugins(
|
|
[activePlugin({ id: PLUGIN_REENABLE_A_ID, bundleUrl: "/reenable-a-bundle.js" })],
|
|
makeHostFactory,
|
|
importer,
|
|
);
|
|
|
|
// The registry itself reorders on re-enable — A's re-registration is
|
|
// appended after B, not merely re-added somewhere. Proves the mechanism
|
|
// (spec.md:797/:815's re-enable ordering guarantee), not just a manually
|
|
// simulated post-re-enable array as app-sidebar-footer.test.tsx's
|
|
// "partitions by current registration order" test discloses it does.
|
|
expect(
|
|
pluginRegistry.getNavRegistrations().map((registration) => registration.pluginId),
|
|
).toEqual([PLUGIN_REENABLE_B_ID, PLUGIN_REENABLE_A_ID]);
|
|
// Count assertion: A is the sole entry after B — a looser "A comes after
|
|
// B somewhere" check would still pass if a stray duplicate leaked in.
|
|
expect(pluginRegistry.getNavRegistrations()).toHaveLength(2);
|
|
});
|
|
});
|