inspector/clients/web/src/components/groups/RootsTable/RootsTable.test.tsx
cliffhall 3153f0305a fix: key resource list rows by position so duplicate URIs cannot collide
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>
2026-09-06 19:43:47 -04:00

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();
});
});