Skip to content

Commit 8cd3cc6

Browse files
prince3235Marek Dano
authored andcommitted
[UI-REWRITE]: Improve unit test coverage for React UI client to 95% and remove dead code (#5561)
* test: Add comprehensive coverage for hooks and gateways components Signed-off-by: Prince Patel <princebpatel2005@gmail.com> * test: fix Resource MSW mocks and Server tests, clean dead code Signed-off-by: Prince Patel <princebpatel2005@gmail.com> * chore: remove singleFork and unused code Signed-off-by: Prince Patel <princebpatel2005@gmail.com> * style: auto-fix linting issues Signed-off-by: Prince Patel <princebpatel2005@gmail.com> * fix: resolve TypeScript errors from upstream type changes Signed-off-by: Prince Patel <princebpatel2005@gmail.com> * chore(ui-rewrite): clean up test-coverage PR per review and rebase onto epic/ui-rewrite Signed-off-by: Marek Dano <Marek.Dano@ibm.com> * test(ui-rewrite): consolidate duplicate test files and fix tsc build errors Signed-off-by: Marek Dano <Marek.Dano@ibm.com> * test(ui-rewrite): raise UI test coverage and enforce thresholds in CI Signed-off-by: Marek Dano <Marek.Dano@ibm.com> --------- Signed-off-by: Prince Patel <princebpatel2005@gmail.com> Signed-off-by: Marek Dano <Marek.Dano@ibm.com> Co-authored-by: Marek Dano <Marek.Dano@ibm.com>
1 parent dc9e546 commit 8cd3cc6

44 files changed

Lines changed: 5905 additions & 243 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/client-lint-test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,4 +118,4 @@ jobs:
118118
# -----------------------------------------------------------
119119
- name: 🧪 Run tests with Vitest
120120
working-directory: ./client
121-
run: npm run test:run
121+
run: npm run test:coverage

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ playwright-report-*.html
120120
# Vitest
121121
coverage/
122122
.vitest/
123+
client/vitest.config.ts.timestamp-*.mjs
123124

124125
# ========================================
125126
# Fuzzing

client/src/App.test.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect, vi } from "vitest";
2-
import { screen } from "@testing-library/react";
2+
import { screen, waitFor } from "@testing-library/react";
33
import userEvent from "@testing-library/user-event";
44
import { App } from "./App";
55
import { renderWithProviders } from "./test/test-utils";
@@ -51,4 +51,15 @@ describe("App", () => {
5151
});
5252
expect(gatewaysHeading).toBeInTheDocument();
5353
});
54+
55+
it("redirects bare /app to /app/", async () => {
56+
localStorage.clear();
57+
sessionStorage.clear();
58+
window.history.pushState({}, "", "/app");
59+
60+
renderWithProviders(<App />);
61+
62+
// The bare /app path renders <Redirect to="/app/" />, which normalizes the URL.
63+
await waitFor(() => expect(window.location.pathname).toBe("/app/"));
64+
});
5465
});

client/src/api/resources.test.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,4 +56,73 @@ describe("resourcesApi", () => {
5656
await expect(resourcesApi.updateTags("42", ["x"])).rejects.toThrow("HTTP 403");
5757
});
5858
});
59+
60+
const okJson = (body: unknown) =>
61+
new Response(JSON.stringify(body), {
62+
status: 200,
63+
headers: { "Content-Type": "application/json" },
64+
});
65+
66+
describe("create", () => {
67+
it("POSTs the resource to /resources", async () => {
68+
mockFetch.mockResolvedValueOnce(okJson({ id: "new-resource" }));
69+
70+
await resourcesApi.create({
71+
uri: "resource://example",
72+
name: "Example",
73+
content: "hello",
74+
} as Parameters<typeof resourcesApi.create>[0]);
75+
76+
expect(mockFetch).toHaveBeenCalledWith(
77+
expect.stringContaining("/resources"),
78+
expect.objectContaining({ method: "POST" }),
79+
);
80+
});
81+
});
82+
83+
describe("update", () => {
84+
it("PUTs the resource to /resources/:id", async () => {
85+
mockFetch.mockResolvedValueOnce(okJson({ id: "res-1" }));
86+
87+
await resourcesApi.update("res-1", { name: "Renamed" } as Parameters<
88+
typeof resourcesApi.update
89+
>[1]);
90+
91+
expect(mockFetch).toHaveBeenCalledWith(
92+
expect.stringContaining("/resources/res-1"),
93+
expect.objectContaining({ method: "PUT" }),
94+
);
95+
});
96+
97+
it("throws synchronously for an invalid ID", () => {
98+
expect(() => resourcesApi.update("../etc/passwd", {})).toThrow("Invalid resource ID format");
99+
});
100+
});
101+
102+
describe("delete", () => {
103+
it("DELETEs /resources/:id", async () => {
104+
mockFetch.mockResolvedValueOnce(okJson({}));
105+
106+
await resourcesApi.delete("res-1");
107+
108+
expect(mockFetch).toHaveBeenCalledWith(
109+
expect.stringContaining("/resources/res-1"),
110+
expect.objectContaining({ method: "DELETE" }),
111+
);
112+
});
113+
114+
it("throws synchronously for an invalid ID", () => {
115+
expect(() => resourcesApi.delete("bad/id")).toThrow("Invalid resource ID format");
116+
});
117+
});
118+
119+
describe("validateResourceId (via delete)", () => {
120+
it("rejects an empty id", () => {
121+
expect(() => resourcesApi.delete("")).toThrow(/^Invalid resource ID$/);
122+
});
123+
124+
it("rejects an id longer than 255 characters", () => {
125+
expect(() => resourcesApi.delete("a".repeat(256))).toThrow("Resource ID too long");
126+
});
127+
});
59128
});

client/src/api/servers.test.ts

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
22
import { serversApi } from "./servers";
3+
import type { GatewayTestRequest } from "@/generated/types";
34

45
describe("serversApi", () => {
56
const mockFetch = vi.fn();
@@ -255,6 +256,31 @@ describe("serversApi", () => {
255256
vi.useRealTimers();
256257
});
257258

259+
it("ignores non-oauth_callback messages from the popup", async () => {
260+
vi.useFakeTimers();
261+
const mockAuthWindow = { closed: false } as unknown as Window;
262+
vi.spyOn(window, "open").mockReturnValue(mockAuthWindow);
263+
264+
const promise = serversApi.triggerOAuthAuthorization("server-123");
265+
266+
// Correct source, but payloads that fail the null / type guard are ignored.
267+
const nullEvent = new MessageEvent("message", { data: null });
268+
Object.defineProperty(nullEvent, "source", { value: mockAuthWindow, writable: false });
269+
window.dispatchEvent(nullEvent);
270+
271+
const wrongTypeEvent = new MessageEvent("message", { data: { type: "something_else" } });
272+
Object.defineProperty(wrongTypeEvent, "source", { value: mockAuthWindow, writable: false });
273+
window.dispatchEvent(wrongTypeEvent);
274+
275+
// Neither settled the promise; closing the popup does.
276+
(mockAuthWindow as { closed: boolean }).closed = true;
277+
vi.advanceTimersByTime(1000);
278+
279+
await expect(promise).rejects.toThrow("OAuth authorization was cancelled");
280+
281+
vi.useRealTimers();
282+
});
283+
258284
it("rejects with cancellation message when user closes the popup", async () => {
259285
vi.useFakeTimers();
260286
const mockAuthWindow = { closed: false } as unknown as Window;
@@ -299,4 +325,193 @@ describe("serversApi", () => {
299325
);
300326
});
301327
});
328+
329+
describe("validateServerId (via get)", () => {
330+
it("throws 'Invalid server ID' for an empty id", () => {
331+
expect(() => serversApi.get("")).toThrow(/^Invalid server ID$/);
332+
});
333+
334+
it("throws 'Invalid server ID format' for an id with illegal characters", () => {
335+
expect(() => serversApi.get("bad/id")).toThrow("Invalid server ID format");
336+
});
337+
});
338+
339+
describe("list", () => {
340+
const jsonResponse = (body: unknown) =>
341+
new Response(JSON.stringify(body), {
342+
status: 200,
343+
headers: { "Content-Type": "application/json" },
344+
});
345+
346+
it("requests /gateways with only include_pagination by default", async () => {
347+
const body = { servers: [], pagination: { nextCursor: null } };
348+
mockFetch.mockResolvedValueOnce(jsonResponse(body));
349+
350+
const result = await serversApi.list();
351+
352+
expect(result).toEqual(body);
353+
const url = mockFetch.mock.calls[0][0] as string;
354+
expect(url).toContain("/gateways?");
355+
expect(url).toContain("include_pagination=true");
356+
expect(url).not.toContain("cursor=");
357+
expect(url).not.toContain("limit=");
358+
expect(url).not.toContain("include_inactive=");
359+
});
360+
361+
it("includes cursor, clamped limit, and include_inactive when provided", async () => {
362+
mockFetch.mockResolvedValueOnce(jsonResponse({ servers: [] }));
363+
364+
await serversApi.list({ cursor: "next-page", limit: 500, include_inactive: true });
365+
366+
const url = mockFetch.mock.calls[0][0] as string;
367+
expect(url).toContain("cursor=next-page");
368+
expect(url).toContain("limit=100"); // clamped to the max of 100
369+
expect(url).toContain("include_inactive=true");
370+
});
371+
372+
it("clamps a limit below 1 up to 1", async () => {
373+
mockFetch.mockResolvedValueOnce(jsonResponse({ servers: [] }));
374+
375+
await serversApi.list({ limit: 0 });
376+
377+
expect(mockFetch.mock.calls[0][0]).toContain("limit=1");
378+
});
379+
380+
it("falls back to a limit of 25 for a non-finite limit", async () => {
381+
mockFetch.mockResolvedValueOnce(jsonResponse({ servers: [] }));
382+
383+
await serversApi.list({ limit: Number.POSITIVE_INFINITY });
384+
385+
expect(mockFetch.mock.calls[0][0]).toContain("limit=25");
386+
});
387+
388+
it("resolves when passed an AbortSignal", async () => {
389+
mockFetch.mockResolvedValueOnce(jsonResponse({ servers: [] }));
390+
const controller = new AbortController();
391+
392+
await expect(serversApi.list({ signal: controller.signal })).resolves.toEqual({
393+
servers: [],
394+
});
395+
});
396+
});
397+
398+
describe("get", () => {
399+
const jsonResponse = (body: unknown) =>
400+
new Response(JSON.stringify(body), {
401+
status: 200,
402+
headers: { "Content-Type": "application/json" },
403+
});
404+
405+
it("fetches /gateways/:id and returns the server", async () => {
406+
const server = { id: "get-basic", name: "Basic" };
407+
mockFetch.mockResolvedValueOnce(jsonResponse(server));
408+
409+
const result = await serversApi.get("get-basic");
410+
411+
expect(result).toEqual(server);
412+
expect(mockFetch).toHaveBeenCalledWith(
413+
expect.stringContaining("/gateways/get-basic"),
414+
expect.objectContaining({ method: "GET" }),
415+
);
416+
});
417+
418+
it("returns the same in-flight promise for concurrent gets (request cache)", async () => {
419+
mockFetch.mockResolvedValueOnce(jsonResponse({ id: "get-cache" }));
420+
421+
const first = serversApi.get("get-cache");
422+
const second = serversApi.get("get-cache");
423+
424+
expect(first).toBe(second);
425+
await first;
426+
expect(mockFetch).toHaveBeenCalledTimes(1);
427+
});
428+
429+
it("evicts the cache on failure so a later call retries", async () => {
430+
mockFetch.mockResolvedValueOnce(
431+
new Response(JSON.stringify({ detail: "boom" }), {
432+
status: 500,
433+
headers: { "Content-Type": "application/json" },
434+
}),
435+
);
436+
437+
await expect(serversApi.get("get-evict")).rejects.toThrow("HTTP 500");
438+
439+
// The failed request was removed from the cache, so this refetches.
440+
mockFetch.mockResolvedValueOnce(jsonResponse({ id: "get-evict" }));
441+
const result = await serversApi.get("get-evict");
442+
443+
expect(result).toEqual({ id: "get-evict" });
444+
expect(mockFetch).toHaveBeenCalledTimes(2);
445+
});
446+
});
447+
448+
describe("testConnection", () => {
449+
it("POSTs /gateways/:id/test and returns the result", async () => {
450+
mockFetch.mockResolvedValueOnce(
451+
new Response(JSON.stringify({ success: true, message: "Reachable" }), {
452+
status: 200,
453+
headers: { "Content-Type": "application/json" },
454+
}),
455+
);
456+
457+
const result = await serversApi.testConnection("server-123");
458+
459+
expect(result).toEqual({ success: true, message: "Reachable" });
460+
expect(mockFetch).toHaveBeenCalledWith(
461+
expect.stringContaining("/gateways/server-123/test"),
462+
expect.objectContaining({ method: "POST" }),
463+
);
464+
});
465+
466+
it("throws synchronously for an invalid server ID", () => {
467+
expect(() => serversApi.testConnection("../etc/passwd")).toThrow("Invalid server ID format");
468+
});
469+
});
470+
471+
describe("delete", () => {
472+
it("DELETEs /gateways/:id", async () => {
473+
mockFetch.mockResolvedValueOnce(
474+
new Response(JSON.stringify({}), {
475+
status: 200,
476+
headers: { "Content-Type": "application/json" },
477+
}),
478+
);
479+
480+
await serversApi.delete("server-123");
481+
482+
expect(mockFetch).toHaveBeenCalledWith(
483+
expect.stringContaining("/gateways/server-123"),
484+
expect.objectContaining({ method: "DELETE" }),
485+
);
486+
});
487+
488+
it("throws synchronously for an invalid server ID", () => {
489+
expect(() => serversApi.delete("../etc/passwd")).toThrow("Invalid server ID format");
490+
});
491+
});
492+
493+
describe("testConnectivity", () => {
494+
it("POSTs the request to /v1/mcp-servers/test and returns the response", async () => {
495+
const upstream = { statusCode: 200, body: "ok" };
496+
mockFetch.mockResolvedValueOnce(
497+
new Response(JSON.stringify(upstream), {
498+
status: 200,
499+
headers: { "Content-Type": "application/json" },
500+
}),
501+
);
502+
503+
const request: GatewayTestRequest = {
504+
method: "GET",
505+
baseUrl: "https://example.com",
506+
path: "/health",
507+
};
508+
const result = await serversApi.testConnectivity(request);
509+
510+
expect(result).toEqual(upstream);
511+
expect(mockFetch).toHaveBeenCalledWith(
512+
expect.stringContaining("/v1/mcp-servers/test"),
513+
expect.objectContaining({ method: "POST", body: JSON.stringify(request) }),
514+
);
515+
});
516+
});
302517
});

0 commit comments

Comments
 (0)