diff --git a/.gitignore b/.gitignore index 0f4928e801..3f9579f386 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,6 @@ node_modules server/build client/dist +client/coverage client/tsconfig.app.tsbuildinfo client/tsconfig.node.tsbuildinfo diff --git a/client/package.json b/client/package.json index 888495c565..f9026434df 100644 --- a/client/package.json +++ b/client/package.json @@ -18,7 +18,9 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest", + "test:coverage": "vitest run --coverage" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.0.3", @@ -40,20 +42,25 @@ }, "devDependencies": { "@eslint/js": "^9.11.1", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.1.0", "@types/node": "^22.7.5", "@types/react": "^18.3.10", "@types/react-dom": "^18.3.0", "@types/serve-handler": "^6.1.4", "@vitejs/plugin-react": "^4.3.2", + "@vitest/coverage-v8": "^2.1.8", "autoprefixer": "^10.4.20", "eslint": "^9.11.1", "eslint-plugin-react-hooks": "^5.1.0-rc.0", "eslint-plugin-react-refresh": "^0.4.12", "globals": "^15.9.0", + "jsdom": "^25.0.1", "postcss": "^8.4.47", "tailwindcss": "^3.4.13", "typescript": "^5.5.3", "typescript-eslint": "^8.7.0", - "vite": "^5.4.8" + "vite": "^5.4.8", + "vitest": "^2.1.8" } } diff --git a/client/src/App.tsx b/client/src/App.tsx index c225c305ae..f3791b2d0a 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -16,7 +16,7 @@ import { ResourceTemplate, Root, ServerNotification, - Tool + Tool, } from "@modelcontextprotocol/sdk/types.js"; import { useEffect, useRef, useState } from "react"; @@ -124,10 +124,7 @@ const App = () => { const [nextToolCursor, setNextToolCursor] = useState(); const progressTokenRef = useRef(0); - const { - height: historyPaneHeight, - handleDragStart - } = useDraggablePane(300); + const { height: historyPaneHeight, handleDragStart } = useDraggablePane(300); const { connectionStatus, @@ -136,7 +133,7 @@ const App = () => { requestHistory, makeRequest: makeConnectionRequest, sendNotification, - connect: connectMcpServer + connect: connectMcpServer, } = useConnection({ transportType, command, @@ -145,18 +142,21 @@ const App = () => { env, proxyServerUrl: PROXY_SERVER_URL, onNotification: (notification) => { - setNotifications(prev => [...prev, notification as ServerNotification]); + setNotifications((prev) => [...prev, notification as ServerNotification]); }, onStdErrNotification: (notification) => { - setStdErrNotifications(prev => [...prev, notification as StdErrNotification]); + setStdErrNotifications((prev) => [ + ...prev, + notification as StdErrNotification, + ]); }, onPendingRequest: (request, resolve, reject) => { - setPendingSampleRequests(prev => [ + setPendingSampleRequests((prev) => [ ...prev, - { id: nextRequestId.current++, request, resolve, reject } + { id: nextRequestId.current++, request, resolve, reject }, ]); }, - getRoots: () => rootsRef.current + getRoots: () => rootsRef.current, }); const makeRequest = async ( @@ -345,26 +345,40 @@ const App = () => { {mcpClient ? ( (window.location.hash = value)} > - + Resources - + Prompts - + Tools @@ -388,7 +402,9 @@ const App = () => {
- {!serverCapabilities?.resources && !serverCapabilities?.prompts && !serverCapabilities?.tools ? ( + {!serverCapabilities?.resources && + !serverCapabilities?.prompts && + !serverCapabilities?.tools ? (

The connected server does not support any MCP capabilities diff --git a/client/src/__tests__/App.test.tsx b/client/src/__tests__/App.test.tsx new file mode 100644 index 0000000000..1a5d9200d1 --- /dev/null +++ b/client/src/__tests__/App.test.tsx @@ -0,0 +1,220 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, act } from "@testing-library/react"; +import App from "../App"; +import { useConnection } from "../lib/hooks/useConnection"; +import { useDraggablePane } from "../lib/hooks/useDraggablePane"; + +// Mock URL params +const mockURLSearchParams = vi.fn(); +vi.stubGlobal("URLSearchParams", mockURLSearchParams); + +// Mock the hooks +vi.mock("../lib/hooks/useConnection", () => ({ + useConnection: vi.fn(), +})); + +vi.mock("../lib/hooks/useDraggablePane", () => ({ + useDraggablePane: vi.fn(), +})); + +// Mock fetch for config +const mockFetch = vi.fn(); +global.fetch = mockFetch; + +describe("App", () => { + beforeEach(() => { + // Reset all mocks + vi.clearAllMocks(); + + // Mock URL params + mockURLSearchParams.mockReturnValue({ + get: () => "3000", + }); + + // Mock fetch response + mockFetch.mockResolvedValue({ + json: () => + Promise.resolve({ + defaultEnvironment: {}, + defaultCommand: "test-command", + defaultArgs: "--test", + }), + }); + + // Mock useConnection hook + const mockUseConnection = useConnection as jest.Mock; + mockUseConnection.mockReturnValue({ + connectionStatus: "disconnected", + serverCapabilities: null, + mcpClient: null, + requestHistory: [], + makeRequest: vi.fn(), + sendNotification: vi.fn(), + connect: vi.fn(), + }); + + // Mock useDraggablePane hook + const mockUseDraggablePane = useDraggablePane as jest.Mock; + mockUseDraggablePane.mockReturnValue({ + height: 300, + handleDragStart: vi.fn(), + }); + }); + + it("renders initial disconnected state", async () => { + await act(async () => { + render(); + }); + expect( + screen.getByText("Connect to an MCP server to start inspecting"), + ).toBeInTheDocument(); + }); + + it("loads config on mount", async () => { + await act(async () => { + render(); + }); + expect(mockFetch).toHaveBeenCalledWith("http://localhost:3000/config"); + }); + + it("shows connected interface when mcpClient is available", async () => { + const mockUseConnection = useConnection as jest.Mock; + mockUseConnection.mockReturnValue({ + connectionStatus: "connected", + serverCapabilities: { + resources: true, + prompts: true, + tools: true, + }, + mcpClient: {}, + requestHistory: [], + makeRequest: vi.fn(), + sendNotification: vi.fn(), + connect: vi.fn(), + }); + + await act(async () => { + render(); + }); + + // Use more specific selectors + const resourcesTab = screen.getByRole("tab", { name: /resources/i }); + const promptsTab = screen.getByRole("tab", { name: /prompts/i }); + const toolsTab = screen.getByRole("tab", { name: /tools/i }); + + expect(resourcesTab).toBeInTheDocument(); + expect(promptsTab).toBeInTheDocument(); + expect(toolsTab).toBeInTheDocument(); + }); + + it("disables tabs based on server capabilities", async () => { + const mockUseConnection = useConnection as jest.Mock; + mockUseConnection.mockReturnValue({ + connectionStatus: "connected", + serverCapabilities: { + resources: false, + prompts: true, + tools: false, + }, + mcpClient: {}, + requestHistory: [], + makeRequest: vi.fn(), + sendNotification: vi.fn(), + connect: vi.fn(), + }); + + await act(async () => { + render(); + }); + + // Resources tab should be disabled + const resourcesTab = screen.getByRole("tab", { name: /resources/i }); + expect(resourcesTab).toHaveAttribute("disabled"); + + // Prompts tab should be enabled + const promptsTab = screen.getByRole("tab", { name: /prompts/i }); + expect(promptsTab).not.toHaveAttribute("disabled"); + + // Tools tab should be disabled + const toolsTab = screen.getByRole("tab", { name: /tools/i }); + expect(toolsTab).toHaveAttribute("disabled"); + }); + + it("shows notification count in sampling tab", async () => { + const mockUseConnection = useConnection as jest.Mock; + mockUseConnection.mockReturnValue({ + connectionStatus: "connected", + serverCapabilities: { sampling: true }, + mcpClient: {}, + requestHistory: [], + makeRequest: vi.fn(), + sendNotification: vi.fn(), + connect: vi.fn(), + onPendingRequest: (request, resolve, reject) => { + // Simulate a pending request + setPendingSampleRequests((prev) => [ + ...prev, + { id: 1, request, resolve, reject }, + ]); + }, + }); + + await act(async () => { + render(); + }); + + // Initially no notification count + const samplingTab = screen.getByRole("tab", { name: /sampling/i }); + expect(samplingTab.querySelector(".bg-red-500")).not.toBeInTheDocument(); + + // Simulate a pending request + await act(async () => { + mockUseConnection.mock.calls[0][0].onPendingRequest( + { method: "test", params: {} }, + () => {}, + () => {}, + ); + }); + + // Should show notification count + expect(samplingTab.querySelector(".bg-red-500")).toBeInTheDocument(); + }); + + it("persists command and args to localStorage", async () => { + const setItemSpy = vi.spyOn(Storage.prototype, "setItem"); + + await act(async () => { + render(); + }); + + // Simulate command change + await act(async () => { + const commandInput = screen.getByPlaceholderText(/command/i); + fireEvent.change(commandInput, { target: { value: "new-command" } }); + }); + + expect(setItemSpy).toHaveBeenCalledWith("lastCommand", "new-command"); + }); + + it("shows error message when server has no capabilities", async () => { + const mockUseConnection = useConnection as jest.Mock; + mockUseConnection.mockReturnValue({ + connectionStatus: "connected", + serverCapabilities: {}, + mcpClient: {}, + requestHistory: [], + makeRequest: vi.fn(), + sendNotification: vi.fn(), + connect: vi.fn(), + }); + + await act(async () => { + render(); + }); + expect( + screen.getByText( + "The connected server does not support any MCP capabilities", + ), + ).toBeInTheDocument(); + }); +}); diff --git a/client/src/__tests__/components/History.test.tsx b/client/src/__tests__/components/History.test.tsx new file mode 100644 index 0000000000..7d190bd786 --- /dev/null +++ b/client/src/__tests__/components/History.test.tsx @@ -0,0 +1,61 @@ +import { describe, it, expect } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import HistoryAndNotifications from "../../components/History"; + +describe("HistoryAndNotifications", () => { + const mockHistory = [ + { + request: JSON.stringify({ method: "test1" }), + response: JSON.stringify({ result: "output1" }), + }, + { + request: JSON.stringify({ method: "test2" }), + response: JSON.stringify({ result: "output2" }), + }, + ]; + + it("renders history items", () => { + render( + , + ); + const items = screen.getAllByText(/test[12]/, { exact: false }); + expect(items).toHaveLength(2); + }); + + it("expands history item when clicked", () => { + render( + , + ); + + const firstItem = screen.getByText(/test1/, { exact: false }); + fireEvent.click(firstItem); + expect(screen.getByText("Request:")).toBeInTheDocument(); + expect(screen.getByText(/output1/, { exact: false })).toBeInTheDocument(); + }); + + it("renders and expands server notifications", () => { + const notifications = [ + { method: "notify1", params: { data: "test data 1" } }, + { method: "notify2", params: { data: "test data 2" } }, + ]; + render( + , + ); + + const items = screen.getAllByText(/notify[12]/, { exact: false }); + expect(items).toHaveLength(2); + + fireEvent.click(items[0]); + expect(screen.getByText("Details:")).toBeInTheDocument(); + expect(screen.getByText(/test data/, { exact: false })).toBeInTheDocument(); + }); +}); diff --git a/client/src/__tests__/components/ListPane.test.tsx b/client/src/__tests__/components/ListPane.test.tsx new file mode 100644 index 0000000000..1ea25b708f --- /dev/null +++ b/client/src/__tests__/components/ListPane.test.tsx @@ -0,0 +1,84 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import ListPane from "../../components/ListPane"; + +describe("ListPane", () => { + type TestItem = { + id: number; + name: string; + }; + + const mockItems: TestItem[] = [ + { id: 1, name: "Item 1" }, + { id: 2, name: "Item 2" }, + ]; + + const defaultProps = { + items: mockItems, + listItems: vi.fn(), + clearItems: vi.fn(), + setSelectedItem: vi.fn(), + renderItem: (item: TestItem) => ( + <> + {item.name} + ID: {item.id} + + ), + title: "Test Items", + buttonText: "List Items", + }; + + it("renders title and buttons", () => { + render(); + expect(screen.getByText("Test Items")).toBeInTheDocument(); + expect(screen.getByText("List Items")).toBeInTheDocument(); + expect(screen.getByText("Clear")).toBeInTheDocument(); + }); + + it("renders list of items using renderItem prop", () => { + render(); + expect(screen.getByText("Item 1")).toBeInTheDocument(); + expect(screen.getByText("Item 2")).toBeInTheDocument(); + expect(screen.getByText("ID: 1")).toBeInTheDocument(); + expect(screen.getByText("ID: 2")).toBeInTheDocument(); + }); + + it("calls listItems when List Items button is clicked", () => { + const listItems = vi.fn(); + render(); + + fireEvent.click(screen.getByText("List Items")); + expect(listItems).toHaveBeenCalled(); + }); + + it("calls clearItems when Clear button is clicked", () => { + const clearItems = vi.fn(); + render(); + + fireEvent.click(screen.getByText("Clear")); + expect(clearItems).toHaveBeenCalled(); + }); + + it("calls setSelectedItem when an item is clicked", () => { + const setSelectedItem = vi.fn(); + render(); + + fireEvent.click(screen.getByText("Item 1")); + expect(setSelectedItem).toHaveBeenCalledWith(mockItems[0]); + }); + + it("disables Clear button when items array is empty", () => { + render(); + expect(screen.getByText("Clear")).toBeDisabled(); + }); + + it("disables List Items button when isButtonDisabled is true", () => { + render(); + expect(screen.getByText("List Items")).toBeDisabled(); + }); + + it("enables List Items button when isButtonDisabled is false", () => { + render(); + expect(screen.getByText("List Items")).not.toBeDisabled(); + }); +}); diff --git a/client/src/__tests__/components/PingTab.test.tsx b/client/src/__tests__/components/PingTab.test.tsx new file mode 100644 index 0000000000..75f16dff35 --- /dev/null +++ b/client/src/__tests__/components/PingTab.test.tsx @@ -0,0 +1,51 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import PingTab from "../../components/PingTab"; +import { Tabs } from "@/components/ui/tabs"; + +describe("PingTab", () => { + const renderWithTabs = (component: React.ReactElement) => { + return render({component}); + }; + + it("renders the MEGA PING button", () => { + renderWithTabs( {}} />); + const button = screen.getByRole("button", { name: /mega ping/i }); + expect(button).toBeInTheDocument(); + expect(button).toHaveClass( + "bg-gradient-to-r", + "from-purple-500", + "to-pink-500", + ); + }); + + it("includes rocket and explosion emojis", () => { + renderWithTabs( {}} />); + expect(screen.getByText("🚀")).toBeInTheDocument(); + expect(screen.getByText("💥")).toBeInTheDocument(); + }); + + it("calls onPingClick when button is clicked", () => { + const onPingClick = vi.fn(); + renderWithTabs(); + + fireEvent.click(screen.getByRole("button", { name: /mega ping/i })); + expect(onPingClick).toHaveBeenCalledTimes(1); + }); + + it("has animation classes for visual feedback", () => { + renderWithTabs( {}} />); + const button = screen.getByRole("button", { name: /mega ping/i }); + expect(button).toHaveClass( + "animate-pulse", + "hover:scale-110", + "transition", + ); + }); + + it("has focus styles for accessibility", () => { + renderWithTabs( {}} />); + const button = screen.getByRole("button", { name: /mega ping/i }); + expect(button).toHaveClass("focus:outline-none", "focus:ring-4"); + }); +}); diff --git a/client/src/__tests__/components/PromptsTab.test.tsx b/client/src/__tests__/components/PromptsTab.test.tsx new file mode 100644 index 0000000000..73aaa87189 --- /dev/null +++ b/client/src/__tests__/components/PromptsTab.test.tsx @@ -0,0 +1,98 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import PromptsTab from "../../components/PromptsTab"; +import type { Prompt } from "../../components/PromptsTab"; +import { Tabs } from "@/components/ui/tabs"; + +describe("PromptsTab", () => { + const mockPrompts: Prompt[] = [ + { + name: "test-prompt-1", + description: "Test prompt 1 description", + arguments: [ + { name: "arg1", description: "Argument 1", required: true }, + { name: "arg2", description: "Argument 2" }, + ], + }, + { + name: "test-prompt-2", + description: "Test prompt 2 description", + }, + ]; + + const defaultProps = { + prompts: mockPrompts, + listPrompts: vi.fn(), + clearPrompts: vi.fn(), + getPrompt: vi.fn(), + selectedPrompt: null, + setSelectedPrompt: vi.fn(), + promptContent: "", + nextCursor: null, + error: null, + }; + + const renderWithTabs = (component: React.ReactElement) => { + return render({component}); + }; + + it("renders list of prompts", () => { + renderWithTabs(); + expect(screen.getByText("test-prompt-1")).toBeInTheDocument(); + expect(screen.getByText("test-prompt-2")).toBeInTheDocument(); + }); + + it("shows prompt details when selected", () => { + const props = { + ...defaultProps, + selectedPrompt: mockPrompts[0], + }; + renderWithTabs(); + expect( + screen.getByText("Test prompt 1 description", { + selector: "p.text-sm.text-gray-600", + }), + ).toBeInTheDocument(); + expect(screen.getByText("arg1")).toBeInTheDocument(); + expect(screen.getByText("arg2")).toBeInTheDocument(); + }); + + it("handles argument input", () => { + const getPrompt = vi.fn(); + const props = { + ...defaultProps, + selectedPrompt: mockPrompts[0], + getPrompt, + }; + renderWithTabs(); + + const arg1Input = screen.getByPlaceholderText("Enter arg1"); + fireEvent.change(arg1Input, { target: { value: "test value" } }); + + const getPromptButton = screen.getByText("Get Prompt"); + fireEvent.click(getPromptButton); + + expect(getPrompt).toHaveBeenCalledWith("test-prompt-1", { + arg1: "test value", + }); + }); + + it("shows error message when error prop is provided", () => { + const props = { + ...defaultProps, + error: "Test error message", + }; + renderWithTabs(); + expect(screen.getByText("Test error message")).toBeInTheDocument(); + }); + + it("shows prompt content when provided", () => { + const props = { + ...defaultProps, + selectedPrompt: mockPrompts[0], + promptContent: "Test prompt content", + }; + renderWithTabs(); + expect(screen.getByText("Test prompt content")).toBeInTheDocument(); + }); +}); diff --git a/client/src/__tests__/components/ResourcesTab.test.tsx b/client/src/__tests__/components/ResourcesTab.test.tsx new file mode 100644 index 0000000000..c0e73a68f6 --- /dev/null +++ b/client/src/__tests__/components/ResourcesTab.test.tsx @@ -0,0 +1,135 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import ResourcesTab from "../../components/ResourcesTab"; +import { Tabs } from "@/components/ui/tabs"; +import type { + Resource, + ResourceTemplate, +} from "@modelcontextprotocol/sdk/types.js"; + +describe("ResourcesTab", () => { + const mockResources: Resource[] = [ + { uri: "file:///test1.txt", name: "Test 1" }, + { uri: "file:///test2.txt", name: "Test 2" }, + ]; + + const mockTemplates: ResourceTemplate[] = [ + { + name: "Template 1", + description: "Test template 1", + uriTemplate: "file:///test/{param1}/{param2}.txt", + }, + { + name: "Template 2", + description: "Test template 2", + uriTemplate: "file:///other/{name}.txt", + }, + ]; + + const defaultProps = { + resources: mockResources, + resourceTemplates: mockTemplates, + listResources: vi.fn(), + clearResources: vi.fn(), + listResourceTemplates: vi.fn(), + clearResourceTemplates: vi.fn(), + readResource: vi.fn(), + selectedResource: null, + setSelectedResource: vi.fn(), + resourceContent: "", + nextCursor: null, + nextTemplateCursor: null, + error: null, + }; + + const renderWithTabs = (component: React.ReactElement) => { + return render({component}); + }; + + it("renders resources list", () => { + renderWithTabs(); + expect(screen.getByText("Test 1")).toBeInTheDocument(); + expect(screen.getByText("Test 2")).toBeInTheDocument(); + }); + + it("renders templates list", () => { + renderWithTabs(); + expect(screen.getByText("Template 1")).toBeInTheDocument(); + expect(screen.getByText("Template 2")).toBeInTheDocument(); + }); + + it("shows resource content when resource is selected", () => { + const props = { + ...defaultProps, + selectedResource: mockResources[0], + resourceContent: "Test content", + }; + renderWithTabs(); + expect(screen.getByText("Test content")).toBeInTheDocument(); + }); + + it("shows template form when template is selected", () => { + renderWithTabs(); + + fireEvent.click(screen.getByText("Template 1")); + expect(screen.getByText("Test template 1")).toBeInTheDocument(); + expect(screen.getByLabelText("param1")).toBeInTheDocument(); + expect(screen.getByLabelText("param2")).toBeInTheDocument(); + }); + + it("fills template and reads resource", () => { + const readResource = vi.fn(); + const setSelectedResource = vi.fn(); + renderWithTabs( + , + ); + + // Select template + fireEvent.click(screen.getByText("Template 1")); + + // Fill in template parameters + fireEvent.change(screen.getByLabelText("param1"), { + target: { value: "value1" }, + }); + fireEvent.change(screen.getByLabelText("param2"), { + target: { value: "value2" }, + }); + + // Submit form + fireEvent.click(screen.getByText("Read Resource")); + + expect(readResource).toHaveBeenCalledWith("file:///test/value1/value2.txt"); + expect(setSelectedResource).toHaveBeenCalledWith( + expect.objectContaining({ + uri: "file:///test/value1/value2.txt", + name: "file:///test/value1/value2.txt", + }), + ); + }); + + it("shows error message when error prop is provided", () => { + const props = { + ...defaultProps, + error: "Test error message", + }; + renderWithTabs(); + expect(screen.getByText("Test error message")).toBeInTheDocument(); + }); + + it("refreshes resource content when refresh button is clicked", () => { + const readResource = vi.fn(); + const props = { + ...defaultProps, + selectedResource: mockResources[0], + readResource, + }; + renderWithTabs(); + + fireEvent.click(screen.getByText("Refresh")); + expect(readResource).toHaveBeenCalledWith(mockResources[0].uri); + }); +}); diff --git a/client/src/__tests__/components/RootsTab.test.tsx b/client/src/__tests__/components/RootsTab.test.tsx new file mode 100644 index 0000000000..b8186bef1f --- /dev/null +++ b/client/src/__tests__/components/RootsTab.test.tsx @@ -0,0 +1,80 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import RootsTab from "../../components/RootsTab"; +import { Tabs } from "@/components/ui/tabs"; +import type { Root } from "@modelcontextprotocol/sdk/types.js"; + +describe("RootsTab", () => { + const mockRoots: Root[] = [ + { uri: "file:///test/path1", name: "test1" }, + { uri: "file:///test/path2", name: "test2" }, + ]; + + const defaultProps = { + roots: mockRoots, + setRoots: vi.fn(), + onRootsChange: vi.fn(), + }; + + const renderWithTabs = (component: React.ReactElement) => { + return render({component}); + }; + + it("renders list of roots", () => { + renderWithTabs(); + expect(screen.getByDisplayValue("file:///test/path1")).toBeInTheDocument(); + expect(screen.getByDisplayValue("file:///test/path2")).toBeInTheDocument(); + }); + + it("adds a new root when Add Root button is clicked", () => { + const setRoots = vi.fn(); + renderWithTabs(); + + fireEvent.click(screen.getByText("Add Root")); + + expect(setRoots).toHaveBeenCalled(); + const updateFn = setRoots.mock.calls[0][0]; + const result = updateFn(mockRoots); + expect(result).toEqual([...mockRoots, { uri: "file://", name: "" }]); + }); + + it("removes a root when remove button is clicked", () => { + const setRoots = vi.fn(); + renderWithTabs(); + + const removeButtons = screen.getAllByRole("button", { + name: /remove root/i, + }); + fireEvent.click(removeButtons[0]); + + expect(setRoots).toHaveBeenCalled(); + const updateFn = setRoots.mock.calls[0][0]; + const result = updateFn(mockRoots); + expect(result).toEqual([mockRoots[1]]); + }); + + it("updates root URI when input changes", () => { + const setRoots = vi.fn(); + renderWithTabs(); + + const firstInput = screen.getByDisplayValue("file:///test/path1"); + fireEvent.change(firstInput, { target: { value: "file:///new/path" } }); + + expect(setRoots).toHaveBeenCalled(); + const updateFn = setRoots.mock.calls[0][0]; + const result = updateFn(mockRoots); + expect(result[0].uri).toBe("file:///new/path"); + expect(result[1]).toEqual(mockRoots[1]); + }); + + it("calls onRootsChange when Save Changes is clicked", () => { + const onRootsChange = vi.fn(); + renderWithTabs( + , + ); + + fireEvent.click(screen.getByText("Save Changes")); + + expect(onRootsChange).toHaveBeenCalled(); + }); +}); diff --git a/client/src/__tests__/components/SamplingTab.test.tsx b/client/src/__tests__/components/SamplingTab.test.tsx new file mode 100644 index 0000000000..d84186e536 --- /dev/null +++ b/client/src/__tests__/components/SamplingTab.test.tsx @@ -0,0 +1,91 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import SamplingTab from "../../components/SamplingTab"; +import { Tabs } from "@/components/ui/tabs"; +import type { CreateMessageRequest } from "@modelcontextprotocol/sdk/types.js"; + +describe("SamplingTab", () => { + const mockRequest: CreateMessageRequest = { + model: "test-model", + role: "user", + content: { + type: "text", + text: "Test message", + }, + }; + + const mockPendingRequests = [ + { id: 1, request: mockRequest }, + { + id: 2, + request: { + ...mockRequest, + content: { type: "text", text: "Another test" }, + }, + }, + ]; + + const defaultProps = { + pendingRequests: mockPendingRequests, + onApprove: vi.fn(), + onReject: vi.fn(), + }; + + const renderWithTabs = (component: React.ReactElement) => { + return render({component}); + }; + + it("renders empty state when no requests", () => { + renderWithTabs(); + expect(screen.getByText("No pending requests")).toBeInTheDocument(); + }); + + it("renders list of pending requests", () => { + renderWithTabs(); + expect(screen.getByText(/Test message/)).toBeInTheDocument(); + expect(screen.getByText(/Another test/)).toBeInTheDocument(); + }); + + it("shows request details in JSON format", () => { + renderWithTabs(); + const requestJson = screen.getAllByText((content) => + content.includes('"model": "test-model"'), + ); + expect(requestJson).toHaveLength(2); + }); + + it("calls onApprove with stub response when Approve is clicked", () => { + const onApprove = vi.fn(); + renderWithTabs(); + + const approveButtons = screen.getAllByText("Approve"); + fireEvent.click(approveButtons[0]); + + expect(onApprove).toHaveBeenCalledWith(1, { + model: "stub-model", + stopReason: "endTurn", + role: "assistant", + content: { + type: "text", + text: "This is a stub response.", + }, + }); + }); + + it("calls onReject when Reject is clicked", () => { + const onReject = vi.fn(); + renderWithTabs(); + + const rejectButtons = screen.getAllByText("Reject"); + fireEvent.click(rejectButtons[0]); + + expect(onReject).toHaveBeenCalledWith(1); + }); + + it("shows informational alert about sampling requests", () => { + renderWithTabs(); + expect( + screen.getByText(/When the server requests LLM sampling/), + ).toBeInTheDocument(); + }); +}); diff --git a/client/src/__tests__/setup/setup.ts b/client/src/__tests__/setup/setup.ts new file mode 100644 index 0000000000..e5794233ea --- /dev/null +++ b/client/src/__tests__/setup/setup.ts @@ -0,0 +1,34 @@ +import "@testing-library/jest-dom"; +import { expect, afterEach, vi } from "vitest"; +import { cleanup } from "@testing-library/react"; +import * as matchers from "@testing-library/jest-dom/matchers"; + +// @ts-ignore +expect.extend(matchers); + +// Mock window.matchMedia +Object.defineProperty(window, "matchMedia", { + writable: true, + value: vi.fn().mockImplementation((query) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), // Deprecated + removeListener: vi.fn(), // Deprecated + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), +}); + +// Mock window.location.hash +Object.defineProperty(window, "location", { + writable: true, + value: { hash: "" }, +}); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); + window.location.hash = ""; +}); diff --git a/client/src/components/RootsTab.tsx b/client/src/components/RootsTab.tsx index 33f60d5d96..b6e145fa6e 100644 --- a/client/src/components/RootsTab.tsx +++ b/client/src/components/RootsTab.tsx @@ -54,6 +54,7 @@ const RootsTab = ({ variant="destructive" size="sm" onClick={() => removeRoot(index)} + aria-label="Remove root" > diff --git a/client/src/components/Sidebar.tsx b/client/src/components/Sidebar.tsx index c716bd2ce1..17cd59d2b1 100644 --- a/client/src/components/Sidebar.tsx +++ b/client/src/components/Sidebar.tsx @@ -1,5 +1,12 @@ import { useState } from "react"; -import { Play, ChevronDown, ChevronRight, CircleHelp, Bug, Github } from "lucide-react"; +import { + Play, + ChevronDown, + ChevronRight, + CircleHelp, + Bug, + Github, +} from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { @@ -243,18 +250,33 @@ const Sidebar = ({

- + - + - - diff --git a/client/src/components/ToolsTab.tsx b/client/src/components/ToolsTab.tsx index 77a97e73dd..6b64c014e9 100644 --- a/client/src/components/ToolsTab.tsx +++ b/client/src/components/ToolsTab.tsx @@ -174,8 +174,7 @@ const ToolsTab = ({ } className="mt-1" /> - ) : - /* @ts-expect-error value type is currently unknown */ + ) : /* @ts-expect-error value type is currently unknown */ value.type === "object" ? (