Files
kandev/apps/web/lib/plugins/registry.test.ts
T
Kandev Agent c53f699714 feat: add plugin system with grpc runtime and native ui plugins
- go plugin backends ship as release tarballs (per-platform binaries, checksums) that
  kandev installs from url/upload or filesystem sideload+sync, then spawns and
  supervises via hashicorp/go-plugin (unix socket / loopback tcp+automtls)
- strict typed grpc contract (kandev.plugin.v1): DeliverEvent/InvokeTool/HandleWebhook
  plus a capability-gated host service (state, secrets, emit); author sdk in
  pkg/pluginsdk; packaging cli in cmd/plugin-pack
- native frontend bundles: window.registerKandevPlugin + registry/host loader with
  dynamic routes, nav items, slots and ws handlers; plugins settings page with
  install dialog, sync button and enable/disable/uninstall
- gated behind the plugins feature flag (prod off, dev/e2e on); covered by unit and
  integration tests plus playwright e2e against a real packaged fixture plugin

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 17:55:45 +01:00

125 lines
3.9 KiB
TypeScript

import { describe, it, expect, afterEach } from "vitest";
import { pluginRegistry } from "./registry";
const TASK_SIDEBAR_SLOT = "task-sidebar";
const TASK_CREATED_ACTION = "task.created";
function cleanup(...pluginIds: string[]) {
pluginIds.forEach((id) => pluginRegistry.unregisterPlugin(id));
}
describe("pluginRegistry", () => {
afterEach(() => {
cleanup("plugin-a", "plugin-b");
});
it("registers and returns a route via the scoped registry view", () => {
const scoped = pluginRegistry.forPlugin("plugin-a");
function Page() {
return null;
}
scoped.registerRoute("/plugin-a", Page);
const routes = pluginRegistry.getRoutes();
expect(routes).toContainEqual({ path: "/plugin-a", Component: Page });
});
it("registers and returns a nav item", () => {
const scoped = pluginRegistry.forPlugin("plugin-a");
scoped.registerNavItem({ id: "nav-a", label: "A", path: "/plugin-a" });
expect(pluginRegistry.getNavItems()).toContainEqual({
id: "nav-a",
label: "A",
path: "/plugin-a",
});
});
it("registers and returns a settings route", () => {
const scoped = pluginRegistry.forPlugin("plugin-a");
function Settings() {
return null;
}
scoped.registerSettingsRoute("/settings/plugins/plugin-a", Settings);
expect(pluginRegistry.getSettingsRoutes()).toContainEqual({
path: "/settings/plugins/plugin-a",
Component: Settings,
});
});
it("registers a slot component and only returns it for the matching slot", () => {
const scoped = pluginRegistry.forPlugin("plugin-a");
function Sidebar() {
return null;
}
scoped.registerComponent(TASK_SIDEBAR_SLOT, Sidebar);
expect(pluginRegistry.getSlotComponents(TASK_SIDEBAR_SLOT)).toEqual([Sidebar]);
expect(pluginRegistry.getSlotComponents("settings-nav")).toEqual([]);
});
it("registers a WS handler and only returns it for the matching action", () => {
const scoped = pluginRegistry.forPlugin("plugin-a");
const handler = () => {};
scoped.registerWsHandler(TASK_CREATED_ACTION, handler);
expect(pluginRegistry.getWsHandlers(TASK_CREATED_ACTION)).toEqual([handler]);
expect(pluginRegistry.getWsHandlers("task.deleted")).toEqual([]);
});
it("bulk-revokes every registration owned by a plugin on unregisterPlugin", () => {
const scopedA = pluginRegistry.forPlugin("plugin-a");
const scopedB = pluginRegistry.forPlugin("plugin-b");
function PageA() {
return null;
}
function PageB() {
return null;
}
scopedA.registerRoute("/plugin-a", PageA);
scopedA.registerNavItem({ id: "nav-a", label: "A", path: "/plugin-a" });
scopedA.registerComponent(TASK_SIDEBAR_SLOT, PageA);
scopedA.registerWsHandler(TASK_CREATED_ACTION, () => {});
scopedB.registerRoute("/plugin-b", PageB);
pluginRegistry.unregisterPlugin("plugin-a");
expect(pluginRegistry.getRoutes()).toEqual([{ path: "/plugin-b", Component: PageB }]);
expect(pluginRegistry.getNavItems().find((item) => item.id === "nav-a")).toBeUndefined();
expect(pluginRegistry.getSlotComponents(TASK_SIDEBAR_SLOT)).toEqual([]);
expect(pluginRegistry.getWsHandlers(TASK_CREATED_ACTION)).toEqual([]);
});
it("notifies subscribers when a registration is added", () => {
const scoped = pluginRegistry.forPlugin("plugin-a");
let notified = 0;
const unsubscribe = pluginRegistry.subscribe(() => {
notified += 1;
});
scoped.registerNavItem({ id: "nav-a", label: "A", path: "/plugin-a" });
unsubscribe();
expect(notified).toBe(1);
});
it("does not notify subscribers when unregistering a plugin with no registrations", () => {
let notified = 0;
const unsubscribe = pluginRegistry.subscribe(() => {
notified += 1;
});
pluginRegistry.unregisterPlugin("plugin-with-nothing-registered");
unsubscribe();
expect(notified).toBe(0);
});
});