Files
kandev/apps/web/components/settings/plugins/use-plugin-actions.test.tsx
T
João Salavisa 07038018ac fix(plugins): unload previous version before reloading on plugin update (#1861)
* 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>
2026-07-22 13:45:18 +01:00

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);
});
});