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:
@@ -126,6 +126,8 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("PluginManager registry browsing", () => {
|
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 () => {
|
it("renders registry entries with metadata", async () => {
|
||||||
await renderRegistry();
|
await renderRegistry();
|
||||||
|
|
||||||
@@ -165,6 +167,32 @@ describe("PluginManager registry browsing", () => {
|
|||||||
expect(fetchPlugins).toHaveBeenCalledTimes(2);
|
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 () => {
|
it("opens detail management for installed entries", async () => {
|
||||||
await renderRegistry();
|
await renderRegistry();
|
||||||
|
|
||||||
@@ -227,6 +255,42 @@ describe("PluginManager registry browsing", () => {
|
|||||||
expect(fetchPluginRegistry).toHaveBeenCalledTimes(2);
|
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 () => {
|
it("shows empty state when no registry entries match", async () => {
|
||||||
await renderRegistry([]);
|
await renderRegistry([]);
|
||||||
|
|
||||||
|
|||||||
@@ -130,6 +130,44 @@ describe("GET /api/plugins/registry", () => {
|
|||||||
|
|
||||||
await expect(buildRegistryPluginEntries({}, pluginStore)).resolves.toEqual([]);
|
await expect(buildRegistryPluginEntries({}, pluginStore)).resolves.toEqual([]);
|
||||||
await expect(buildRegistryPluginEntries({ plugins: [] }, 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 () => {
|
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 }),
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ const qualityAppComponentTests = [
|
|||||||
"NodeHealthDot",
|
"NodeHealthDot",
|
||||||
"NodeStatusIndicator",
|
"NodeStatusIndicator",
|
||||||
"PlanningModeModal.autosize",
|
"PlanningModeModal.autosize",
|
||||||
|
"PluginManager.registry",
|
||||||
"PrChecksList",
|
"PrChecksList",
|
||||||
"PrCreateModal",
|
"PrCreateModal",
|
||||||
"PrCreateModal.layout",
|
"PrCreateModal.layout",
|
||||||
@@ -235,7 +236,7 @@ const qualityAppSettingsOnlyTests = ["app/components/__tests__/SettingsModal.tes
|
|||||||
const qualityApiTests = [
|
const qualityApiTests = [
|
||||||
// Critical HTTP/server behavior: auth, task/project/settings mutation,
|
// Critical HTTP/server behavior: auth, task/project/settings mutation,
|
||||||
// git/GitHub, agents, nodes, chat/files, realtime, and isolation guards.
|
// 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/__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",
|
"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",
|
"scripts/__tests__/run-vitest-with-heap.test.ts",
|
||||||
|
|||||||
Reference in New Issue
Block a user