The Resources sidebar keyed every row on its `uri` (and templates on `uriTemplate`), but nothing in MCP makes those unique — a server may return the same URI twice, and `ManagedListState.applyItems` replaces the list wholesale, so a repeat is what the server actually sent. React logged `Encountered two children with the same key` on every render, and a filtered-out row survived reconciliation instead of unmounting. This is the resources counterpart of #1957. The row identity is now the same `listRowKey(id, sourceIndex)` the Tools sidebar has used since then, extracted so the two formats cannot drift; `toolRowKey` delegates to it. Applied to all three Resources lists and to `RootsTable`, whose user-maintained list can collect a duplicate URI the same way. Selection stays keyed on the URI, deliberately: unlike a duplicated tool name (#2001), duplicate rows here denote the same resource — a `resources/read` takes the URI — so highlighting both is correct. Only the React key needs to tell them apart. Adds `ServerConfig.duplicateResourceUris` and the `duplicate-resource-uris-http.json` showcase config, the `resources/list` mirror of `duplicateToolNames`, so the defect has a fixture to reproduce it — appended rather than adjacent, for the reason #1957 documented. Closes #2206 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018yVgAJz4wpkS4mkGk7DQnp Signed-off-by: cliffhall <cliff@futurescale.com>
164 lines
5.9 KiB
TypeScript
164 lines
5.9 KiB
TypeScript
import { describe, it, expect, vi } from "vitest";
|
|
import userEvent from "@testing-library/user-event";
|
|
import type { Root } from "@modelcontextprotocol/client";
|
|
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
|
|
import { RootsTable } from "./RootsTable";
|
|
|
|
const sampleRoots: Root[] = [
|
|
{ name: "Project Source", uri: "file:///home/user/project/src" },
|
|
{ name: "Configuration", uri: "file:///home/user/project/config" },
|
|
];
|
|
|
|
const baseProps = {
|
|
roots: sampleRoots,
|
|
newRootDraft: { name: "", uri: "" },
|
|
onRemoveRoot: vi.fn(),
|
|
onNewRootDraftChange: vi.fn(),
|
|
onAddRoot: vi.fn(),
|
|
onBrowse: vi.fn(),
|
|
};
|
|
|
|
describe("RootsTable", () => {
|
|
it("renders the title, hint, and warning alert", () => {
|
|
renderWithMantine(<RootsTable {...baseProps} />);
|
|
expect(screen.getByText("Roots Configuration")).toBeInTheDocument();
|
|
expect(
|
|
screen.getByText("Filesystem roots exposed to the connected server:"),
|
|
).toBeInTheDocument();
|
|
expect(screen.getByText("Warning")).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders the roots table with provided roots", () => {
|
|
renderWithMantine(<RootsTable {...baseProps} />);
|
|
expect(screen.getByText("Project Source")).toBeInTheDocument();
|
|
expect(
|
|
screen.getByText("file:///home/user/project/src"),
|
|
).toBeInTheDocument();
|
|
expect(screen.getByText("Configuration")).toBeInTheDocument();
|
|
});
|
|
|
|
it("does not render the table when roots is empty", () => {
|
|
renderWithMantine(<RootsTable {...baseProps} roots={[]} />);
|
|
expect(screen.queryByRole("table")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("invokes onRemoveRoot when an X icon is clicked", async () => {
|
|
const user = userEvent.setup();
|
|
const onRemoveRoot = vi.fn();
|
|
renderWithMantine(
|
|
<RootsTable {...baseProps} onRemoveRoot={onRemoveRoot} />,
|
|
);
|
|
const removes = screen.getAllByRole("button", { name: "X" });
|
|
await user.click(removes[0]);
|
|
expect(onRemoveRoot).toHaveBeenCalledWith("file:///home/user/project/src");
|
|
});
|
|
|
|
it("invokes onAddRoot when the + Add Root button is clicked", async () => {
|
|
const user = userEvent.setup();
|
|
const onAddRoot = vi.fn();
|
|
renderWithMantine(<RootsTable {...baseProps} onAddRoot={onAddRoot} />);
|
|
await user.click(screen.getByRole("button", { name: "+ Add Root" }));
|
|
expect(onAddRoot).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("invokes onAddRoot when the bottom Add button is clicked", async () => {
|
|
const user = userEvent.setup();
|
|
const onAddRoot = vi.fn();
|
|
renderWithMantine(<RootsTable {...baseProps} onAddRoot={onAddRoot} />);
|
|
await user.click(screen.getByRole("button", { name: "Add" }));
|
|
expect(onAddRoot).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("invokes onBrowse when Browse is clicked", async () => {
|
|
const user = userEvent.setup();
|
|
const onBrowse = vi.fn();
|
|
renderWithMantine(<RootsTable {...baseProps} onBrowse={onBrowse} />);
|
|
await user.click(screen.getByRole("button", { name: "Browse" }));
|
|
expect(onBrowse).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("invokes onNewRootDraftChange when typing into the Name input", async () => {
|
|
const user = userEvent.setup();
|
|
const onNewRootDraftChange = vi.fn();
|
|
renderWithMantine(
|
|
<RootsTable {...baseProps} onNewRootDraftChange={onNewRootDraftChange} />,
|
|
);
|
|
await user.type(screen.getByLabelText("Name"), "x");
|
|
expect(onNewRootDraftChange).toHaveBeenCalledWith({ name: "x", uri: "" });
|
|
});
|
|
|
|
it("invokes onNewRootDraftChange when typing into the URI input", async () => {
|
|
const user = userEvent.setup();
|
|
const onNewRootDraftChange = vi.fn();
|
|
renderWithMantine(
|
|
<RootsTable {...baseProps} onNewRootDraftChange={onNewRootDraftChange} />,
|
|
);
|
|
await user.type(screen.getByLabelText("URI"), "y");
|
|
expect(onNewRootDraftChange).toHaveBeenCalledWith({ name: "", uri: "y" });
|
|
});
|
|
|
|
it("clears the Name and URI inputs via their Clear buttons", async () => {
|
|
const user = userEvent.setup();
|
|
const onNewRootDraftChange = vi.fn();
|
|
renderWithMantine(
|
|
<RootsTable
|
|
{...baseProps}
|
|
newRootDraft={{ name: "n", uri: "u" }}
|
|
onNewRootDraftChange={onNewRootDraftChange}
|
|
/>,
|
|
);
|
|
const clearButtons = screen.getAllByRole("button", { name: "Clear" });
|
|
expect(clearButtons).toHaveLength(2);
|
|
await user.click(clearButtons[0]);
|
|
expect(onNewRootDraftChange).toHaveBeenCalledWith({ name: "", uri: "u" });
|
|
await user.click(clearButtons[1]);
|
|
expect(onNewRootDraftChange).toHaveBeenCalledWith({ name: "n", uri: "" });
|
|
});
|
|
|
|
it("renders no Clear buttons when the draft fields are empty", () => {
|
|
renderWithMantine(<RootsTable {...baseProps} />);
|
|
expect(
|
|
screen.queryByRole("button", { name: "Clear" }),
|
|
).not.toBeInTheDocument();
|
|
});
|
|
|
|
// The roots list is user-maintained and nothing dedupes it, so the same URI
|
|
// can appear twice and collide on the row key (#2206).
|
|
it("renders both rows when a root URI repeats (#2206)", () => {
|
|
const consoleError = vi
|
|
.spyOn(console, "error")
|
|
.mockImplementation(() => {});
|
|
try {
|
|
renderWithMantine(
|
|
<RootsTable
|
|
{...baseProps}
|
|
roots={[
|
|
{ name: "First", uri: "file:///dupe" },
|
|
{ name: "Second", uri: "file:///dupe" },
|
|
]}
|
|
/>,
|
|
);
|
|
expect(screen.getByText("First")).toBeInTheDocument();
|
|
expect(screen.getByText("Second")).toBeInTheDocument();
|
|
const messages = consoleError.mock.calls.map((call) =>
|
|
call.map(String).join(" "),
|
|
);
|
|
expect(
|
|
messages.filter((message) => message.includes("same key")),
|
|
).toEqual([]);
|
|
} finally {
|
|
consoleError.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("renders the current draft values in the inputs", () => {
|
|
renderWithMantine(
|
|
<RootsTable
|
|
{...baseProps}
|
|
newRootDraft={{ name: "Test", uri: "file:///t" }}
|
|
/>,
|
|
);
|
|
expect(screen.getByDisplayValue("Test")).toBeInTheDocument();
|
|
expect(screen.getByDisplayValue("file:///t")).toBeInTheDocument();
|
|
});
|
|
});
|