c53f699714
- 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>
76 lines
2.5 KiB
TypeScript
76 lines
2.5 KiB
TypeScript
import { cleanup, render, screen } from "@testing-library/react";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { pluginRegistry } from "@/lib/plugins/registry";
|
|
import { PluginSlot } from "./plugin-slot";
|
|
|
|
const SLOT = "task-sidebar";
|
|
|
|
function cleanupPlugins(...pluginIds: string[]) {
|
|
pluginIds.forEach((id) => pluginRegistry.unregisterPlugin(id));
|
|
}
|
|
|
|
describe("PluginSlot", () => {
|
|
afterEach(() => {
|
|
cleanup();
|
|
cleanupPlugins("plugin-a", "plugin-b");
|
|
});
|
|
|
|
it("renders nothing when no plugin has registered a component for the slot", () => {
|
|
const { container } = render(<PluginSlot name={SLOT} />);
|
|
expect(container.innerHTML).toBe("");
|
|
});
|
|
|
|
it("renders every component registered for the named slot", () => {
|
|
pluginRegistry
|
|
.forPlugin("plugin-a")
|
|
.registerComponent(SLOT, () => <div data-testid="slot-a">A</div>);
|
|
pluginRegistry
|
|
.forPlugin("plugin-b")
|
|
.registerComponent(SLOT, () => <div data-testid="slot-b">B</div>);
|
|
|
|
render(<PluginSlot name={SLOT} />);
|
|
|
|
expect(screen.getByTestId("slot-a")).not.toBeNull();
|
|
expect(screen.getByTestId("slot-b")).not.toBeNull();
|
|
});
|
|
|
|
it("does not render a component registered for a different slot", () => {
|
|
pluginRegistry
|
|
.forPlugin("plugin-a")
|
|
.registerComponent("settings-nav", () => <div data-testid="slot-a">A</div>);
|
|
|
|
render(<PluginSlot name={SLOT} />);
|
|
|
|
expect(screen.queryByTestId("slot-a")).toBeNull();
|
|
});
|
|
|
|
it("passes slotProps through to each registered component", () => {
|
|
pluginRegistry
|
|
.forPlugin("plugin-a")
|
|
.registerComponent(SLOT, ({ slotProps }) => (
|
|
<div data-testid="slot-a">{String((slotProps as { label: string })?.label)}</div>
|
|
));
|
|
|
|
render(<PluginSlot name={SLOT} slotProps={{ label: "hello" }} />);
|
|
|
|
expect(screen.getByTestId("slot-a").textContent).toBe("hello");
|
|
});
|
|
|
|
it("isolates a throwing slot component so a sibling still renders", () => {
|
|
// eslint-disable-next-line no-console -- expected error boundary log, assert + silence it
|
|
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
|
|
pluginRegistry.forPlugin("plugin-a").registerComponent(SLOT, () => {
|
|
throw new Error("boom");
|
|
});
|
|
pluginRegistry
|
|
.forPlugin("plugin-b")
|
|
.registerComponent(SLOT, () => <div data-testid="slot-b">B</div>);
|
|
|
|
render(<PluginSlot name={SLOT} />);
|
|
|
|
expect(screen.getByTestId("slot-b")).not.toBeNull();
|
|
consoleError.mockRestore();
|
|
});
|
|
});
|