Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 136 additions & 0 deletions e2e/quick-add-server.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { test, expect } from "./fixtures/api-mock";
import { APP } from "./utils/paths";
import type { CatalogServer } from "../src/generated/types";

const DEEPWIKI: CatalogServer = {
id: "deepwiki",
name: "DeepWiki",
category: "RAG-as-a-Service",
url: "https://mcp.deepwiki.com/mcp",
auth_type: "Open",
provider: "Devin",
description: "Knowledge base with deep learning integration",
transport: null,
logo_url: "/static/catalog-icons/deepwiki.png",
};

const EXA_SEARCH: CatalogServer = {
id: "exa-search",
name: "Exa Search",
category: "RAG-as-a-Service",
url: "https://mcp.exa.ai/mcp",
auth_type: "Open",
provider: "Exa",
description: "AI-powered search engine for retrieving web content",
transport: "SSE",
};

async function mockCatalog(page: import("@playwright/test").Page, servers: CatalogServer[]) {
await page.route("**/v1/catalog*", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
servers,
total: servers.length,
categories: [],
auth_types: [],
providers: [],
}),
});
});
}

async function openQuickAddDialog(page: import("@playwright/test").Page) {
await page.route("**/gateways?*", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ gateways: [], nextCursor: null }),
});
});

await page.goto(APP.SERVERS);
await page.waitForLoadState("networkidle");

await page.getByRole("button", { name: /Connect/i }).click();
await page.getByRole("button", { name: /mcp server catalog/i }).click();
await expect(
page.getByRole("dialog").getByRole("heading", { name: "Connect MCP server" }),
).toBeVisible();
}

test.describe("Quick Add server dialog", () => {
test.beforeEach(async ({ page, apiMock }) => {
await apiMock.mockSession();
await apiMock.mockPermissions();

await page.addInitScript(() => {
sessionStorage.setItem("mcpgateway_token", "mock-token-12345");
});
});

test("pre-fills the connect form from a picked catalog entry and submits a new gateway", async ({
page,
}) => {
await mockCatalog(page, [DEEPWIKI, EXA_SEARCH]);
await openQuickAddDialog(page);

// Only the curated entries render, in the configured order.
await expect(page.getByRole("radio", { name: /DeepWiki/i })).toBeVisible();
await expect(page.getByRole("radio", { name: /Exa Search/i })).toBeVisible();

const continueButton = page.getByRole("button", { name: "Continue" });
await expect(continueButton).toBeDisabled();

// The radio input is visually hidden (sr-only); a real user clicks the visible
// card, which the associated <label> forwards to the input.
await page.getByText("DeepWiki", { exact: true }).click();
await expect(page.getByRole("radio", { name: /DeepWiki/i })).toBeChecked();
await expect(continueButton).toBeEnabled();
await continueButton.click();

await expect(page.getByRole("dialog")).not.toBeVisible();
await expect(page.getByLabel(/Name/i)).toHaveValue("DeepWiki");
await expect(page.getByLabel(/URL/i)).toHaveValue("https://mcp.deepwiki.com/mcp");
await expect(page.getByPlaceholder(/Add an optional description/i)).toHaveValue(
"Knowledge base with deep learning integration",
);
await expect(page.getByRole("radio", { name: "Streamable HTTP" })).toBeChecked();

const createRequest = page.waitForRequest(
(request) => request.url().includes("/gateways") && request.method() === "POST",
);
await page.route(
(url) => url.pathname.endsWith("/gateways") || url.pathname.endsWith("/api/gateways"),
async (route) => {
if (route.request().method() !== "POST") return route.fallback();
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ id: "new-gateway-1", name: "DeepWiki" }),
});
},
);

await page.getByRole("button", { name: /Connect server/i }).click();

const request = await createRequest;
const body = request.postDataJSON() as { name?: string; url?: string; transport?: string };
expect(body.name).toBe("DeepWiki");
expect(body.url).toBe("https://mcp.deepwiki.com/mcp");
expect(body.transport).toBe("STREAMABLEHTTP");
});

test("Browse full catalog closes the connect form and navigates to the full catalog", async ({
page,
}) => {
await mockCatalog(page, [DEEPWIKI]);
await openQuickAddDialog(page);

await page.getByRole("button", { name: "server catalog" }).click();

await expect(page).toHaveURL(new RegExp(APP.SERVER_CATALOG));
await expect(page.getByRole("dialog")).not.toBeVisible();
});
});
128 changes: 99 additions & 29 deletions src/components/mcp-servers/MCPServerForm.test.tsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,34 @@
import { describe, it, expect, vi, beforeEach, beforeAll, afterAll, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { render, screen, fireEvent, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
import { MCPServerForm } from "./MCPServerForm";
import { RouterProvider } from "@/router";
import { I18nProvider } from "@/i18n";
import { AuthProvider } from "@/auth/AuthContext";
import { QUICK_ADD_CATALOG_IDS } from "@/config/quickAddServers";

let mockHookActive = false;
let mockHookReturnValue: Record<string, unknown> | null = null;

vi.mock("@/hooks/useMCPServerForm", async (importOriginal) => {
const actual = (await importOriginal()) as {
useMCPServerForm: (serverId?: string) => Record<string, unknown>;
useMCPServerForm: (
serverId?: string,
initialValues?: Record<string, unknown>,
) => Record<string, unknown>;
};
return {
...actual,
useMCPServerForm: (serverId?: string) => {
useMCPServerForm: (serverId?: string, initialValues?: Record<string, unknown>) => {
if (mockHookActive) {
return {
...actual.useMCPServerForm(serverId),
...actual.useMCPServerForm(serverId, initialValues),
...mockHookReturnValue,
};
}
return actual.useMCPServerForm(serverId);
return actual.useMCPServerForm(serverId, initialValues);
},
};
});
Expand Down Expand Up @@ -65,6 +69,37 @@ const server = setupServer(
teams: [{ id: "team-personal", name: "Personal team", is_personal: true }],
});
}),
// Quick Add dialog's catalog fetch — one curated entry is enough to exercise selection/prefill.
http.get("/api/v1/catalog", () => {
return HttpResponse.json({
servers: [
{
id: QUICK_ADD_CATALOG_IDS[0],
name: "DeepWiki",
category: "RAG-as-a-Service",
url: "https://mcp.deepwiki.com/mcp",
auth_type: "Open",
provider: "Devin",
description: "Knowledge base with deep learning integration",
transport: null,
},
{
id: QUICK_ADD_CATALOG_IDS[1],
name: "Exa Search",
category: "RAG-as-a-Service",
url: "https://mcp.exa.ai/sse",
auth_type: "Open",
provider: "Exa",
description: "AI-powered search engine for retrieving web content",
transport: "SSE",
},
],
total: 2,
categories: [],
auth_types: [],
providers: [],
});
}),
);

beforeAll(() => server.listen({ onUnhandledRequest: "warn" }));
Expand Down Expand Up @@ -128,13 +163,6 @@ describe("MCPServerForm", () => {
renderWithRouter(<MCPServerForm isOpen={true} onToggle={vi.fn()} serverId="edit-123" />);
expect(screen.getByRole("button", { name: /Save changes/i })).toBeInTheDocument();
});

it("should render link to server catalog", () => {
renderWithRouter(<MCPServerForm {...defaultProps} />);

const catalogLink = screen.getByRole("button", { name: /mcp server catalog/i });
expect(catalogLink).toBeInTheDocument();
});
});

describe("Error States", () => {
Expand Down Expand Up @@ -612,23 +640,6 @@ describe("MCPServerForm", () => {
});
});

describe("Server Catalog Navigation", () => {
it("should navigate to server catalog when link is clicked", async () => {
const user = userEvent.setup();
const onToggle = vi.fn();
renderWithRouter(<MCPServerForm isOpen={true} onToggle={onToggle} />);

const catalogLink = screen.getByRole("button", { name: /mcp server catalog/i });
await user.click(catalogLink);

expect(onToggle).toHaveBeenCalledTimes(1);
// Verify navigation by checking window location
await waitFor(() => {
expect(window.location.pathname).toBe("/app/server-catalog");
});
});
});

describe("Accessibility", () => {
it("should have proper ARIA labels for transport type radio group", () => {
renderWithRouter(<MCPServerForm {...defaultProps} />);
Expand Down Expand Up @@ -1217,4 +1228,63 @@ describe("MCPServerForm", () => {
});
});
});

describe("Quick Add", () => {
it("navigates directly to the full catalog in edit mode instead of opening quick add", async () => {
const user = userEvent.setup();
const onToggleSpy = vi.fn();
renderWithRouter(<MCPServerForm isOpen={true} onToggle={onToggleSpy} serverId="edit-123" />);

await user.click(screen.getByRole("button", { name: /mcp server catalog/i }));

expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(onToggleSpy).toHaveBeenCalled();
expect(window.location.pathname).toBe("/app/server-catalog");
});

it("opens the dialog from the catalog link and pre-fills the form on selection", async () => {
const user = userEvent.setup();
renderWithRouter(<MCPServerForm {...defaultProps} />);

await user.click(screen.getByRole("button", { name: /mcp server catalog/i }));
const dialog = screen.getByRole("dialog");
expect(
within(dialog).getByRole("heading", { name: "Connect MCP server" }),
).toBeInTheDocument();

await user.click(screen.getByRole("radio", { name: /DeepWiki/i }));
await user.click(screen.getByRole("button", { name: "Continue" }));

expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(screen.getByLabelText(/Name/i)).toHaveValue("DeepWiki");
expect(screen.getByLabelText(/URL/i)).toHaveValue("https://mcp.deepwiki.com/mcp");
expect(screen.getByPlaceholderText(/Add an optional description/i)).toHaveValue(
"Knowledge base with deep learning integration",
);
expect(screen.getByRole("radio", { name: "Streamable HTTP" })).toBeChecked();
});

it("maps a catalog entry's declared SSE transport onto the transport radio", async () => {
const user = userEvent.setup();
renderWithRouter(<MCPServerForm {...defaultProps} />);

await user.click(screen.getByRole("button", { name: /mcp server catalog/i }));
await user.click(screen.getByRole("radio", { name: /Exa Search/i }));
await user.click(screen.getByRole("button", { name: "Continue" }));

expect(screen.getByRole("radio", { name: "SSE" })).toBeChecked();
});

it("navigates to the full catalog and closes the form when Browse full catalog is clicked", async () => {
const user = userEvent.setup();
const onToggleSpy = vi.fn();
renderWithRouter(<MCPServerForm isOpen={true} onToggle={onToggleSpy} />);

await user.click(screen.getByRole("button", { name: /mcp server catalog/i }));
await user.click(screen.getByRole("button", { name: "server catalog" }));

expect(onToggleSpy).toHaveBeenCalled();
expect(window.location.pathname).toBe("/app/server-catalog");
});
});
});
Loading
Loading