diff --git a/client/e2e/startup-state.spec.ts b/client/e2e/startup-state.spec.ts new file mode 100644 index 000000000..414ebd3e6 --- /dev/null +++ b/client/e2e/startup-state.spec.ts @@ -0,0 +1,16 @@ +import { test, expect } from "@playwright/test"; + +// Adjust the URL if your dev server runs on a different port +const APP_URL = "http://localhost:6274/"; + +test.describe("Startup State", () => { + test("should not navigate to a tab when Inspector first opens", async ({ + page, + }) => { + await page.goto(APP_URL); + + // Check that there is no hash fragment in the URL + const url = page.url(); + expect(url).not.toContain("#"); + }); +}); diff --git a/client/jest.config.cjs b/client/jest.config.cjs index 8652eda52..9b793eae6 100644 --- a/client/jest.config.cjs +++ b/client/jest.config.cjs @@ -32,4 +32,5 @@ module.exports = { "/e2e/", "\\.config\\.(js|ts|cjs|mjs)$", ], + randomize: true, }; diff --git a/client/src/App.tsx b/client/src/App.tsx index c4c89564b..3836aa9b8 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -465,10 +465,24 @@ const App = () => { }, [roots]); useEffect(() => { - if (!window.location.hash) { - window.location.hash = "resources"; + if (mcpClient && !window.location.hash) { + const defaultTab = serverCapabilities?.resources + ? "resources" + : serverCapabilities?.prompts + ? "prompts" + : serverCapabilities?.tools + ? "tools" + : "ping"; + window.location.hash = defaultTab; + } else if (!mcpClient && window.location.hash) { + // Clear hash when disconnected - completely remove the fragment + window.history.replaceState( + null, + "", + window.location.pathname + window.location.search, + ); } - }, []); + }, [mcpClient, serverCapabilities]); useEffect(() => { const handleHashChange = () => { diff --git a/client/src/__tests__/App.routing.test.tsx b/client/src/__tests__/App.routing.test.tsx new file mode 100644 index 000000000..dbd463f95 --- /dev/null +++ b/client/src/__tests__/App.routing.test.tsx @@ -0,0 +1,159 @@ +import { render, waitFor } from "@testing-library/react"; +import App from "../App"; +import { useConnection } from "../lib/hooks/useConnection"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; + +// Mock auth dependencies first +jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({ + auth: jest.fn(), +})); + +jest.mock("../lib/oauth-state-machine", () => ({ + OAuthStateMachine: jest.fn(), +})); + +jest.mock("../lib/auth", () => ({ + InspectorOAuthClientProvider: jest.fn().mockImplementation(() => ({ + tokens: jest.fn().mockResolvedValue(null), + clear: jest.fn(), + })), + DebugInspectorOAuthClientProvider: jest.fn(), +})); + +// Mock the config utils +jest.mock("../utils/configUtils", () => ({ + ...jest.requireActual("../utils/configUtils"), + getMCPProxyAddress: jest.fn(() => "http://localhost:6277"), + getMCPProxyAuthToken: jest.fn(() => ({ + token: "", + header: "X-MCP-Proxy-Auth", + })), + getInitialTransportType: jest.fn(() => "stdio"), + getInitialSseUrl: jest.fn(() => "http://localhost:3001/sse"), + getInitialCommand: jest.fn(() => "mcp-server-everything"), + getInitialArgs: jest.fn(() => ""), + initializeInspectorConfig: jest.fn(() => ({})), + saveInspectorConfig: jest.fn(), +})); + +// Default connection state is disconnected +const disconnectedConnectionState = { + connectionStatus: "disconnected" as const, + serverCapabilities: null, + mcpClient: null, + requestHistory: [], + makeRequest: jest.fn(), + sendNotification: jest.fn(), + handleCompletion: jest.fn(), + completionsSupported: false, + connect: jest.fn(), + disconnect: jest.fn(), +}; + +// Connected state for tests that need an active connection +const connectedConnectionState = { + ...disconnectedConnectionState, + connectionStatus: "connected" as const, + serverCapabilities: {}, + mcpClient: { + request: jest.fn(), + notification: jest.fn(), + close: jest.fn(), + } as unknown as Client, +}; + +// Mock required dependencies, but unrelated to routing. +jest.mock("../lib/hooks/useDraggablePane", () => ({ + useDraggablePane: () => ({ + height: 300, + handleDragStart: jest.fn(), + }), + useDraggableSidebar: () => ({ + width: 320, + isDragging: false, + handleDragStart: jest.fn(), + }), +})); + +jest.mock("../components/Sidebar", () => ({ + __esModule: true, + default: () =>
Sidebar
, +})); + +// Mock fetch +global.fetch = jest.fn().mockResolvedValue({ json: () => Promise.resolve({}) }); + +// Use an empty module mock, so that mock state can be reset between tests. +jest.mock("../lib/hooks/useConnection", () => ({ + useConnection: jest.fn(), +})); + +describe("App - URL Fragment Routing", () => { + const mockUseConnection = jest.mocked(useConnection); + + beforeEach(() => { + jest.restoreAllMocks(); + + // Inspector starts disconnected. + mockUseConnection.mockReturnValue(disconnectedConnectionState); + }); + + test("does not set hash when starting disconnected", async () => { + render(); + + await waitFor(() => { + expect(window.location.hash).toBe(""); + }); + }); + + test("sets default hash based on server capabilities priority", async () => { + // Tab priority follows UI order: Resources | Prompts | Tools | Ping | Sampling | Roots | Auth + // + // Server capabilities determine the first three tabs; if none are present, falls back to Ping. + + const testCases = [ + { + capabilities: { resources: { listChanged: true, subscribe: true } }, + expected: "#resources", + }, + { + capabilities: { prompts: { listChanged: true, subscribe: true } }, + expected: "#prompts", + }, + { + capabilities: { tools: { listChanged: true, subscribe: true } }, + expected: "#tools", + }, + { capabilities: {}, expected: "#ping" }, + ]; + + const { rerender } = render(); + + for (const { capabilities, expected } of testCases) { + window.location.hash = ""; + mockUseConnection.mockReturnValue({ + ...connectedConnectionState, + serverCapabilities: capabilities, + }); + + rerender(); + + await waitFor(() => { + expect(window.location.hash).toBe(expected); + }); + } + }); + + test("clears hash when disconnected", async () => { + // Start with a hash set (simulating a connection) + window.location.hash = "#resources"; + + // App starts disconnected (default mock) + render(); + + // Should clear the hash when disconnected + await waitFor(() => { + expect(window.location.hash).toBe(""); + }); + }); +});