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
16 changes: 16 additions & 0 deletions client/e2e/startup-state.spec.ts
Original file line number Diff line number Diff line change
@@ -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("#");
});
});
1 change: 1 addition & 0 deletions client/jest.config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,5 @@ module.exports = {
"/e2e/",
"\\.config\\.(js|ts|cjs|mjs)$",
],
randomize: true,
};
20 changes: 17 additions & 3 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand Down
159 changes: 159 additions & 0 deletions client/src/__tests__/App.routing.test.tsx
Original file line number Diff line number Diff line change
@@ -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: () => <div>Sidebar</div>,
}));

// 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(<App />);

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(<App />);

for (const { capabilities, expected } of testCases) {
window.location.hash = "";
mockUseConnection.mockReturnValue({
...connectedConnectionState,
serverCapabilities: capabilities,
});

rerender(<App />);

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(<App />);

// Should clear the hash when disconnected
await waitFor(() => {
expect(window.location.hash).toBe("");
});
});
});