inspector/clients/web/src/components/groups/ResourceControls/ResourceControls.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

586 lines
21 KiB
TypeScript

import { useState } from "react";
import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import type {
Resource,
ResourceTemplateType as ResourceTemplate,
} from "@modelcontextprotocol/client";
import type { InspectorResourceSubscription } from "@inspector/core/mcp/types.js";
import { NEVER_ACKNOWLEDGED_SUBSCRIPTION_MESSAGE } from "@inspector/core/mcp/subscriptionAck.js";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import {
ResourceControls,
type ResourceControlsProps,
} from "./ResourceControls";
import { noopPagination } from "../../../test/fixtures/pagination";
const sampleResources: Resource[] = [
{ name: "config.json", uri: "file:///config.json" },
{ name: "README.md", uri: "file:///README.md" },
];
const sampleTemplates: ResourceTemplate[] = [
{ name: "User Profile", uriTemplate: "file:///users/{userId}/profile" },
];
const sampleSubscriptions: InspectorResourceSubscription[] = [
{
resource: { name: "config.json", uri: "file:///config.json" },
lastUpdated: new Date("2026-03-17T10:30:00Z"),
},
];
const baseProps = {
resources: sampleResources,
templates: sampleTemplates,
subscriptions: sampleSubscriptions,
listChanged: false,
onRefreshList: vi.fn(),
onSearchChange: vi.fn(),
onOpenSectionsChange: vi.fn(),
onSelectUri: vi.fn(),
onSelectTemplate: vi.fn(),
onUnsubscribeResource: vi.fn(),
compact: false,
onCompactChange: vi.fn(),
pagination: noopPagination,
};
// ResourceControls is controlled: search text + accordion open-sections live in
// the parent (App, via ResourcesScreen) so they persist across tab navigation
// (#1417). This host holds that state so typing filters the lists and toggling
// the ListToggle drives the accordion, mirroring how App owns it. Props passed
// in override defaults; the stateful search/open-sections wiring is applied last
// so callers can still observe changes via the spied callbacks.
function ControlledResourceControls(props: Partial<ResourceControlsProps>) {
const [searchText, setSearchText] = useState<string>(props.searchText ?? "");
const [openSections, setOpenSections] = useState<string[]>(
props.openSections ?? ["resources", "templates", "subscriptions"],
);
return (
<ResourceControls
{...baseProps}
{...props}
searchText={searchText}
openSections={openSections}
onSearchChange={(value) => {
setSearchText(value);
props.onSearchChange?.(value);
}}
onOpenSectionsChange={(value) => {
setOpenSections(value);
props.onOpenSectionsChange?.(value);
}}
/>
);
}
describe("ResourceControls", () => {
it("renders title and section counts", () => {
renderWithMantine(<ResourceControls {...baseProps} />);
expect(screen.getByText("Resources")).toBeInTheDocument();
expect(screen.getByText("URIs (2)")).toBeInTheDocument();
expect(screen.getByText("Templates (1)")).toBeInTheDocument();
expect(screen.getByText("Subscriptions (1)")).toBeInTheDocument();
});
it("does not show ListChangedIndicator when listChanged is false", () => {
renderWithMantine(<ResourceControls {...baseProps} />);
expect(screen.queryByText("List updated")).not.toBeInTheDocument();
});
it("shows ListChangedIndicator and triggers onRefreshList when listChanged", async () => {
const user = userEvent.setup();
const onRefreshList = vi.fn();
renderWithMantine(
<ResourceControls
{...baseProps}
listChanged
onRefreshList={onRefreshList}
/>,
);
expect(screen.getByText("List updated")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Refresh" }));
expect(onRefreshList).toHaveBeenCalledTimes(1);
});
it("filters resources, templates, and subscriptions by search text", async () => {
const user = userEvent.setup();
renderWithMantine(<ControlledResourceControls />);
await user.type(screen.getByPlaceholderText("Search..."), "README");
expect(screen.getByText("URIs (1)")).toBeInTheDocument();
expect(screen.getByText("Templates (0)")).toBeInTheDocument();
expect(screen.getByText("Subscriptions (0)")).toBeInTheDocument();
});
it("invokes onSelectUri when a resource is clicked", async () => {
const user = userEvent.setup();
const onSelectUri = vi.fn();
renderWithMantine(
<ResourceControls {...baseProps} onSelectUri={onSelectUri} />,
);
await user.click(screen.getByText("README.md"));
expect(onSelectUri).toHaveBeenCalledWith("file:///README.md");
});
it("does not invoke onSelectUri when clicking the already-selected resource", async () => {
const user = userEvent.setup();
const onSelectUri = vi.fn();
renderWithMantine(
<ResourceControls
{...baseProps}
selectedUri="file:///README.md"
onSelectUri={onSelectUri}
/>,
);
await user.click(screen.getByText("README.md"));
expect(onSelectUri).not.toHaveBeenCalled();
});
it("invokes onSelectTemplate when a template is clicked", async () => {
const user = userEvent.setup();
const onSelectTemplate = vi.fn();
renderWithMantine(
<ResourceControls {...baseProps} onSelectTemplate={onSelectTemplate} />,
);
await user.click(screen.getByText("User Profile"));
expect(onSelectTemplate).toHaveBeenCalledWith(
"file:///users/{userId}/profile",
);
});
it("does not invoke onSelectTemplate when clicking the already-selected template", async () => {
const user = userEvent.setup();
const onSelectTemplate = vi.fn();
renderWithMantine(
<ResourceControls
{...baseProps}
selectedTemplateUri="file:///users/{userId}/profile"
onSelectTemplate={onSelectTemplate}
/>,
);
await user.click(screen.getByText("User Profile"));
expect(onSelectTemplate).not.toHaveBeenCalled();
});
it("invokes onUnsubscribeResource when Unsubscribe is clicked", async () => {
const user = userEvent.setup();
const onUnsubscribeResource = vi.fn();
renderWithMantine(
<ResourceControls
{...baseProps}
onUnsubscribeResource={onUnsubscribeResource}
/>,
);
await user.click(screen.getByRole("button", { name: "Unsubscribe" }));
expect(onUnsubscribeResource).toHaveBeenCalledWith("file:///config.json");
});
it("renders empty section counts when collections are empty", () => {
renderWithMantine(
<ResourceControls
{...baseProps}
resources={[]}
templates={[]}
subscriptions={[]}
/>,
);
expect(screen.getByText("URIs (0)")).toBeInTheDocument();
expect(screen.getByText("Templates (0)")).toBeInTheDocument();
expect(screen.getByText("Subscriptions (0)")).toBeInTheDocument();
});
it("seeds the accordion to all-open when compact is false", () => {
renderWithMantine(<ResourceControls {...baseProps} compact={false} />);
// All three sections open → ListToggle reads "Collapse all".
expect(
screen.getByRole("button", { name: "Collapse all" }),
).toBeInTheDocument();
});
it("seeds the accordion to all-closed when compact is true", () => {
renderWithMantine(<ResourceControls {...baseProps} compact />);
// All three sections closed → ListToggle reads "Expand all".
expect(
screen.getByRole("button", { name: "Expand all" }),
).toBeInTheDocument();
});
it("invokes onCompactChange with the new preference when the ListToggle is clicked", async () => {
const user = userEvent.setup();
const onCompactChange = vi.fn();
renderWithMantine(
<ControlledResourceControls
compact={false}
onCompactChange={onCompactChange}
/>,
);
// All sections start open → ListToggle reads "Collapse all"; clicking
// it collapses everything and records compact=true.
await user.click(screen.getByRole("button", { name: "Collapse all" }));
expect(onCompactChange).toHaveBeenCalledWith(true);
// Now collapsed → ListToggle reads "Expand all"; clicking re-expands
// and records compact=false.
await user.click(screen.getByRole("button", { name: "Expand all" }));
expect(onCompactChange).toHaveBeenLastCalledWith(false);
});
it("keeps an empty section collapsed even when it's in openSections", () => {
// All three sections requested open, but Subscriptions has no items: its
// control must render collapsed (aria-expanded=false) so the chevron points
// right, while the populated sections stay expanded (#1462).
renderWithMantine(
<ResourceControls
{...baseProps}
subscriptions={[]}
openSections={["resources", "templates", "subscriptions"]}
/>,
);
expect(screen.getByRole("button", { name: /URIs \(2\)/ })).toHaveAttribute(
"aria-expanded",
"true",
);
expect(
screen.getByRole("button", { name: /Templates \(1\)/ }),
).toHaveAttribute("aria-expanded", "true");
expect(
screen.getByRole("button", { name: /Subscriptions \(0\)/ }),
).toHaveAttribute("aria-expanded", "false");
});
it("preserves an open-but-empty section's intent when toggling another section", async () => {
// Subscriptions is open-in-intent but empty (excluded from the accordion's
// value). Collapsing a populated section must not drop subscriptions from
// the persisted intent, so it reopens once it has items again (#1462).
const user = userEvent.setup();
const onOpenSectionsChange = vi.fn();
renderWithMantine(
<ResourceControls
{...baseProps}
subscriptions={[]}
openSections={["resources", "templates", "subscriptions"]}
onOpenSectionsChange={onOpenSectionsChange}
/>,
);
await user.click(screen.getByRole("button", { name: /Templates \(1\)/ }));
// Mantine emits ["resources"]; "subscriptions" is merged back in.
expect(onOpenSectionsChange).toHaveBeenCalledWith(
expect.arrayContaining(["resources", "subscriptions"]),
);
expect(onOpenSectionsChange.mock.calls[0][0]).not.toContain("templates");
});
it("hides the Subscriptions section when subscriptionsSupported is false", () => {
renderWithMantine(
<ResourceControls {...baseProps} subscriptionsSupported={false} />,
);
expect(screen.getByText("URIs (2)")).toBeInTheDocument();
expect(screen.getByText("Templates (1)")).toBeInTheDocument();
expect(screen.queryByText(/Subscriptions/)).not.toBeInTheDocument();
});
it("shows the Subscriptions section by default (subscriptionsSupported omitted)", () => {
renderWithMantine(<ResourceControls {...baseProps} />);
expect(screen.getByText("Subscriptions (1)")).toBeInTheDocument();
});
it("reads 'Collapse all' with subscriptions hidden when the two visible sections are open", () => {
// allSections drops "subscriptions", so the remaining two open sections
// must still count as fully expanded — even if persisted openSections
// still carries a stale "subscriptions" entry.
renderWithMantine(
<ResourceControls
{...baseProps}
subscriptionsSupported={false}
compact={false}
openSections={["resources", "templates", "subscriptions"]}
/>,
);
expect(
screen.getByRole("button", { name: "Collapse all" }),
).toBeInTheDocument();
});
it("drops a stale 'subscriptions' entry from persisted state when subscriptions are unsupported", async () => {
// A "subscriptions" value persisted from a prior subscription-capable
// session must not be perpetually re-appended once the section is no longer
// rendered — toggling a visible section should emit it out of the open set.
const user = userEvent.setup();
const onOpenSectionsChange = vi.fn();
renderWithMantine(
<ResourceControls
{...baseProps}
subscriptionsSupported={false}
openSections={["resources", "templates", "subscriptions"]}
onOpenSectionsChange={onOpenSectionsChange}
/>,
);
await user.click(screen.getByRole("button", { name: /Templates \(1\)/ }));
expect(onOpenSectionsChange).toHaveBeenCalledTimes(1);
expect(onOpenSectionsChange.mock.calls[0][0]).not.toContain(
"subscriptions",
);
});
it("filters by resource title when title is set", async () => {
const user = userEvent.setup();
const resourcesWithTitle: Resource[] = [
{ name: "x", title: "Special Title", uri: "file:///x" },
{ name: "y", uri: "file:///y" },
];
renderWithMantine(
<ControlledResourceControls resources={resourcesWithTitle} />,
);
await user.type(screen.getByPlaceholderText("Search..."), "special");
expect(screen.getByText("URIs (1)")).toBeInTheDocument();
});
describe("modern listen-stream chrome (#1630)", () => {
const activeAck = {
active: true as const,
status: "acknowledged" as const,
honoredUris: ["file:///config.json"],
};
it("shows the stream badge in the section header on the modern era", () => {
renderWithMantine(
<ControlledResourceControls
protocolEra="modern"
subscriptionStreamState={activeAck}
/>,
);
// The labelled badge sits in the accordion header (next to the count).
expect(screen.getByText("Listening")).toBeInTheDocument();
});
it("renders no stream chrome on the legacy era even when a stream is active", () => {
renderWithMantine(
<ControlledResourceControls subscriptionStreamState={activeAck} />,
);
expect(screen.queryByText("Listening")).not.toBeInTheDocument();
});
it("renders no stream chrome on the modern era when the stream is inactive", () => {
renderWithMantine(
<ControlledResourceControls
protocolEra="modern"
subscriptionStreamState={{
active: false,
status: "ended",
honoredUris: [],
}}
/>,
);
expect(
screen.queryByText(/Listening|Stream ended/),
).not.toBeInTheDocument();
});
it("hides the stream badge when a search filters out all subscriptions", async () => {
const user = userEvent.setup();
renderWithMantine(
<ControlledResourceControls
protocolEra="modern"
subscriptionStreamState={activeAck}
/>,
);
expect(screen.getByText("Listening")).toBeInTheDocument();
// A query matching none of the (live) subscriptions empties the section;
// the badge hides with it rather than sitting next to "Subscriptions (0)".
await user.type(
screen.getByPlaceholderText("Search..."),
"no-such-resource",
);
expect(screen.getByText("Subscriptions (0)")).toBeInTheDocument();
expect(screen.queryByText("Listening")).not.toBeInTheDocument();
});
// A badge tooltip is not enough for this one: the server broke the listen
// contract and the user has to be told without hovering (#2097).
it("spells out a never-acknowledged close in the panel", () => {
renderWithMantine(
<ControlledResourceControls
protocolEra="modern"
subscriptionStreamState={{
active: true,
status: "never-acknowledged",
honoredUris: [],
}}
/>,
);
expect(screen.getByText("Not acknowledged")).toBeInTheDocument();
expect(
screen.getByText(NEVER_ACKNOWLEDGED_SUBSCRIPTION_MESSAGE),
).toBeInTheDocument();
});
it("shows no such notice while the stream is healthy", () => {
renderWithMantine(
<ControlledResourceControls
protocolEra="modern"
subscriptionStreamState={activeAck}
/>,
);
expect(
screen.queryByText(NEVER_ACKNOWLEDGED_SUBSCRIPTION_MESSAGE),
).not.toBeInTheDocument();
});
});
// A failed load is rendered above the list instead of leaving the panel
// empty, which is indistinguishable from a server that has none (#1953).
it("renders a failed load above the list and retries via onRefreshList", async () => {
const user = userEvent.setup();
const onRefreshList = vi.fn();
renderWithMantine(
<ResourceControls
{...baseProps}
loadError={new Error("codec said no")}
onRefreshList={onRefreshList}
/>,
);
expect(screen.getByText("Couldn't load resources")).toBeInTheDocument();
expect(screen.getByText("codec said no")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Retry" }));
expect(onRefreshList).toHaveBeenCalledTimes(1);
});
it("renders no load error by default", () => {
renderWithMantine(<ResourceControls {...baseProps} />);
expect(screen.queryByText(/Couldn't load/)).not.toBeInTheDocument();
});
// Nothing in the protocol makes `resources/list` URIs unique, and the store
// does not dedupe them either — `ManagedListState.applyItems` replaces the
// list wholesale, so a repeat is what the server actually sent. Keying a row
// on the URI alone therefore collides, which React warns about on every
// render and which lets a filtered-out row survive reconciliation (#2206).
describe("duplicate identifiers (#2206)", () => {
const duplicateUriResources: Resource[] = [
{ name: "app", title: "App First", uri: "ui://hello-world/app.html" },
{ name: "notes", title: "Notes", uri: "file:///notes.md" },
{ name: "app", title: "App Second", uri: "ui://hello-world/app.html" },
];
const duplicateTemplates: ResourceTemplate[] = [
{ name: "profile", title: "Profile First", uriTemplate: "file:///{id}" },
{ name: "logs", title: "Logs", uriTemplate: "log:///{day}" },
{ name: "profile", title: "Profile Second", uriTemplate: "file:///{id}" },
];
// Same URI, different names — the shape a search can tell apart. The rows
// display the last URI segment, so assertions count rows rather than text.
const duplicateSubscriptions: InspectorResourceSubscription[] = [
{
resource: { name: "alpha", uri: "ui://hello-world/app.html" },
lastUpdated: new Date("2026-03-17T10:30:00Z"),
},
{
resource: { name: "beta", uri: "file:///notes.md" },
lastUpdated: new Date("2026-03-17T10:31:00Z"),
},
{
resource: { name: "gamma", uri: "ui://hello-world/app.html" },
lastUpdated: new Date("2026-03-17T10:32:00Z"),
},
];
// The console warning was the reported symptom, so assert on it directly:
// React only emits it when two siblings share a key.
it("renders repeated URIs without a React key collision", () => {
const consoleError = vi
.spyOn(console, "error")
.mockImplementation(() => {});
try {
renderWithMantine(
<ResourceControls
{...baseProps}
resources={duplicateUriResources}
templates={duplicateTemplates}
subscriptions={duplicateSubscriptions}
/>,
);
const messages = consoleError.mock.calls.map((call) =>
call.map(String).join(" "),
);
expect(
messages.filter((message) => message.includes("same key")),
).toEqual([]);
// Every entry the server sent is still on screen, which is the other
// half of what the collision put at risk.
expect(screen.getByText("App First")).toBeInTheDocument();
expect(screen.getByText("App Second")).toBeInTheDocument();
} finally {
consoleError.mockRestore();
}
});
it("removes every non-matching row when resource URIs repeat", async () => {
const user = userEvent.setup();
renderWithMantine(
<ControlledResourceControls resources={duplicateUriResources} />,
);
await user.type(screen.getByPlaceholderText("Search..."), "notes");
expect(screen.getByText("Notes")).toBeInTheDocument();
// Both copies are orphaned by the collision on the broken build; the
// second is the one React reuses rather than unmounting.
expect(screen.queryByText("App First")).not.toBeInTheDocument();
expect(screen.queryByText("App Second")).not.toBeInTheDocument();
});
it("removes every non-matching row when uriTemplates repeat", async () => {
const user = userEvent.setup();
renderWithMantine(
<ControlledResourceControls
resources={[]}
subscriptions={[]}
templates={duplicateTemplates}
/>,
);
await user.type(screen.getByPlaceholderText("Search..."), "logs");
expect(screen.getByText("Logs")).toBeInTheDocument();
expect(screen.queryByText("Profile First")).not.toBeInTheDocument();
expect(screen.queryByText("Profile Second")).not.toBeInTheDocument();
});
it("renders one row per subscription when their URIs repeat", () => {
renderWithMantine(
<ResourceControls
{...baseProps}
resources={[]}
templates={[]}
subscriptions={duplicateSubscriptions}
/>,
);
expect(screen.getByText("Subscriptions (3)")).toBeInTheDocument();
expect(
screen.getAllByRole("button", { name: "Unsubscribe" }),
).toHaveLength(3);
});
it("removes every non-matching row when subscription URIs repeat", async () => {
const user = userEvent.setup();
renderWithMantine(
<ControlledResourceControls
resources={[]}
templates={[]}
subscriptions={duplicateSubscriptions}
/>,
);
await user.type(screen.getByPlaceholderText("Search..."), "beta");
expect(screen.getByText("Subscriptions (1)")).toBeInTheDocument();
expect(
screen.getAllByRole("button", { name: "Unsubscribe" }),
).toHaveLength(1);
});
});
});