inspector/clients/web/src/components/groups/ResourceControls/ResourceControls.stories.tsx
cliffhall 519ecf49ee chore(lint): enable no-floating-promises in all five ESLint scopes
A floated promise is invisible at review time — the call reads like an
awaited one minus four characters — and the unhandled rejection it
produces surfaces in a different test, in a different file, as a stack
pointing at SDK internals. In #1947 two un-held `callTool` calls made
`npm run ci` unpassable and took a full investigation to attribute; the
fix was two lines. `@typescript-eslint/no-floating-promises` catches the
class outright, so enable it at `error` everywhere.

The rule is type-aware and `tseslint.configs.recommended` is not, so each
scope's config gains a parser project. The four clients name their own
tsconfig projects (both of them — `src` and `__tests__`/`server` live in
different ones). The root scope has no tsconfig at all, so add
`tsconfig.lint.json`: a lint-only project covering `core/**`,
`test-servers/src/**`, and `vitest.shared.mts`, emitting nothing and
gating nothing. It sets `moduleResolution: bundler` deliberately — under
NodeNext `core/`'s extensionless imports fail to resolve, every import
degrades to `any`, and the rule silently stops seeing promises at all.

Measured surface, and the fixes:

- core (2) — `stream.close()` in two Hono `onAbort` listeners. Hono
  invokes subscribers with a bare `subscriber()`, so a promise returned
  from the listener is floated one layer up; `closeAbortedStream` owns
  the rejection where it can actually be owned.
- tui (5) — `handleDisconnect` had no catch of its own and its only
  caller is a key handler that cannot await, so a failing disconnect was
  a live unhandled rejection; it now surfaces the message the way
  `handleConnect` does. The rest are `void` at callers that genuinely
  cannot await, over callees that already own their failures.
- web (92) — 83 are Storybook `expect(...)`, which is instrumented and
  returns a promise, now awaited (`scrollAreaStoryAssertions` is async
  for the same reason). Two tests were asserting against a provider call
  they never awaited. `open(url)` in both web servers now warns instead
  of escaping a synchronous callback.
- cli, launcher — already clean.

Type-aware linting costs time: `clients/web`'s `eslint .` goes from ~8s
to ~19s, inside `validate`. Narrowing the loaded projects is the lever if
that needs reducing later; dropping the rule is not.

Closes #1959

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-16 20:57:32 -04:00

183 lines
5.6 KiB
TypeScript

import type { Meta, StoryObj } from "@storybook/react-vite";
import { noopPagination } from "../../../test/fixtures/pagination";
import type {
Resource,
ResourceTemplateType as ResourceTemplate,
} from "@modelcontextprotocol/client";
import type { InspectorResourceSubscription } from "../../../../../../core/mcp/types.js";
import { expect, fn, within } from "storybook/test";
import { ResourceControls } from "./ResourceControls";
// rotate(90deg) — Mantine sets `data-rotate` on the chevron slot of an open
// section; App.css rotates the right-pointing arrow 90° so it points down.
const ROTATED_DOWN = "matrix(0, 1, -1, 0, 0, 0)";
// Identity — a closed section's right-pointing chevron at its natural 0°.
const NOT_ROTATED = "matrix(1, 0, 0, 1, 0, 0)";
function chevronTransforms(canvasElement: HTMLElement): string[] {
// Scoped to the accordion root so it still works if a story ever renders more
// than one disclosure accordion on the canvas.
const root = canvasElement.querySelector(".disclosure-sections");
return [
...(root ?? canvasElement).querySelectorAll(".disclosure-chevron"),
].map((c) => getComputedStyle(c).transform);
}
const meta: Meta<typeof ResourceControls> = {
title: "Groups/ResourceControls",
component: ResourceControls,
args: {
pagination: noopPagination,
onRefreshList: fn(),
onSelectUri: fn(),
onSelectTemplate: fn(),
onUnsubscribeResource: fn(),
onSearchChange: fn(),
onOpenSectionsChange: fn(),
listChanged: false,
compact: false,
onCompactChange: fn(),
},
};
export default meta;
type Story = StoryObj<typeof ResourceControls>;
const sampleResources: Resource[] = [
{
name: "config.json",
uri: "file:///config.json",
annotations: { audience: ["user"], priority: 0.8 },
},
{
name: "README.md",
uri: "file:///README.md",
},
{
name: "schema.sql",
uri: "file:///schema.sql",
},
];
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"),
},
];
export const Default: Story = {
args: {
resources: sampleResources,
templates: sampleTemplates,
subscriptions: sampleSubscriptions,
},
// All three populated sections open by default (compact=false) → every
// chevron points down.
play: async ({ canvasElement }) => {
const transforms = chevronTransforms(canvasElement);
await expect(transforms).toHaveLength(3);
for (const t of transforms) await expect(t).toBe(ROTATED_DOWN);
},
};
export const WithSearch: Story = {
args: {
...Default.args,
},
};
export const ListChanged: Story = {
args: {
...Default.args,
listChanged: true,
},
};
export const Empty: Story = {
args: {
resources: [],
templates: [],
subscriptions: [],
},
};
// Sections explicitly collapsed: every chevron points right (no rotation).
export const Collapsed: Story = {
args: {
resources: sampleResources,
templates: sampleTemplates,
subscriptions: sampleSubscriptions,
openSections: [],
},
play: async ({ canvasElement }) => {
const transforms = chevronTransforms(canvasElement);
await expect(transforms).toHaveLength(3);
// Identity matrix = the right-pointing arrow's natural 0° (not rotated).
for (const t of transforms) await expect(t).toBe(NOT_ROTATED);
},
};
// Populated URIs/Templates but no Subscriptions, all "open" in intent. The
// empty Subscriptions section is kept out of the open set, so its chevron
// points right while the two populated ones point down.
export const EmptySectionCollapsed: Story = {
args: {
resources: sampleResources,
templates: sampleTemplates,
subscriptions: [],
openSections: ["resources", "templates", "subscriptions"],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByText("Subscriptions (0)")).toBeInTheDocument();
const transforms = chevronTransforms(canvasElement);
const downCount = transforms.filter((t) => t === ROTATED_DOWN).length;
await expect(downCount).toBe(2); // URIs + Templates down, Subscriptions right
},
};
// Server does not advertise resources.subscribe: the Subscriptions section is
// omitted entirely, leaving only URIs and Templates (#1478).
export const SubscriptionsUnsupported: Story = {
args: {
resources: sampleResources,
templates: sampleTemplates,
subscriptions: sampleSubscriptions,
subscriptionsSupported: false,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.queryByText(/Subscriptions/)).not.toBeInTheDocument();
// Only the two remaining sections render, both open → both chevrons down.
const transforms = chevronTransforms(canvasElement);
await expect(transforms).toHaveLength(2);
for (const t of transforms) await expect(t).toBe(ROTATED_DOWN);
},
};
// Many URIs with sparse Templates/Subscriptions: under the old equal `/ n`
// height split, URIs scrolled while the others left their share unused. Now the
// sections size to content inside one bounded scroll region.
const manyResources: Resource[] = Array.from({ length: 30 }, (_, i) => ({
name: `resource-${String(i + 1).padStart(2, "0")}.md`,
uri: `file:///docs/resource-${i + 1}.md`,
}));
export const ManyResources: Story = {
args: {
resources: manyResources,
templates: sampleTemplates,
subscriptions: sampleSubscriptions,
},
};