FN-6074: add plugin registry QA coverage

Expand post-merge QA coverage for plugin registry browsing and install flows.

- add PluginManager registry UI tests for metadata, action states, install failures, loading/error states, search debounce, and responsive rendering
- add API route tests for invalid manifest payloads, sanitized query/category handling, and project-scoped store failure paths
- include the new registry-focused UI and API suites in the dashboard quality vitest config

Files changed:
 .../components/__tests__/PluginManager.registry.test.tsx | 64 ++++++++++++++++++++
 .../src/__tests__/routes-plugin-registry.test.ts        | 68 ++++++++++++++++++++++
 packages/dashboard/vitest.config.ts                     |  3 +-
 3 files changed, 134 insertions(+), 1 deletion(-)

Fusion-Task-Id: FN-6074

Fusion-Task-Lineage: 5cc00fb9-2ef4-4097-af7f-d8f49c536774
This commit is contained in:
gsxdsm
2026-06-09 06:43:13 -07:00
parent b1c4f7a4d3
commit 4f8bb31774
3 changed files with 134 additions and 1 deletions

View File

@@ -126,6 +126,8 @@ afterEach(() => {
});
describe("PluginManager registry browsing", () => {
// GAP: The API supports ?category= filtering but the UI does not expose a category selector.
// A future task should add category filter UI and tests.
it("renders registry entries with metadata", async () => {
await renderRegistry();
@@ -165,6 +167,32 @@ describe("PluginManager registry browsing", () => {
expect(fetchPlugins).toHaveBeenCalledTimes(2);
});
it("surfaces install rejection without unmounting registry results", async () => {
vi.mocked(installPlugin).mockRejectedValueOnce(new Error("install rejected"));
await renderRegistry();
const installable = screen.getByText("Installable Registry").closest(".plugin-registry-item") as HTMLElement;
const installButton = within(installable).getByRole("button", { name: "Install" });
fireEvent.click(installButton);
expect(within(installable).getByRole("button", { name: "Installing..." })).toBeDisabled();
await act(async () => {
await Promise.resolve();
});
await act(async () => {
await Promise.resolve();
});
expect(addToast).toHaveBeenCalledWith(expect.stringContaining("install rejected"), "error");
expect(screen.getByRole("region", { name: "Browse Registry" })).toBeInTheDocument();
expect(screen.getByText("Installable Registry")).toBeInTheDocument();
expect(within(installable).getByRole("button", { name: "Install" })).toBeEnabled();
const searchInput = screen.getByPlaceholderText("Search registry plugins");
fireEvent.change(searchInput, { target: { value: "still interactive" } });
expect(searchInput).toHaveValue("still interactive");
});
it("opens detail management for installed entries", async () => {
await renderRegistry();
@@ -227,6 +255,42 @@ describe("PluginManager registry browsing", () => {
expect(fetchPluginRegistry).toHaveBeenCalledTimes(2);
});
it("renders very long registry metadata inside the registry item container", async () => {
const longName = `Very Long Registry Plugin ${"Name".repeat(140)}`;
const longDescription = `Description ${"with lengthy details ".repeat(40)}`;
await renderRegistry([
{
id: "registry-long-metadata",
name: longName,
description: longDescription,
version: "9.9.9",
author: "Fusion Labs",
category: "integration",
path: "./plugins/registry-long-metadata",
installed: false,
canInstall: true,
},
]);
const item = screen.getByText(longName).closest(".plugin-registry-item") as HTMLElement;
expect(item).toBeInTheDocument();
expect(item).toHaveClass("plugin-registry-item");
expect(within(item).getByText(longName)).toBeInTheDocument();
expect(item).toHaveTextContent(longDescription.trim());
});
it("renders registry browsing controls at narrow viewport widths", async () => {
Object.defineProperty(window, "innerWidth", { configurable: true, value: 360 });
window.dispatchEvent(new Event("resize"));
await renderRegistry();
const section = screen.getByRole("region", { name: "Browse Registry" });
expect(within(section).getByPlaceholderText("Search registry plugins")).toBeInTheDocument();
expect(within(section).getByLabelText("Registry plugin results")).toBeInTheDocument();
expect(within(section).getByText("Installable Registry")).toBeInTheDocument();
});
it("shows empty state when no registry entries match", async () => {
await renderRegistry([]);

View File

@@ -130,6 +130,44 @@ describe("GET /api/plugins/registry", () => {
await expect(buildRegistryPluginEntries({}, pluginStore)).resolves.toEqual([]);
await expect(buildRegistryPluginEntries({ plugins: [] }, pluginStore)).resolves.toEqual([]);
await expect(buildRegistryPluginEntries({ plugins: "not-an-array" }, pluginStore)).resolves.toEqual([]);
});
it("filters invalid manifest entry shapes", async () => {
const pluginStore = createMockPluginStore();
await expect(
buildRegistryPluginEntries({ plugins: [null, undefined, "string-entry", 42] }, pluginStore),
).resolves.toEqual([]);
await expect(
buildRegistryPluginEntries({ plugins: [{ id: 123, name: null }] }, pluginStore),
).resolves.toEqual([]);
});
describe("input sanitization", () => {
it.each([
["HTML/script injection", "<script>alert(1)</script>"],
["SQL-injection-like text", "' OR 1=1 --"],
["extremely long text", "x".repeat(10_001)],
["regex-special characters", "[.*+]"],
])("treats %s in q as plain search text", async (_label, query) => {
const pluginStore = createMockPluginStore();
const res = await performGet(buildApp(pluginStore), `/api/plugins/registry?q=${encodeURIComponent(query)}`);
expect(res.status).toBe(200);
expect(res.body).toEqual({ plugins: [] });
});
it.each(["nonexistent", "../../etc", "runtime<script>"])(
"treats invalid category %s as a non-matching literal filter",
async (category) => {
const pluginStore = createMockPluginStore();
const res = await performGet(buildApp(pluginStore), `/api/plugins/registry?category=${encodeURIComponent(category)}`);
expect(res.status).toBe(200);
expect(res.body).toEqual({ plugins: [] });
},
);
});
it("uses the project-scoped plugin store when projectId is provided", async () => {
@@ -153,4 +191,34 @@ describe("GET /api/plugins/registry", () => {
expect.objectContaining({ id: "fusion-plugin-reports", installed: true }),
]);
});
describe("project-scoped store failures", () => {
it("returns 500 when resolving the project store fails", async () => {
const globalStore = createMockPluginStore();
const getOrCreateProjectStore = vi
.spyOn(projectStoreResolver, "getOrCreateProjectStore")
.mockRejectedValue(new Error("store unavailable"));
const res = await performGet(buildApp(globalStore), "/api/plugins/registry?projectId=bad-project");
expect(res.status).toBe(500);
expect(res.body).toEqual({ error: "store unavailable" });
expect(getOrCreateProjectStore).toHaveBeenCalledWith("bad-project");
});
it("returns 500 when the scoped store cannot provide a plugin store", async () => {
const globalStore = createMockPluginStore();
const getPluginStore = vi.fn(() => {
throw new Error("plugin store unavailable");
});
vi.spyOn(projectStoreResolver, "getOrCreateProjectStore").mockResolvedValue({ getPluginStore } as any);
const res = await performGet(buildApp(globalStore), "/api/plugins/registry?projectId=bad-project");
expect(res.status).toBe(500);
expect(res.body).toEqual({ error: "plugin store unavailable" });
expect(getPluginStore).toHaveBeenCalled();
expect(globalStore.getPlugin).not.toHaveBeenCalled();
});
});
});

View File

@@ -151,6 +151,7 @@ const qualityAppComponentTests = [
"NodeHealthDot",
"NodeStatusIndicator",
"PlanningModeModal.autosize",
"PluginManager.registry",
"PrChecksList",
"PrCreateModal",
"PrCreateModal.layout",
@@ -235,7 +236,7 @@ const qualityAppSettingsOnlyTests = ["app/components/__tests__/SettingsModal.tes
const qualityApiTests = [
// Critical HTTP/server behavior: auth, task/project/settings mutation,
// git/GitHub, agents, nodes, chat/files, realtime, and isolation guards.
"src/__tests__/{api-error,auth-middleware,auth-middleware-integration,chat-attachment-routes,chat-manager,chat-routes,file-service,github,github-webhooks,initialize,planning-flow-diagnostics-guardrail,pr-routes-auto-merge,pr-routes.contract,project-routes,project-store-resolver,register-git-github.pr-options-preflight-metadata,register-git-github.pr-resolve-conflicts,remote-access-routes,remote-auth,routes-agent-budget,routes-agent-keys,routes-agent-permissions,routes-agent-ratings,routes-agent-runs,routes-agent-soul-memory,routes-agents,routes-automation,routes-branch-groups,routes-git,routes-github,routes-merge-advance-push-origin,routes-nodes,routes-nodes-sync-contract,routes-planning,routes-secrets-sync,routes-settings,routes-task-commit-associations,routes-tasks,routes-tasks-deterministic-dedup,routes-tasks-duplicate-check,routes-tasks-explicit-duplicate-marker,server,server-static-assets,server-webhook,server.events,setup-routes,sse,sse-buffer,test-isolation-guard,update-check-route,websocket,recover-branch-binding-route}.test.ts",
"src/__tests__/{api-error,auth-middleware,auth-middleware-integration,chat-attachment-routes,chat-manager,chat-routes,file-service,github,github-webhooks,initialize,planning-flow-diagnostics-guardrail,pr-routes-auto-merge,pr-routes.contract,project-routes,project-store-resolver,register-git-github.pr-options-preflight-metadata,register-git-github.pr-resolve-conflicts,remote-access-routes,remote-auth,routes-agent-budget,routes-agent-keys,routes-agent-permissions,routes-agent-ratings,routes-agent-runs,routes-agent-soul-memory,routes-agents,routes-automation,routes-branch-groups,routes-git,routes-github,routes-merge-advance-push-origin,routes-nodes,routes-nodes-sync-contract,routes-planning,routes-plugin-registry,routes-secrets-sync,routes-settings,routes-task-commit-associations,routes-tasks,routes-tasks-deterministic-dedup,routes-tasks-duplicate-check,routes-tasks-explicit-duplicate-marker,server,server-static-assets,server-webhook,server.events,setup-routes,sse,sse-buffer,test-isolation-guard,update-check-route,websocket,recover-branch-binding-route}.test.ts",
"src/__tests__/dashboard-test-config-guard.test.ts",
"src/routes/__tests__/{custom-provider-routes,custom-providers,register-docker-node-routes,register-diagnostics-routes,stash-recovery-routes}.test.ts",
"scripts/__tests__/run-vitest-with-heap.test.ts",