Add a curated plugin registry surface to the dashboard plugin manager. - expose GET /api/plugins/registry with search, category filtering, and installed-state annotations from the dashboard registry manifest - add registry fetch/install support to the dashboard plugin manager UI, including search, install/manage actions, and related tests - document plugin registry entries and registry installability in plugin authoring docs and shared concepts Files changed: CONCEPTS.md | 6 + docs/PLUGIN_AUTHORING.md | 23 ++ packages/dashboard/app/api/legacy.ts | 36 +++ .../dashboard/app/components/PluginManager.css | 185 ++++++++++++++- .../dashboard/app/components/PluginManager.tsx | 162 ++++++++++++- .../PluginManager.install-browse.test.tsx | 5 +- .../__tests__/PluginManager.registry.test.tsx | 260 +++++++++++++++++++++ .../components/__tests__/PluginManager.test.tsx | 3 +- .../__tests__/PluginManager.toggle.test.tsx | 5 +- .../src/__tests__/routes-plugin-registry.test.ts | 156 +++++++++++++ packages/dashboard/src/plugin-routes.ts | 115 +++++++++ packages/dashboard/src/registry-manifest.json | 143 ++++++++++++ 12 files changed, 1091 insertions(+), 8 deletions(-) Fusion-Task-Id: FN-6071 Fusion-Task-Lineage: 72c7bdfd-5832-4323-b675-63d0680167d8
120 lines
4.2 KiB
TypeScript
120 lines
4.2 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import { render, screen, waitFor, cleanup } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import { PluginManager } from "../PluginManager";
|
|
import { loadAllAppCss } from "../../test/cssFixture";
|
|
|
|
vi.mock("../../api", () => ({
|
|
fetchPlugins: vi.fn(() => Promise.resolve([])),
|
|
fetchPluginRegistry: vi.fn(() => Promise.resolve([])),
|
|
installPlugin: vi.fn(() => Promise.resolve({})),
|
|
enablePlugin: vi.fn(() => Promise.resolve({})),
|
|
disablePlugin: vi.fn(() => Promise.resolve({})),
|
|
uninstallPlugin: vi.fn(() => Promise.resolve()),
|
|
fetchPluginSettings: vi.fn(() => Promise.resolve({})),
|
|
updatePluginSettings: vi.fn(() => Promise.resolve({})),
|
|
reloadPlugin: vi.fn(() => Promise.resolve({})),
|
|
fetchPluginSetupStatus: vi.fn(() => Promise.resolve({ hasSetup: false })),
|
|
installPluginSetup: vi.fn(() => Promise.resolve({ success: true })),
|
|
updatePlugin: vi.fn(() => Promise.resolve({})),
|
|
rescanPlugin: vi.fn(() => Promise.resolve({})),
|
|
browseDirectory: vi.fn(() => Promise.resolve({ currentPath: "/", parentPath: null, entries: [] })),
|
|
}));
|
|
|
|
import { fetchPlugins, disablePlugin } from "../../api";
|
|
|
|
const addToast = vi.fn();
|
|
|
|
function plugin(enabled: boolean) {
|
|
return {
|
|
id: "plugin-a",
|
|
name: "Test Plugin A",
|
|
version: "1.0.0",
|
|
state: "started" as const,
|
|
enabled,
|
|
path: "/plugins/plugin-a",
|
|
settings: {},
|
|
settingsSchema: {},
|
|
createdAt: "2026-01-01T00:00:00.000Z",
|
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
};
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
const styleEl = document.createElement("style");
|
|
styleEl.setAttribute("data-test-id", "all-app-css");
|
|
styleEl.textContent = loadAllAppCss();
|
|
document.head.appendChild(styleEl);
|
|
|
|
const esInstance = {
|
|
readyState: 1,
|
|
close: vi.fn(),
|
|
addEventListener: vi.fn(),
|
|
removeEventListener: vi.fn(),
|
|
onerror: null,
|
|
onopen: null,
|
|
onmessage: null,
|
|
};
|
|
const MockES = vi.fn(function MockEventSource() {
|
|
return esInstance;
|
|
}) as unknown as typeof EventSource;
|
|
(MockES as unknown as { CONNECTING: number; OPEN: number; CLOSED: number }).CONNECTING = 0;
|
|
(MockES as unknown as { CONNECTING: number; OPEN: number; CLOSED: number }).OPEN = 1;
|
|
(MockES as unknown as { CONNECTING: number; OPEN: number; CLOSED: number }).CLOSED = 2;
|
|
vi.stubGlobal("EventSource", MockES);
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
document.querySelector('[data-test-id="all-app-css"]')?.remove();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe("PluginManager toggle switch", () => {
|
|
it("keeps checkbox focusable but visually hidden", async () => {
|
|
vi.mocked(fetchPlugins).mockResolvedValue([plugin(true)]);
|
|
|
|
render(<PluginManager addToast={addToast} />);
|
|
|
|
const checkbox = await screen.findByRole("checkbox", { name: "Disable Test Plugin A" });
|
|
const styles = getComputedStyle(checkbox);
|
|
|
|
expect(styles.position).toBe("absolute");
|
|
expect(styles.opacity).toBe("0");
|
|
expect(styles.pointerEvents).toBe("none");
|
|
});
|
|
|
|
it("toggles by clicking the label/slider control", async () => {
|
|
vi.mocked(fetchPlugins).mockResolvedValue([plugin(true)]);
|
|
|
|
render(<PluginManager addToast={addToast} />);
|
|
|
|
const checkbox = await screen.findByRole("checkbox", { name: "Disable Test Plugin A" });
|
|
const label = checkbox.closest("label.toggle-switch") as HTMLLabelElement;
|
|
await userEvent.click(label);
|
|
|
|
await waitFor(() => {
|
|
expect(disablePlugin).toHaveBeenCalledWith("plugin-a", undefined);
|
|
});
|
|
});
|
|
|
|
it("renders slider next to input and reflects enabled state", async () => {
|
|
vi.mocked(fetchPlugins).mockResolvedValue([plugin(true)]);
|
|
const first = render(<PluginManager addToast={addToast} />);
|
|
|
|
const enabled = await screen.findByRole("checkbox", { name: "Disable Test Plugin A" });
|
|
expect(enabled).toBeChecked();
|
|
expect(enabled.nextElementSibling).toHaveClass("toggle-slider");
|
|
|
|
first.unmount();
|
|
|
|
vi.mocked(fetchPlugins).mockResolvedValue([plugin(false)]);
|
|
render(<PluginManager addToast={addToast} />);
|
|
|
|
const disabled = await screen.findByRole("checkbox", { name: "Enable Test Plugin A" });
|
|
expect(disabled).not.toBeChecked();
|
|
expect(disabled.nextElementSibling).toHaveClass("toggle-slider");
|
|
});
|
|
});
|