Skip to content
Open
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
75 changes: 71 additions & 4 deletions e2e/server-catalog.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { test, expect } from "./fixtures/api-mock";
import { test, expect, MOCK_CSRF_TOKEN } from "./fixtures/api-mock";
import { APP } from "./utils/paths";
import type { CatalogServer } from "../src/generated/types";

const CATALOG_ROUTE = (url: URL) => /^(?:\/api)?\/v1\/catalog$/.test(url.pathname);
const REGISTER_ROUTE = (url: URL) =>
/^(?:\/api)?\/v1\/catalog\/open-notes\/register$/.test(url.pathname);

const OPEN_CONNECTED: CatalogServer = {
id: "open-connected",
name: "Globalping",
Expand All @@ -27,6 +31,20 @@ const OPEN_AVAILABLE: CatalogServer = {
is_registered: false,
};

const OPEN_SERVER = {
id: "open-notes",
name: "Public Notes",
category: "Productivity",
url: "https://notes.example/mcp",
auth_type: "Open",
provider: "Example",
description: "Search public notes and documents",
tags: ["search", "documents"],
transport: "STREAMABLEHTTP",
is_available: true,
is_registered: false,
};

const API_KEY_SERVER: CatalogServer = {
id: "api-key",
name: "Secret Service",
Expand Down Expand Up @@ -137,8 +155,9 @@ test.describe("Server catalog page", () => {
await page.goto(APP.SERVER_CATALOG);
await page.waitForLoadState("networkidle");

const viewButton = page.getByRole("button", { name: "View Globalping" });
await viewButton.click();
const actionsButton = page.getByRole("button", { name: "Actions for Globalping" });
await actionsButton.click();
await page.getByRole("menuitem", { name: "View details" }).click();

const dialog = page.getByRole("dialog");
await expect(dialog.getByRole("heading", { name: "Globalping" })).toBeVisible();
Expand All @@ -151,7 +170,7 @@ test.describe("Server catalog page", () => {

await page.keyboard.press("Escape");
await expect(dialog).toHaveCount(0);
await expect(viewButton).toBeFocused();
await expect(actionsButton).toBeFocused();

await page.getByRole("button", { name: "View Public Notes" }).click();
await expect(page.getByRole("dialog").getByText("Not connected")).toBeVisible();
Expand Down Expand Up @@ -203,4 +222,52 @@ test.describe("Server catalog page", () => {

await expect(page.getByRole("heading", { name: "Globalping" })).toBeVisible();
});

test("adds an open server and refreshes its card to Connected", async ({ page }) => {
let registered = false;
let registerCalls = 0;

await page.route(CATALOG_ROUTE, async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
servers: [{ ...OPEN_SERVER, is_registered: registered }],
total: 1,
categories: ["Productivity"],
auth_types: ["Open"],
providers: ["Example"],
all_tags: ["search", "documents"],
}),
});
});

await page.route(REGISTER_ROUTE, async (route) => {
expect(route.request().method()).toBe("POST");
expect(route.request().headers()["x-csrf-token"]).toBe(MOCK_CSRF_TOKEN);
registerCalls += 1;
registered = true;
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
success: true,
server_id: "gateway-public-notes",
message: "Server registered successfully",
}),
});
});

await page.goto(APP.SERVER_CATALOG);
await expect(page.getByRole("heading", { name: "Public Notes" })).toBeVisible();

await page.getByRole("button", { name: "Add Public Notes" }).click();

await expect.poll(() => registerCalls).toBe(1);
const catalog = page.getByRole("list", { name: "Catalog servers" });
await expect(catalog.getByText("Connected", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Add Public Notes" })).toHaveCount(0);
await expect(page.getByRole("button", { name: "View Public Notes" })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Actions for Public Notes" })).toBeVisible();
});
});
30 changes: 30 additions & 0 deletions src/api/catalog.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { http, HttpResponse } from "msw";

import { server } from "@/test/mocks/server";
import { registerCatalogServer } from "./catalog";

describe("registerCatalogServer", () => {
it("POSTs the URL-encoded catalog id through the API proxy", async () => {
let requestPath = "";
server.use(
http.post("*/api/v1/catalog/:catalogId/register", ({ request }) => {
requestPath = new URL(request.url).pathname;
return HttpResponse.json({
success: true,
server_id: "gateway-1",
message: "Registered",
});
}),
);

const result = await registerCatalogServer("server/id with space");

expect(requestPath).toBe("/api/v1/catalog/server%2Fid%20with%20space/register");
expect(result).toEqual({
success: true,
server_id: "gateway-1",
message: "Registered",
});
});
});
11 changes: 11 additions & 0 deletions src/api/catalog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { api } from "./client";
import type { CatalogServerRegisterResponse } from "@/generated/types";

/** Register an open catalog entry through the authenticated BFF proxy. */
export async function registerCatalogServer(
catalogId: string,
): Promise<CatalogServerRegisterResponse> {
return api.post<CatalogServerRegisterResponse>(
`/v1/catalog/${encodeURIComponent(catalogId)}/register`,
);
}
67 changes: 67 additions & 0 deletions src/components/server-catalog/CatalogResults.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { describe, expect, it, vi } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";

import type { CatalogServer } from "@/generated/types";
import { renderWithProviders } from "@/test/test-utils";
import { CatalogResults } from "./CatalogResults";

const availableServer: CatalogServer = {
id: "public-notes",
name: "Public Notes",
category: "Productivity",
url: "https://notes.example/mcp",
auth_type: "Open",
provider: "Example",
description: "Search public notes and documents",
tags: ["search", "documents"],
is_registered: false,
};

function catalogResults(server: CatalogServer, addingServerIds: ReadonlySet<string> = new Set()) {
return (
<CatalogResults
servers={[server]}
emptyStateMessageId="mcpServer.catalog.empty"
onView={vi.fn()}
onAdd={vi.fn()}
addingServerIds={addingServerIds}
/>
);
}

describe("CatalogResults", () => {
it("gives add states a server-specific accessible name", () => {
const { rerender } = renderWithProviders(catalogResults(availableServer));

expect(screen.getByRole("button", { name: "Add Public Notes" })).toBeInTheDocument();

rerender(catalogResults(availableServer, new Set([availableServer.id])));

expect(screen.getByRole("button", { name: "Adding Public Notes…" })).toBeDisabled();
});

it("moves focus from Add to Actions after registration", async () => {
const user = userEvent.setup();
const { rerender } = renderWithProviders(catalogResults(availableServer));

const addButton = screen.getByRole("button", { name: "Add Public Notes" });
await user.click(addButton);
expect(addButton).toHaveFocus();

rerender(catalogResults({ ...availableServer, is_registered: true }));

await waitFor(() =>
expect(screen.getByRole("button", { name: "Actions for Public Notes" })).toHaveFocus(),
);
});

it("aligns the Actions menu to the card's trailing edge", async () => {
const user = userEvent.setup();
renderWithProviders(catalogResults({ ...availableServer, is_registered: true }));

await user.click(screen.getByRole("button", { name: "Actions for Public Notes" }));

expect(await screen.findByRole("menu")).toHaveAttribute("data-align", "end");
});
});
Loading
Loading