Merge branch 'feat/add-resource-link-support' of https://github.com/nandsha/mcp-inspector into feat/add-resource-link-support

This commit is contained in:
Nandha Reddy 2025-07-08 22:34:23 +10:00
commit 7c9cfa2113
No known key found for this signature in database
GPG key ID: 09ED40FC0F563333
4 changed files with 1047 additions and 181 deletions

View file

@ -0,0 +1,256 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, it, expect, jest } from "@jest/globals";
import DynamicJsonForm from "../DynamicJsonForm";
import type { JsonSchemaType } from "@/utils/jsonUtils";
describe("DynamicJsonForm Array Fields", () => {
const renderSimpleArrayForm = (props = {}) => {
const defaultProps = {
schema: {
type: "array" as const,
description: "Test array field",
items: {
type: "string" as const,
description: "Array item",
},
} satisfies JsonSchemaType,
value: [],
onChange: jest.fn(),
};
return render(<DynamicJsonForm {...defaultProps} {...props} />);
};
const renderComplexArrayForm = (props = {}) => {
const defaultProps = {
schema: {
type: "array" as const,
description: "Test complex array field",
items: {
type: "object" as const,
properties: {
nested: { type: "object" as const },
},
},
} satisfies JsonSchemaType,
value: [],
onChange: jest.fn(),
};
return render(<DynamicJsonForm {...defaultProps} {...props} />);
};
describe("Simple Array Rendering", () => {
it("should render form fields for simple array items", () => {
renderSimpleArrayForm({ value: ["item1", "item2"] });
// Should show array description
expect(screen.getByText("Test array field")).toBeDefined();
expect(screen.getByText("Items: Array item")).toBeDefined();
// Should show input fields for each item
const inputs = screen.getAllByRole("textbox");
expect(inputs).toHaveLength(2);
expect(inputs[0]).toHaveProperty("value", "item1");
expect(inputs[1]).toHaveProperty("value", "item2");
// Should show remove buttons
const removeButtons = screen.getAllByText("Remove");
expect(removeButtons).toHaveLength(2);
// Should show add button
expect(screen.getByText("Add Item")).toBeDefined();
});
it("should add new items when Add Item button is clicked", () => {
const onChange = jest.fn();
renderSimpleArrayForm({ value: ["item1"], onChange });
const addButton = screen.getByText("Add Item");
fireEvent.click(addButton);
expect(onChange).toHaveBeenCalledWith(["item1", ""]);
});
it("should remove items when Remove button is clicked", () => {
const onChange = jest.fn();
renderSimpleArrayForm({ value: ["item1", "item2"], onChange });
const removeButtons = screen.getAllByText("Remove");
fireEvent.click(removeButtons[0]);
expect(onChange).toHaveBeenCalledWith(["item2"]);
});
it("should update item values when input changes", () => {
const onChange = jest.fn();
renderSimpleArrayForm({ value: ["item1"], onChange });
const input = screen.getByRole("textbox");
fireEvent.change(input, { target: { value: "updated item" } });
expect(onChange).toHaveBeenCalledWith(["updated item"]);
});
it("should handle empty arrays", () => {
renderSimpleArrayForm({ value: [] });
// Should show description and add button but no items
expect(screen.getByText("Test array field")).toBeDefined();
expect(screen.getByText("Add Item")).toBeDefined();
expect(screen.queryByText("Remove")).toBeNull();
});
});
describe("Complex Array Fallback", () => {
it("should render JSON editor for complex arrays", () => {
renderComplexArrayForm();
// Should render as JSON editor (textarea)
const textarea = screen.getByRole("textbox");
expect(textarea).toHaveProperty("type", "textarea");
// Should not show form-specific array controls
expect(screen.queryByText("Add Item")).toBeNull();
expect(screen.queryByText("Remove")).toBeNull();
});
});
describe("Array Type Detection", () => {
it("should detect string arrays as simple", () => {
const schema = {
type: "array" as const,
items: { type: "string" as const },
};
renderSimpleArrayForm({ schema, value: ["test"] });
// Should render form fields, not JSON editor
expect(screen.getByRole("textbox")).not.toHaveProperty(
"type",
"textarea",
);
});
it("should detect number arrays as simple", () => {
const schema = {
type: "array" as const,
items: { type: "number" as const },
};
renderSimpleArrayForm({ schema, value: [1, 2] });
// Should render form fields (number inputs)
const inputs = screen.getAllByRole("spinbutton");
expect(inputs).toHaveLength(2);
});
it("should detect boolean arrays as simple", () => {
const schema = {
type: "array" as const,
items: { type: "boolean" as const },
};
renderSimpleArrayForm({ schema, value: [true, false] });
// Should render form fields (checkboxes)
const checkboxes = screen.getAllByRole("checkbox");
expect(checkboxes).toHaveLength(2);
});
it("should detect simple object arrays as simple", () => {
const schema = {
type: "array" as const,
items: {
type: "object" as const,
properties: {
name: { type: "string" as const },
age: { type: "number" as const },
},
},
};
renderSimpleArrayForm({ schema, value: [{ name: "John", age: 30 }] });
// Should render form fields for simple objects
expect(screen.getByText("Add Item")).toBeDefined();
expect(screen.getByText("Remove")).toBeDefined();
});
});
describe("Array with Different Item Types", () => {
it("should handle integer array items", () => {
const schema = {
type: "array" as const,
items: { type: "integer" as const },
};
const onChange = jest.fn();
renderSimpleArrayForm({ schema, value: [1, 2], onChange });
const inputs = screen.getAllByRole("spinbutton");
expect(inputs).toHaveLength(2);
expect(inputs[0]).toHaveProperty("value", "1");
expect(inputs[1]).toHaveProperty("value", "2");
// Test adding new integer item
const addButton = screen.getByText("Add Item");
fireEvent.click(addButton);
expect(onChange).toHaveBeenCalledWith([1, 2, 0]);
});
it("should handle boolean array items", () => {
const schema = {
type: "array" as const,
items: { type: "boolean" as const },
};
const onChange = jest.fn();
renderSimpleArrayForm({ schema, value: [true, false], onChange });
const checkboxes = screen.getAllByRole("checkbox");
expect(checkboxes).toHaveLength(2);
expect(checkboxes[0]).toHaveProperty("checked", true);
expect(checkboxes[1]).toHaveProperty("checked", false);
// Test adding new boolean item
const addButton = screen.getByText("Add Item");
fireEvent.click(addButton);
expect(onChange).toHaveBeenCalledWith([true, false, false]);
});
});
describe("Array Item Descriptions", () => {
it("should show item description when available", () => {
const schema = {
type: "array" as const,
description: "List of names",
items: {
type: "string" as const,
description: "Person name",
},
};
renderSimpleArrayForm({ schema });
expect(screen.getByText("List of names")).toBeDefined();
expect(screen.getByText("Items: Person name")).toBeDefined();
});
it("should use item description in add button title", () => {
const schema = {
type: "array" as const,
items: {
type: "string" as const,
description: "Email address",
},
};
renderSimpleArrayForm({ schema });
const addButton = screen.getByText("Add Item");
expect(addButton).toHaveProperty("title", "Add new Email address");
});
it("should use default title when no item description", () => {
const schema = {
type: "array" as const,
items: { type: "string" as const },
};
renderSimpleArrayForm({ schema });
const addButton = screen.getByText("Add Item");
expect(addButton).toHaveProperty("title", "Add new item");
});
});
});

View file

@ -117,8 +117,11 @@ describe("DynamicJsonForm Complex Fields", () => {
const input = screen.getByRole("textbox");
expect(input).toHaveProperty("type", "textarea");
const buttons = screen.getAllByRole("button");
expect(buttons).toHaveLength(1);
expect(buttons[0]).toHaveProperty("textContent", "Format JSON");
expect(buttons).toHaveLength(2); // Copy JSON + Format JSON
const copyButton = screen.getByRole("button", { name: /copy json/i });
const formatButton = screen.getByRole("button", { name: /format json/i });
expect(copyButton).toBeTruthy();
expect(formatButton).toBeTruthy();
});
it("should pass changed values to onChange", () => {
@ -137,3 +140,72 @@ describe("DynamicJsonForm Complex Fields", () => {
});
});
});
describe("DynamicJsonForm Copy JSON Functionality", () => {
const mockWriteText = jest.fn(() => Promise.resolve());
beforeEach(() => {
jest.clearAllMocks();
Object.assign(navigator, {
clipboard: {
writeText: mockWriteText,
},
});
});
const renderFormInJsonMode = (props = {}) => {
const defaultProps = {
schema: {
type: "object",
properties: {
nested: { oneOf: [{ type: "string" }, { type: "integer" }] },
},
} as unknown as JsonSchemaType,
value: { nested: "test value" },
onChange: jest.fn(),
};
return render(<DynamicJsonForm {...defaultProps} {...props} />);
};
describe("Copy JSON Button", () => {
it("should render Copy JSON button when in JSON mode", () => {
renderFormInJsonMode();
const copyButton = screen.getByRole("button", { name: "Copy JSON" });
expect(copyButton).toBeTruthy();
});
it("should not render Copy JSON button when not in JSON mode", () => {
const simpleSchema = {
type: "string" as const,
description: "Test string field",
};
render(
<DynamicJsonForm
schema={simpleSchema}
value="test"
onChange={jest.fn()}
/>,
);
const copyButton = screen.queryByRole("button", { name: "Copy JSON" });
expect(copyButton).toBeNull();
});
it("should copy JSON to clipboard when clicked", async () => {
const testValue = { nested: "test value", number: 42 };
renderFormInJsonMode({ value: testValue });
const copyButton = screen.getByRole("button", { name: "Copy JSON" });
fireEvent.click(copyButton);
await waitFor(() => {
expect(mockWriteText).toHaveBeenCalledWith(
JSON.stringify(testValue, null, 2),
);
});
});
});
});

View file

@ -0,0 +1,226 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, it, expect, jest } from "@jest/globals";
import HistoryAndNotifications from "../HistoryAndNotifications";
import { ServerNotification } from "@modelcontextprotocol/sdk/types.js";
// Mock JsonView component
jest.mock("../JsonView", () => {
return function JsonView({ data }: { data: string }) {
return <div data-testid="json-view">{data}</div>;
};
});
describe("HistoryAndNotifications", () => {
const mockRequestHistory = [
{
request: JSON.stringify({ method: "test/method1", params: {} }),
response: JSON.stringify({ result: "success" }),
},
{
request: JSON.stringify({ method: "test/method2", params: {} }),
response: JSON.stringify({ result: "success" }),
},
];
const mockNotifications: ServerNotification[] = [
{
method: "notifications/message",
params: {
level: "info" as const,
message: "First notification",
},
},
{
method: "notifications/progress",
params: {
progressToken: "test-token",
progress: 50,
message: "Second notification",
},
},
];
it("renders history and notifications sections", () => {
render(
<HistoryAndNotifications
requestHistory={mockRequestHistory}
serverNotifications={mockNotifications}
/>,
);
expect(screen.getByText("History")).toBeTruthy();
expect(screen.getByText("Server Notifications")).toBeTruthy();
});
it("displays request history items with correct numbering", () => {
render(
<HistoryAndNotifications
requestHistory={mockRequestHistory}
serverNotifications={[]}
/>,
);
// Items should be numbered in reverse order (newest first)
expect(screen.getByText("2. test/method2")).toBeTruthy();
expect(screen.getByText("1. test/method1")).toBeTruthy();
});
it("displays server notifications with correct numbering", () => {
render(
<HistoryAndNotifications
requestHistory={[]}
serverNotifications={mockNotifications}
/>,
);
// Items should be numbered in reverse order (newest first)
expect(screen.getByText("2. notifications/progress")).toBeTruthy();
expect(screen.getByText("1. notifications/message")).toBeTruthy();
});
it("expands and collapses request items when clicked", () => {
render(
<HistoryAndNotifications
requestHistory={mockRequestHistory}
serverNotifications={[]}
/>,
);
const firstRequestHeader = screen.getByText("2. test/method2");
// Initially collapsed - should show ▶ arrows (there are multiple)
expect(screen.getAllByText("▶")).toHaveLength(2);
expect(screen.queryByText("Request:")).toBeNull();
// Click to expand
fireEvent.click(firstRequestHeader);
// Should now be expanded - one ▼ and one ▶
expect(screen.getByText("▼")).toBeTruthy();
expect(screen.getAllByText("▶")).toHaveLength(1);
expect(screen.getByText("Request:")).toBeTruthy();
expect(screen.getByText("Response:")).toBeTruthy();
});
it("expands and collapses notification items when clicked", () => {
render(
<HistoryAndNotifications
requestHistory={[]}
serverNotifications={mockNotifications}
/>,
);
const firstNotificationHeader = screen.getByText(
"2. notifications/progress",
);
// Initially collapsed
expect(screen.getAllByText("▶")).toHaveLength(2);
expect(screen.queryByText("Details:")).toBeNull();
// Click to expand
fireEvent.click(firstNotificationHeader);
// Should now be expanded
expect(screen.getByText("▼")).toBeTruthy();
expect(screen.getAllByText("▶")).toHaveLength(1);
expect(screen.getByText("Details:")).toBeTruthy();
});
it("maintains expanded state when new notifications are added", () => {
const { rerender } = render(
<HistoryAndNotifications
requestHistory={[]}
serverNotifications={mockNotifications}
/>,
);
// Find and expand the older notification (should be "1. notifications/message")
const olderNotificationHeader = screen.getByText(
"1. notifications/message",
);
fireEvent.click(olderNotificationHeader);
// Verify it's expanded
expect(screen.getByText("Details:")).toBeTruthy();
// Add a new notification at the beginning (simulating real behavior)
const newNotifications: ServerNotification[] = [
{
method: "notifications/resources/updated",
params: { uri: "file://test.txt" },
},
...mockNotifications,
];
// Re-render with new notifications
rerender(
<HistoryAndNotifications
requestHistory={[]}
serverNotifications={newNotifications}
/>,
);
// The original notification should still be expanded
// It's now numbered as "2. notifications/message" due to the new item
expect(screen.getByText("3. notifications/progress")).toBeTruthy();
expect(screen.getByText("2. notifications/message")).toBeTruthy();
expect(screen.getByText("1. notifications/resources/updated")).toBeTruthy();
// The originally expanded notification should still show its details
expect(screen.getByText("Details:")).toBeTruthy();
});
it("maintains expanded state when new requests are added", () => {
const { rerender } = render(
<HistoryAndNotifications
requestHistory={mockRequestHistory}
serverNotifications={[]}
/>,
);
// Find and expand the older request (should be "1. test/method1")
const olderRequestHeader = screen.getByText("1. test/method1");
fireEvent.click(olderRequestHeader);
// Verify it's expanded
expect(screen.getByText("Request:")).toBeTruthy();
expect(screen.getByText("Response:")).toBeTruthy();
// Add a new request at the beginning
const newRequestHistory = [
{
request: JSON.stringify({ method: "test/new_method", params: {} }),
response: JSON.stringify({ result: "new success" }),
},
...mockRequestHistory,
];
// Re-render with new request history
rerender(
<HistoryAndNotifications
requestHistory={newRequestHistory}
serverNotifications={[]}
/>,
);
// The original request should still be expanded
// It's now numbered as "2. test/method1" due to the new item
expect(screen.getByText("3. test/method2")).toBeTruthy();
expect(screen.getByText("2. test/method1")).toBeTruthy();
expect(screen.getByText("1. test/new_method")).toBeTruthy();
// The originally expanded request should still show its details
expect(screen.getByText("Request:")).toBeTruthy();
expect(screen.getByText("Response:")).toBeTruthy();
});
it("displays empty state messages when no data is available", () => {
render(
<HistoryAndNotifications requestHistory={[]} serverNotifications={[]} />,
);
expect(screen.getByText("No history yet")).toBeTruthy();
expect(screen.getByText("No notifications yet")).toBeTruthy();
});
});

View file

@ -111,6 +111,27 @@ describe("ToolsTab", () => {
});
});
it("should allow typing negative numbers", async () => {
renderToolsTab({
selectedTool: mockTools[0],
});
const input = screen.getByRole("spinbutton") as HTMLInputElement;
// Complete the negative number
fireEvent.change(input, { target: { value: "-42" } });
expect(input.value).toBe("-42");
const submitButton = screen.getByRole("button", { name: /run tool/i });
await act(async () => {
fireEvent.click(submitButton);
});
expect(defaultProps.callTool).toHaveBeenCalledWith(mockTools[0].name, {
num: -42,
});
});
it("should disable button and change text while tool is running", async () => {
// Create a promise that we can resolve later
let resolvePromise: ((value: unknown) => void) | undefined;