inspector/clients/web/src/components/groups/ResourceLink/ResourceLink.test.tsx
Cliff Hall 8d4363d0ea
feat(web): expandable resource_link with read-on-demand (#1449) (#1456)
* feat(web): expandable resource_link with read-on-demand (#1449)

Replace the placeholder `resource_link` rendering in ContentViewer with a
dedicated, Mantine-native `ResourceLink` element that mirrors V1's behavior:

- Shows uri (monospace, link-styled), optional name/description, and a
  mimeType badge.
- Expand/collapse affordance (chevron + loading spinner) that is keyboard
  accessible (native button: role, aria-expanded, aria-label, Enter/Space).
- On first expand, reads the linked resource on demand via an injected
  callback and renders the full read result inline as formatted JSON under a
  green "Resource:" label, capped to a max height with scroll for large
  results. The fetched result is cached across collapse/expand.

The read handler is threaded as a "dumb component + callback as prop" through
ToolResultPanel → ToolsScreen → InspectorView, with App supplying a handler
that returns the `ReadResourceResult` from `inspectorClient.readResource`.

Also extract the shared `resourceContentsToBlock` helper into
`utils/resourceContents.ts` (previously private to ResourcePreviewPanel).

Tests: unit coverage for ResourceLink (collapsed/expanded/error/cache) and
the ContentViewer wiring, plus Storybook stories with play functions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(web): scroll large resource_link read results within the card

A plain ScrollArea with only `mah` does not scroll when its parent (the
content-sized card) has no definite height, so a large read result overflowed
the card instead of scrolling. Switch to ScrollArea.Autosize, which grows with
content up to the max height and then scrolls. Adds a LargeResult story
covering the bounded-scroll behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(web): make ResourceLink a group; extract ResourceLinkInfo element

ResourceLink is stateful (expand/loading/cached result) and fetches data via
a callback — group-like behavior, and the only `elements/` component using
useState. Move it to `groups/` and split out the pure-display metadata as a
new `elements/ResourceLinkInfo` element (uri/name/description/mime + an
optional action slot).

This keeps the layer dependencies clean (no element→group import, which
moving ResourceLink alone would have introduced):
- ContentViewer (element) renders ResourceLinkInfo (element) statically for
  `resource_link` blocks and no longer takes an onReadResource prop.
- ToolResultPanel (group) renders the interactive ResourceLink (group) for
  `resource_link` blocks, ContentViewer for the rest.
- ResourceLink (group) composes ResourceLinkInfo + ContentViewer.

Also reverts the now-single-consumer resourceContents.ts extraction, restoring
ResourcePreviewPanel's local toContentBlock.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(web): make ResourceLink read retryable and guard double-fetch

Address PR review: a failed read is no longer cached as terminal — only
a successful result is cached, so collapsing and re-expanding retries the
read after a transient failure. Add a loading guard so toggling while a
read is in flight does not fire a redundant second read.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style(web): prettier formatting for ResourceLink test

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: remove accidentally-committed build artifacts; gitignore clients/*/build

A stray 'git add -A' swept 8 compiled build files into d25c9382. Remove
them from tracking and add clients/*/build to .gitignore so client build
output is never committed again.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 17:15:25 -04:00

172 lines
5.7 KiB
TypeScript

import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import type { ReadResourceResult } from "@modelcontextprotocol/sdk/types.js";
import {
renderWithMantine,
screen,
waitFor,
} from "../../../test/renderWithMantine";
import { ResourceLink } from "./ResourceLink";
const URI = "file:///docs/readme.md";
const readResult = (text: string): ReadResourceResult => ({
contents: [{ uri: URI, mimeType: "text/plain", text }],
});
describe("ResourceLink", () => {
it("renders uri, name, description, and mimeType", () => {
renderWithMantine(
<ResourceLink
uri={URI}
name="Readme"
description="The project readme"
mimeType="text/markdown"
/>,
);
expect(screen.getByText(URI)).toBeInTheDocument();
expect(screen.getByText("Readme")).toBeInTheDocument();
expect(screen.getByText("The project readme")).toBeInTheDocument();
expect(screen.getByText("text/markdown")).toBeInTheDocument();
});
it("is not interactive without onReadResource", () => {
renderWithMantine(<ResourceLink uri={URI} />);
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
it("reads the resource on demand and renders the result inline", async () => {
const user = userEvent.setup();
const onReadResource = vi.fn().mockResolvedValue(readResult("hello body"));
renderWithMantine(
<ResourceLink uri={URI} onReadResource={onReadResource} />,
);
const button = screen.getByRole("button", {
name: `Expand resource ${URI}`,
});
expect(button).toHaveAttribute("aria-expanded", "false");
await user.click(button);
expect(onReadResource).toHaveBeenCalledWith(URI);
await waitFor(() =>
expect(screen.getByText("Resource:")).toBeInTheDocument(),
);
// The full read result is rendered as formatted JSON.
expect(screen.getByText(/"hello body"/)).toBeInTheDocument();
expect(
screen.getByRole("button", { name: `Collapse resource ${URI}` }),
).toHaveAttribute("aria-expanded", "true");
});
it("collapses and re-expands without re-reading (result cached)", async () => {
const user = userEvent.setup();
const onReadResource = vi.fn().mockResolvedValue(readResult("cached body"));
renderWithMantine(
<ResourceLink uri={URI} onReadResource={onReadResource} />,
);
await user.click(
screen.getByRole("button", { name: `Expand resource ${URI}` }),
);
await waitFor(() =>
expect(screen.getByText(/"cached body"/)).toBeInTheDocument(),
);
// Collapse — content is hidden.
await user.click(
screen.getByRole("button", { name: `Collapse resource ${URI}` }),
);
expect(screen.queryByText("Resource:")).not.toBeInTheDocument();
// Re-expand — content returns without a second read.
await user.click(
screen.getByRole("button", { name: `Expand resource ${URI}` }),
);
expect(screen.getByText(/"cached body"/)).toBeInTheDocument();
expect(onReadResource).toHaveBeenCalledTimes(1);
});
it("shows an alert when the read fails", async () => {
const user = userEvent.setup();
const onReadResource = vi.fn().mockRejectedValue(new Error("nope"));
renderWithMantine(
<ResourceLink uri={URI} onReadResource={onReadResource} />,
);
await user.click(screen.getByRole("button"));
await waitFor(() =>
expect(screen.getByText("Failed to read resource")).toBeInTheDocument(),
);
expect(screen.getByText("nope")).toBeInTheDocument();
});
it("retries the read on re-expand after a failure", async () => {
const user = userEvent.setup();
const onReadResource = vi
.fn()
.mockRejectedValueOnce(new Error("transient"))
.mockResolvedValueOnce(readResult("recovered body"));
renderWithMantine(
<ResourceLink uri={URI} onReadResource={onReadResource} />,
);
// First expand fails.
await user.click(
screen.getByRole("button", { name: `Expand resource ${URI}` }),
);
await waitFor(() =>
expect(screen.getByText("Failed to read resource")).toBeInTheDocument(),
);
// Collapse, then re-expand — the read is retried (error is not cached).
await user.click(
screen.getByRole("button", { name: `Collapse resource ${URI}` }),
);
await user.click(
screen.getByRole("button", { name: `Expand resource ${URI}` }),
);
await waitFor(() =>
expect(screen.getByText(/"recovered body"/)).toBeInTheDocument(),
);
expect(
screen.queryByText("Failed to read resource"),
).not.toBeInTheDocument();
expect(onReadResource).toHaveBeenCalledTimes(2);
});
it("does not fire a second read when toggled while a read is in flight", async () => {
const user = userEvent.setup();
let resolveRead: (value: ReadResourceResult) => void = () => {};
const onReadResource = vi.fn().mockImplementation(
() =>
new Promise<ReadResourceResult>((resolve) => {
resolveRead = resolve;
}),
);
renderWithMantine(
<ResourceLink uri={URI} onReadResource={onReadResource} />,
);
// Expand — read is in flight (loading), then collapse and re-expand.
await user.click(
screen.getByRole("button", { name: `Expand resource ${URI}` }),
);
await user.click(
screen.getByRole("button", { name: `Collapse resource ${URI}` }),
);
await user.click(
screen.getByRole("button", { name: `Expand resource ${URI}` }),
);
// Still only the original in-flight read — no redundant fetch.
expect(onReadResource).toHaveBeenCalledTimes(1);
resolveRead(readResult("in flight body"));
await waitFor(() =>
expect(screen.getByText(/"in flight body"/)).toBeInTheDocument(),
);
});
});