Skip to content

Commit 3ab4ddc

Browse files
Add tests for URL fragment routing behavior with order-independent window mocking
This commit adds comprehensive tests for URL fragment routing functionality: - Tests that default hash is set based on server capabilities priority (Resources > Prompts > Tools > Ping) - Tests that hash is not set when disconnected - Tests that hash is cleared when disconnecting from a connected state - Includes e2e test to verify startup state behavior The tests implement proper window.location and window.history mocking using Jest best practices to avoid order-dependent test failures that occurred in CI environments: **Problem Solved:** - CI failures with "TypeError: Cannot redefine property: location" when tests ran in different orders - Multiple test files using Object.defineProperty() on window properties created non-configurable properties - Subsequent test files failed when trying to redefine the same global properties **Solution Applied:** - Uses Object.defineProperty() with configurable: true and writable: true - Preserves original window properties and restores them in afterAll cleanup - Avoids delete operations on window properties that caused TypeScript compilation errors - Follows Jest 30 official guidance for mocking browser globals in jsdom environment **Technical Approach:** ```javascript Object.defineProperty(window, "location", { writable: true, configurable: true, value: { ...originalLocation, hash: "", pathname: "/", search: "" }, }); ``` This approach ensures tests pass regardless of execution order and maintains compatibility with both Jest runtime and TypeScript compilation (tsc). 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 8ef8fd1 commit 3ab4ddc

File tree

2 files changed

+224
-0
lines changed

2 files changed

+224
-0
lines changed

client/e2e/startup-state.spec.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { test, expect } from "@playwright/test";
2+
3+
// Adjust the URL if your dev server runs on a different port
4+
const APP_URL = "http://localhost:6274/";
5+
6+
test.describe("Startup State", () => {
7+
test("should not navigate to a tab when Inspector first opens", async ({
8+
page,
9+
}) => {
10+
await page.goto(APP_URL);
11+
12+
// Check that there is no hash fragment in the URL
13+
const url = page.url();
14+
expect(url).not.toContain("#");
15+
});
16+
});
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
import { render, waitFor } from "@testing-library/react";
2+
import App from "../App";
3+
import { useConnection } from "../lib/hooks/useConnection";
4+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
5+
6+
// Mock auth dependencies first
7+
jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({
8+
auth: jest.fn(),
9+
}));
10+
11+
jest.mock("../lib/oauth-state-machine", () => ({
12+
OAuthStateMachine: jest.fn(),
13+
}));
14+
15+
jest.mock("../lib/auth", () => ({
16+
InspectorOAuthClientProvider: jest.fn().mockImplementation(() => ({
17+
tokens: jest.fn().mockResolvedValue(null),
18+
clear: jest.fn(),
19+
})),
20+
DebugInspectorOAuthClientProvider: jest.fn(),
21+
}));
22+
23+
// Mock the config utils
24+
jest.mock("../utils/configUtils", () => ({
25+
...jest.requireActual("../utils/configUtils"),
26+
getMCPProxyAddress: jest.fn(() => "http://localhost:6277"),
27+
getMCPProxyAuthToken: jest.fn(() => ({
28+
token: "",
29+
header: "X-MCP-Proxy-Auth",
30+
})),
31+
getInitialTransportType: jest.fn(() => "stdio"),
32+
getInitialSseUrl: jest.fn(() => "http://localhost:3001/sse"),
33+
getInitialCommand: jest.fn(() => "mcp-server-everything"),
34+
getInitialArgs: jest.fn(() => ""),
35+
initializeInspectorConfig: jest.fn(() => ({})),
36+
saveInspectorConfig: jest.fn(),
37+
}));
38+
39+
// Default connection state is disconnected
40+
const disconnectedConnectionState = {
41+
connectionStatus: "disconnected" as const,
42+
serverCapabilities: null,
43+
mcpClient: null,
44+
requestHistory: [],
45+
makeRequest: jest.fn(),
46+
sendNotification: jest.fn(),
47+
handleCompletion: jest.fn(),
48+
completionsSupported: false,
49+
connect: jest.fn(),
50+
disconnect: jest.fn(),
51+
};
52+
53+
// Connected state for tests that need an active connection
54+
const connectedConnectionState = {
55+
...disconnectedConnectionState,
56+
connectionStatus: "connected" as const,
57+
serverCapabilities: {},
58+
mcpClient: {
59+
request: jest.fn(),
60+
notification: jest.fn(),
61+
close: jest.fn(),
62+
} as unknown as Client,
63+
};
64+
65+
// Mock other dependencies
66+
jest.mock("../lib/hooks/useConnection", () => ({
67+
useConnection: jest.fn(() => disconnectedConnectionState),
68+
}));
69+
70+
jest.mock("../lib/hooks/useDraggablePane", () => ({
71+
useDraggablePane: () => ({
72+
height: 300,
73+
handleDragStart: jest.fn(),
74+
}),
75+
useDraggableSidebar: () => ({
76+
width: 320,
77+
isDragging: false,
78+
handleDragStart: jest.fn(),
79+
}),
80+
}));
81+
82+
jest.mock("../components/Sidebar", () => ({
83+
__esModule: true,
84+
default: () => <div>Sidebar</div>,
85+
}));
86+
87+
// Mock fetch
88+
global.fetch = jest.fn().mockResolvedValue({ json: () => Promise.resolve({}) });
89+
90+
// Mock window.location and window.history using Object.defineProperty
91+
const mockHistory = { replaceState: jest.fn() };
92+
93+
// Store original values for cleanup
94+
const originalLocation = window.location;
95+
const originalHistory = window.history;
96+
97+
// Set up location mock using Object.defineProperty to avoid TypeScript issues
98+
Object.defineProperty(window, "location", {
99+
writable: true,
100+
configurable: true,
101+
value: {
102+
...originalLocation,
103+
hash: "",
104+
pathname: "/",
105+
search: "",
106+
},
107+
});
108+
109+
Object.defineProperty(window, "history", {
110+
writable: true,
111+
configurable: true,
112+
value: {
113+
...originalHistory,
114+
replaceState: mockHistory.replaceState,
115+
},
116+
});
117+
118+
describe("App - URL Fragment Routing", () => {
119+
const mockUseConnection = jest.mocked(useConnection);
120+
121+
beforeEach(() => {
122+
jest.clearAllMocks();
123+
window.location.hash = "";
124+
// Override default to connected state, with capabilities for routing tests
125+
mockUseConnection.mockReturnValue({
126+
...connectedConnectionState,
127+
serverCapabilities: { resources: { listChanged: true, subscribe: true } },
128+
});
129+
});
130+
131+
afterAll(() => {
132+
// Restore original window properties
133+
Object.defineProperty(window, "location", {
134+
configurable: true,
135+
value: originalLocation,
136+
});
137+
Object.defineProperty(window, "history", {
138+
configurable: true,
139+
value: originalHistory,
140+
});
141+
});
142+
143+
test("sets default hash based on server capabilities priority", async () => {
144+
// Tab priority follows UI order: Resources | Prompts | Tools | Ping | Sampling | Roots | Auth
145+
// Server capabilities determine the first 3 tabs; if none are present, falls back to Ping
146+
const testCases = [
147+
{
148+
capabilities: { resources: { listChanged: true, subscribe: true } },
149+
expected: "resources",
150+
},
151+
{
152+
capabilities: { prompts: { listChanged: true, subscribe: true } },
153+
expected: "prompts",
154+
},
155+
{
156+
capabilities: { tools: { listChanged: true, subscribe: true } },
157+
expected: "tools",
158+
},
159+
{ capabilities: {}, expected: "ping" }, // No server capabilities - falls back to Ping
160+
];
161+
162+
for (const { capabilities, expected } of testCases) {
163+
window.location.hash = "";
164+
mockUseConnection.mockImplementationOnce(() => ({
165+
...connectedConnectionState,
166+
serverCapabilities: capabilities,
167+
}));
168+
169+
render(<App />);
170+
171+
await waitFor(() => {
172+
expect(window.location.hash).toBe(expected);
173+
});
174+
}
175+
});
176+
177+
test("does not set hash when disconnected", async () => {
178+
window.location.hash = "";
179+
mockUseConnection.mockImplementationOnce(() => disconnectedConnectionState);
180+
181+
render(<App />);
182+
183+
await waitFor(() => {
184+
expect(window.location.hash).toBe("");
185+
});
186+
});
187+
188+
test("clears hash when disconnected", async () => {
189+
window.location.hash = "";
190+
191+
// Start connected - use default connected state
192+
const { rerender } = render(<App />);
193+
194+
// Should set hash to resources (from default mock)
195+
await waitFor(() => {
196+
expect(window.location.hash).toBe("resources");
197+
});
198+
199+
// Now disconnect
200+
mockUseConnection.mockImplementationOnce(() => disconnectedConnectionState);
201+
rerender(<App />);
202+
203+
// Should clear the hash
204+
await waitFor(() => {
205+
expect(mockHistory.replaceState).toHaveBeenCalledWith(null, "", "/");
206+
});
207+
});
208+
});

0 commit comments

Comments
 (0)