Merge branch 'main' into oleina/logging
This commit is contained in:
commit
e573e855f0
8 changed files with 212 additions and 3 deletions
16
client/e2e/startup-state.spec.ts
Normal file
16
client/e2e/startup-state.spec.ts
Normal file
|
|
@ -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("#");
|
||||
});
|
||||
});
|
||||
|
|
@ -32,4 +32,5 @@ module.exports = {
|
|||
"/e2e/",
|
||||
"\\.config\\.(js|ts|cjs|mjs)$",
|
||||
],
|
||||
randomize: true,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -466,10 +466,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 = () => {
|
||||
|
|
|
|||
159
client/src/__tests__/App.routing.test.tsx
Normal file
159
client/src/__tests__/App.routing.test.tsx
Normal file
|
|
@ -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("");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -48,6 +48,7 @@ const PromptsTab = ({
|
|||
ref: PromptReference | ResourceReference,
|
||||
argName: string,
|
||||
value: string,
|
||||
context?: Record<string, string>,
|
||||
) => Promise<string[]>;
|
||||
completionsSupported: boolean;
|
||||
promptContent: string;
|
||||
|
|
@ -73,6 +74,7 @@ const PromptsTab = ({
|
|||
},
|
||||
argName,
|
||||
value,
|
||||
promptArgs,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ const ResourcesTab = ({
|
|||
ref: ResourceReference | PromptReference,
|
||||
argName: string,
|
||||
value: string,
|
||||
context?: Record<string, string>,
|
||||
) => Promise<string[]>;
|
||||
completionsSupported: boolean;
|
||||
resourceContent: string;
|
||||
|
|
@ -94,6 +95,7 @@ const ResourcesTab = ({
|
|||
},
|
||||
key,
|
||||
value,
|
||||
templateValues,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ export function useCompletionState(
|
|||
ref: ResourceReference | PromptReference,
|
||||
argName: string,
|
||||
value: string,
|
||||
context?: Record<string, string>,
|
||||
signal?: AbortSignal,
|
||||
) => Promise<string[]>,
|
||||
completionsSupported: boolean = true,
|
||||
|
|
@ -66,6 +67,7 @@ export function useCompletionState(
|
|||
ref: ResourceReference | PromptReference,
|
||||
argName: string,
|
||||
value: string,
|
||||
context?: Record<string, string>,
|
||||
) => {
|
||||
if (!completionsSupported) {
|
||||
return;
|
||||
|
|
@ -82,10 +84,15 @@ export function useCompletionState(
|
|||
}));
|
||||
|
||||
try {
|
||||
if (context !== undefined) {
|
||||
delete context[argName];
|
||||
}
|
||||
|
||||
const values = await handleCompletion(
|
||||
ref,
|
||||
argName,
|
||||
value,
|
||||
context,
|
||||
abortController.signal,
|
||||
);
|
||||
|
||||
|
|
@ -97,6 +104,7 @@ export function useCompletionState(
|
|||
}));
|
||||
}
|
||||
} catch {
|
||||
console.error("completion failed");
|
||||
if (!abortController.signal.aborted) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
|
|
|
|||
|
|
@ -201,6 +201,7 @@ export function useConnection({
|
|||
ref: ResourceReference | PromptReference,
|
||||
argName: string,
|
||||
value: string,
|
||||
context?: Record<string, string>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string[]> => {
|
||||
if (!mcpClient || !completionsSupported) {
|
||||
|
|
@ -218,6 +219,12 @@ export function useConnection({
|
|||
},
|
||||
};
|
||||
|
||||
if (context) {
|
||||
request["params"]["context"] = {
|
||||
arguments: context,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await makeRequest(request, CompleteResultSchema, {
|
||||
signal,
|
||||
|
|
|
|||
Loading…
Reference in a new issue