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
125 changes: 124 additions & 1 deletion src/components/gateways/VirtualServerDetailsPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { http, HttpResponse } from "msw";
import { http, HttpResponse, delay } from "msw";
import { server as mswServer } from "@/test/mocks/server";
import { renderWithProviders as render } from "@/test/test-utils";
import { VirtualServerDetailsPanel } from "./VirtualServerDetailsPanel";
Expand Down Expand Up @@ -567,6 +567,129 @@ describe("VirtualServerDetailsPanel test connection tab", () => {
expect(screen.queryByText(/counts don.t match/i)).not.toBeInTheDocument();
});

it("suppresses the mismatch banner when one component query fails", async () => {
// If the resources query errors while tools succeeds, the aggregate must
// not silently treat the failed query as a count of 0 — that would flag
// a mismatch against a handshake that actually agrees.
const user = userEvent.setup();
mswServer.use(
http.get("*/servers/:id/tools", () =>
HttpResponse.json({ tools: [{ id: "t1", name: "tool-1", originalName: "tool-1" }] }),
),
http.get("*/servers/:id/resources", () =>
HttpResponse.json({ error: "boom" }, { status: 500 }),
),
http.post(HANDSHAKE_ENDPOINT, () =>
HttpResponse.json({
success: true,
latencyMs: 10,
componentCounts: { tools: 1, resources: 2 },
}),
),
);

render(
<VirtualServerDetailsPanel
server={makeServer()}
error={null}
open
onClose={vi.fn()}
onAddSources={vi.fn()}
/>,
);

await user.click(await screen.findByRole("tab", { name: "Try it" }));
await user.click(screen.getByRole("button", { name: /^test connection$/i }));

await waitFor(() => {
expect(screen.getByText(/^connection test$/i)).toBeInTheDocument();
});
expect(screen.queryByText(/counts don.t match/i)).not.toBeInTheDocument();
});

it("suppresses the mismatch banner when all component queries fail, even with a disabled component", async () => {
// When every component query fails, the panel would otherwise fall back
// to buildComponentItems(server), which carries no `enabled` field and
// can't exclude disabled components from the aggregate.
const user = userEvent.setup();
mswServer.use(
http.get("*/servers/:id/tools", () => HttpResponse.json({ error: "boom" }, { status: 500 })),
http.get("*/servers/:id/resources", () =>
HttpResponse.json({ error: "boom" }, { status: 500 }),
),
http.get("*/servers/:id/prompts", () =>
HttpResponse.json({ error: "boom" }, { status: 500 }),
),
http.post(HANDSHAKE_ENDPOINT, () =>
HttpResponse.json({
success: true,
latencyMs: 10,
componentCounts: { tools: 1 },
}),
),
);

render(
<VirtualServerDetailsPanel
server={makeServer({
associatedTools: ["tool-1", "tool-2"],
associatedToolIds: ["t1", "t2"],
})}
error={null}
open
onClose={vi.fn()}
onAddSources={vi.fn()}
/>,
);

await user.click(await screen.findByRole("tab", { name: "Try it" }));
await user.click(screen.getByRole("button", { name: /^test connection$/i }));

await waitFor(() => {
expect(screen.getByText(/^connection test$/i)).toBeInTheDocument();
});
expect(screen.queryByText(/counts don.t match/i)).not.toBeInTheDocument();
});

it("suppresses the mismatch banner while a component query is still loading", async () => {
// Before all three queries have resolved, the aggregate must not stand
// in as {0,0,0} and get compared against the handshake — that flags a
// spurious mismatch during the loading window, which is longest when a
// query hangs rather than erroring outright.
const user = userEvent.setup();
mswServer.use(
http.get("*/servers/:id/tools", async () => {
await delay("infinite");
return HttpResponse.json({ tools: [] });
}),
http.post(HANDSHAKE_ENDPOINT, () =>
HttpResponse.json({
success: true,
latencyMs: 10,
componentCounts: { tools: 1 },
}),
),
);

render(
<VirtualServerDetailsPanel
server={makeServer({ associatedTools: ["tool-1"], associatedToolIds: ["t1"] })}
error={null}
open
onClose={vi.fn()}
onAddSources={vi.fn()}
/>,
);

await user.click(await screen.findByRole("tab", { name: "Try it" }));
await user.click(screen.getByRole("button", { name: /^test connection$/i }));

await waitFor(() => {
expect(screen.getByText(/^connection test$/i)).toBeInTheDocument();
});
expect(screen.queryByText(/counts don.t match/i)).not.toBeInTheDocument();
});

it("resets to the try-it tab when a new server is selected", async () => {
const user = userEvent.setup();
const { rerender } = render(
Expand Down
55 changes: 40 additions & 15 deletions src/components/gateways/VirtualServerDetailsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -231,23 +231,29 @@ export function VirtualServerDetailsPanel({

// Fetch components data - only when panel is open and server exists
const fetchEnabled = open && Boolean(server?.id);
const { data: toolsData, isLoading: toolsLoading } = useQuery<{ tools: Tool[] }>(toolsPath, {
const {
data: toolsData,
isLoading: toolsLoading,
error: toolsError,
} = useQuery<{ tools: Tool[] }>(toolsPath, {
enabled: fetchEnabled,
});

const { data: resourcesData, isLoading: resourcesLoading } = useQuery<{ resources: Resource[] }>(
resourcesPath,
{
enabled: fetchEnabled,
},
);
const {
data: resourcesData,
isLoading: resourcesLoading,
error: resourcesError,
} = useQuery<{ resources: Resource[] }>(resourcesPath, {
enabled: fetchEnabled,
});

const { data: promptsData, isLoading: promptsLoading } = useQuery<{ prompts: Prompt[] }>(
promptsPath,
{
enabled: fetchEnabled,
},
);
const {
data: promptsData,
isLoading: promptsLoading,
error: promptsError,
} = useQuery<{ prompts: Prompt[] }>(promptsPath, {
enabled: fetchEnabled,
});

const fetchedComponents = useMemo((): ComponentWithType[] => {
const tools = Array.isArray(toolsData) ? toolsData : toolsData?.tools || [];
Expand Down Expand Up @@ -292,14 +298,33 @@ export function VirtualServerDetailsPanel({
// MCP endpoint, which only ever see enabled components — so a disabled
// component here must be excluded too, or a server with one disabled tool
// would show a permanent, spurious mismatch.
//
// Only computed from `fetchedComponents` (never the `buildComponentItems`
// fallback, which carries no `enabled` field and can't be filtered) and
// only when all three component queries succeeded — a failed query would
// otherwise silently contribute 0 and trigger a false mismatch. Also
// withheld until all three have resolved at least once, since `useQuery`
// never clears `data` on a later error and the pre-resolve state would
// otherwise compare a `{ tools: 0, resources: 0, prompts: 0 }` aggregate
// against a handshake that already has real counts.
const aggregatedComponentCounts = useMemo(() => {
if (toolsError || resourcesError || promptsError) return undefined;
if (!toolsData || !resourcesData || !promptsData) return undefined;
const counts: Record<string, number> = { tools: 0, resources: 0, prompts: 0 };
for (const component of allComponents) {
for (const component of fetchedComponents) {
if (component.enabled === false) continue;
counts[component.type] = (counts[component.type] ?? 0) + 1;
}
return counts;
}, [allComponents]);
}, [
fetchedComponents,
toolsError,
resourcesError,
promptsError,
toolsData,
resourcesData,
promptsData,
]);

const sourceIds = useMemo(
() =>
Expand Down
Loading