07038018ac
* fix(plugins): unload previous version before reloading on plugin update The install/update path reloaded a plugin without first unloading the previous version's registrations, unlike disable/uninstall. Because the slot registry only appends, this ran initialize() twice and left two main-top-bar slot entries, duplicating the plugin's widget icon until a page refresh. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(plugins): cache-bust plugin bundle import so updates re-execute unloadPlugin(id, {evictCache: true}) drops the cached bundle registration on install/update, but a same-tab re-import() of the identical bundle URL returns the browser's already-evaluated ES module without re-running its top-level registerKandevPlugin() call — leaving an updated plugin active but missing from the UI. Key the bundle URL on the plugin's installed version (?v=<version>) on both the frontend (toActivePlugin) and the backend boot payload (bootActivePlugins) so a version bump forces a real re-import while an unchanged version stays cache-friendly across boots. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(plugins): update bundleUrl assertions for cache-bust query param toActivePlugin now appends a ?v=<version> cache-busting query param to bundleUrl; these two assertions still expected the bare path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(plugins): only evict bundle cache on install/update, not on enable loadIfActive unconditionally evicted the cached bundle registration, which broke the disable->enable cycle: since bundleUrl doesn't change on plain enable, the browser's ES module cache returned the already-evaluated module without re-running registerKandevPlugin, leaving the plugin's slots/routes unregistered until a full page refresh. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
106 lines
3.8 KiB
TypeScript
106 lines
3.8 KiB
TypeScript
import { renderHook, act, waitFor } from "@testing-library/react";
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import type { ReactNode } from "react";
|
|
import { StateProvider } from "@/components/state-provider";
|
|
import type { PluginRecord } from "@/lib/types/plugins";
|
|
import type { InstallResult } from "@/lib/api/domains/plugins-api";
|
|
|
|
const loadPlugins = vi.fn(async () => {});
|
|
const unloadPlugin = vi.fn();
|
|
const installPluginFromUrl = vi.fn<() => Promise<InstallResult>>();
|
|
const installPluginUpload = vi.fn<() => Promise<InstallResult>>();
|
|
const enablePlugin = vi.fn(async () => ({ enabled: true }));
|
|
|
|
vi.mock("@/lib/plugins/host", () => ({
|
|
loadPlugins: (...args: unknown[]) => loadPlugins(...(args as [])),
|
|
unloadPlugin: (...args: unknown[]) => unloadPlugin(...(args as [])),
|
|
}));
|
|
|
|
vi.mock("@/lib/api/domains/plugins-api", async () => {
|
|
const actual = await vi.importActual<typeof import("@/lib/api/domains/plugins-api")>(
|
|
"@/lib/api/domains/plugins-api",
|
|
);
|
|
return {
|
|
...actual,
|
|
installPluginFromUrl: (...args: unknown[]) => installPluginFromUrl(...(args as [])),
|
|
installPluginUpload: (...args: unknown[]) => installPluginUpload(...(args as [])),
|
|
enablePlugin: (...args: unknown[]) => enablePlugin(...(args as [])),
|
|
};
|
|
});
|
|
|
|
import { usePluginActions } from "./use-plugin-actions";
|
|
|
|
function wrapper({ children }: { children: ReactNode }) {
|
|
return <StateProvider>{children}</StateProvider>;
|
|
}
|
|
|
|
function activeRecord(overrides: Partial<PluginRecord> = {}): PluginRecord {
|
|
return {
|
|
id: "acme-tools",
|
|
api_version: 1,
|
|
version: "2.0.0",
|
|
display_name: "Acme Tools",
|
|
description: "",
|
|
author: "",
|
|
categories: [],
|
|
capabilities: {},
|
|
status: "active",
|
|
install_path: "/home/user/.kandev/plugins/acme-tools/2.0.0",
|
|
signed: true,
|
|
installed_at: "2026-01-01T00:00:00Z",
|
|
restart_count: 0,
|
|
ui: { bundle: "/ui/bundle.js" },
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
beforeEach(() => {
|
|
loadPlugins.mockClear();
|
|
unloadPlugin.mockClear();
|
|
installPluginFromUrl.mockReset();
|
|
installPluginUpload.mockReset();
|
|
enablePlugin.mockClear();
|
|
});
|
|
|
|
describe("usePluginActions — install/update", () => {
|
|
it("unloads the plugin's previous registrations before reloading it, so an update doesn't leave duplicate slot registrations", async () => {
|
|
const plugin = activeRecord();
|
|
installPluginFromUrl.mockResolvedValue({ plugin });
|
|
|
|
const { result } = renderHook(() => usePluginActions(), { wrapper });
|
|
|
|
await act(async () => {
|
|
await result.current.submitInstallUrl("https://example.com/acme-tools.tar.gz");
|
|
});
|
|
|
|
await waitFor(() => expect(loadPlugins).toHaveBeenCalledTimes(1));
|
|
expect(unloadPlugin).toHaveBeenCalledWith(plugin.id, { evictCache: true });
|
|
|
|
const unloadOrder = unloadPlugin.mock.invocationCallOrder[0];
|
|
const loadOrder = loadPlugins.mock.invocationCallOrder[0];
|
|
expect(unloadOrder).toBeLessThan(loadOrder);
|
|
});
|
|
});
|
|
|
|
describe("usePluginActions — enable", () => {
|
|
it("does not evict the cached bundle registration on plain enable, so a disable-then-re-enable cycle reuses it", async () => {
|
|
const plugin = activeRecord();
|
|
|
|
const { result } = renderHook(() => usePluginActions(), { wrapper });
|
|
|
|
await act(async () => {
|
|
await result.current.handleEnable(plugin);
|
|
});
|
|
|
|
await waitFor(() => expect(loadPlugins).toHaveBeenCalledTimes(1));
|
|
// Enable must unload without evicting the cache — eviction is reserved
|
|
// for install/update, where the bundle content actually changed.
|
|
expect(unloadPlugin).toHaveBeenCalledWith(plugin.id);
|
|
expect(unloadPlugin).not.toHaveBeenCalledWith(plugin.id, { evictCache: true });
|
|
|
|
const unloadOrder = unloadPlugin.mock.invocationCallOrder[0];
|
|
const loadOrder = loadPlugins.mock.invocationCallOrder[0];
|
|
expect(unloadOrder).toBeLessThan(loadOrder);
|
|
});
|
|
});
|