inspector/clients/web/src/components/groups/ViewHeader/ViewHeader.test.tsx
cliffhall 58e53d5a92 fix: state every test-gate timeout instead of inheriting a library default
Three of the six Vitest projects ran on Vitest's own testTimeout: 5000 and
five on its hookTimeout/teardownTimeout: 10000, and all 788 Testing Library
waits in the web unit project ran on asyncUtilTimeout: 1000. None of those
numbers was chosen here, and all of them were sized for an idle machine rather
than for three or four concurrent worktree sessions each running a full
local:gate on eight cores. #2292, #2278, #2250, #1942 and #1742 were each that
class, diagnosed one site at a time.

This is the complement of #1596, not a retreat from it: every fix that issue
produced stands, and no race is hidden here. What changes is that a correct,
deterministic test is no longer cut off by a budget nobody picked.

- vitest.shared.mts exports frozen TIMEOUTS / INTEGRATION_TIMEOUTS; all six
  projects spread one of them, clients/launcher importing the shared module for
  the first time to do so.
- Both web projects configure Testing Library's asyncUtilTimeout. The storybook
  one needs its own setup file, deliberately outside .storybook/ and free of
  setProjectAnnotations so the automatic preview-annotation provisioning
  (#1898) is untouched.
- 28 per-suite restatements of the integration project's own budget are gone,
  so raising the shared value now moves every site under it. The handful that
  genuinely differ — cross-process lock contention, a full OAuth round trip —
  keep their budget as a named constant that says why.
- AppRenderer's waitFor no longer asks for exactly its enclosing budget, which
  it could never win (#2292's shape); cli's e2e child timer drops below the
  project budget so the bound carrying the diagnostic is the one that fires.
- The transition auto-settle's slack term is named RAF_SLACK_MS and raised;
  the term derived from HEADER_ANIM_MS is untouched. The two answer to
  different things and must not be scaled as one number.
- The 19 Playwright budgets scattered across three web smokes become four
  named constants in scripts/lib/browser-timeouts.mjs.

scripts/verify-test-timeouts.mjs resolves each project through Vitest itself
and asserts the budgets, the asyncUtilTimeout sites, and that retry stays
unset. It was observed red against the pre-change config: unit/tui/launcher at
testTimeout 5000, five projects at hookTimeout 10000, both asyncUtilTimeout
sites unmet.

Closes #2323

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsmUAZ6aLUAFqenjJKSSRp
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-12 08:32:34 -04:00

474 lines
17 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import userEvent from "@testing-library/user-event";
import {
act,
RAF_SLACK_MS,
renderWithMantine,
renderWithMantineTransitions,
screen,
waitFor,
} from "../../../test/renderWithMantine";
import { HEADER_ANIM_MS, ViewHeader } from "./ViewHeader";
// The `renderWithMantineTransitions` tests use real (env="default") Mantine
// transitions to observe mid-flight "in"/"out" cells. Waiting for one cell to
// unmount doesn't settle the *sibling* enter cells (a completed enter leaves no
// DOM signal to `waitFor`), which would leak the #1760 timer class. Passing
// `settleMs` arms the helper's automatic post-test settle to drain the in-flight
// animation before teardown (#1786). Derive the window from the component's real
// duration plus rAF scheduling slack, so bumping HEADER_ANIM_MS can't silently
// make the settle insufficient. (This is why ViewHeader.tsx exports
// HEADER_ANIM_MS — the settle window must track the animation it settles.)
//
// The slack half is `RAF_SLACK_MS` rather than a bare `+ 200` (#2323): the two
// terms answer to different things — the first to the component, the second to
// how busy the machine is — and only the second should move when the machine
// gets busier. Naming it is what lets it move in one place, here and in the
// helper's own fallback.
//
// No `enterDelay`/`exitDelay` is passed to any of ViewHeader's four
// `Transition`s, so HEADER_ANIM_MS really is the longest JS chain. The 150ms
// stagger visible in the UI is a CSS `animation-delay` (`App.css`), and a CSS
// animation schedules no JS timer for the settle to drain.
const TRANSITION_SETTLE_MS = HEADER_ANIM_MS + RAF_SLACK_MS;
// Mock @mantine/hooks so we can control useMediaQuery results per test.
const mediaQueryMock = vi.hoisted(() => ({ value: false }));
vi.mock("@mantine/hooks", async () => {
const actual =
await vi.importActual<typeof import("@mantine/hooks")>("@mantine/hooks");
return {
...actual,
useMediaQuery: () => mediaQueryMock.value,
};
});
const connectedProps = {
connected: true as const,
serverInfo: { name: "my-mcp-server", version: "1.2.0" },
status: "connected" as const,
latencyMs: 23,
activeTab: "Tools",
availableTabs: ["Tools", "Resources", "Prompts"],
onTabChange: vi.fn(),
onDisconnect: vi.fn(),
onToggleTheme: vi.fn(),
onOpenClientSettings: vi.fn(),
};
describe("ViewHeader", () => {
beforeEach(() => {
mediaQueryMock.value = false;
});
describe("when not connected", () => {
it("renders the title and theme toggle", () => {
renderWithMantine(
<ViewHeader
connected={false}
onToggleTheme={vi.fn()}
onOpenClientSettings={vi.fn()}
/>,
);
expect(screen.getByText("MCP Inspector")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Client settings" }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Toggle color scheme" }),
).toBeInTheDocument();
});
it("shows an 'MCP Documentation' tooltip on the logo link (#1682)", async () => {
const user = userEvent.setup();
renderWithMantine(
<ViewHeader
connected={false}
onToggleTheme={vi.fn()}
onOpenClientSettings={vi.fn()}
/>,
);
await user.hover(screen.getByRole("link"));
expect(await screen.findByText("MCP Documentation")).toBeInTheDocument();
});
it("invokes onOpenClientSettings when the client settings button is clicked", async () => {
const user = userEvent.setup();
const onOpenClientSettings = vi.fn();
renderWithMantine(
<ViewHeader
connected={false}
onToggleTheme={vi.fn()}
onOpenClientSettings={onOpenClientSettings}
/>,
);
await user.click(screen.getByRole("button", { name: "Client settings" }));
expect(onOpenClientSettings).toHaveBeenCalled();
});
it("invokes onToggleTheme when the theme button is clicked", async () => {
const user = userEvent.setup();
const onToggleTheme = vi.fn();
renderWithMantine(
<ViewHeader
connected={false}
onToggleTheme={onToggleTheme}
onOpenClientSettings={vi.fn()}
/>,
);
await user.click(
screen.getByRole("button", { name: "Toggle color scheme" }),
);
expect(onToggleTheme).toHaveBeenCalledTimes(1);
});
it("omits the monitoring toggle when no monitorToggle is provided", () => {
renderWithMantine(
<ViewHeader
connected={false}
onToggleTheme={vi.fn()}
onOpenClientSettings={vi.fn()}
/>,
);
expect(
screen.queryByRole("button", { name: "Open monitoring sidebar" }),
).toBeNull();
expect(
screen.queryByRole("button", { name: "Close monitoring sidebar" }),
).toBeNull();
});
it("renders and wires the monitoring toggle for a failed connect attempt", async () => {
const user = userEvent.setup();
const onToggle = vi.fn();
renderWithMantine(
<ViewHeader
connected={false}
onToggleTheme={vi.fn()}
onOpenClientSettings={vi.fn()}
monitorToggle={{ open: false, onToggle }}
/>,
);
await user.click(
screen.getByRole("button", { name: "Open monitoring sidebar" }),
);
expect(onToggle).toHaveBeenCalledTimes(1);
});
});
describe("when connected", () => {
it("renders the server name and tab data", () => {
renderWithMantine(<ViewHeader {...connectedProps} />);
expect(screen.getByText("my-mcp-server")).toBeInTheDocument();
// The active tab "Tools" should appear at least once (segmented control or select)
const toolsMatches = screen.getAllByText("Tools");
expect(toolsMatches.length).toBeGreaterThan(0);
});
it("invokes onToggleTheme when the theme button is clicked", async () => {
const user = userEvent.setup();
const onToggleTheme = vi.fn();
renderWithMantine(
<ViewHeader {...connectedProps} onToggleTheme={onToggleTheme} />,
);
await user.click(
screen.getByRole("button", { name: "Toggle color scheme" }),
);
expect(onToggleTheme).toHaveBeenCalledTimes(1);
});
it("renders the monitoring toggle in its open state and toggles it closed", async () => {
const user = userEvent.setup();
const onToggle = vi.fn();
renderWithMantine(
<ViewHeader
{...connectedProps}
monitorToggle={{ open: true, onToggle }}
/>,
);
expect(
screen.queryByRole("button", { name: "Open monitoring sidebar" }),
).toBeNull();
await user.click(
screen.getByRole("button", { name: "Close monitoring sidebar" }),
);
expect(onToggle).toHaveBeenCalledTimes(1);
});
it("invokes onDisconnect when the disconnect control is clicked", async () => {
const user = userEvent.setup();
const onDisconnect = vi.fn();
renderWithMantine(
<ViewHeader {...connectedProps} onDisconnect={onDisconnect} />,
);
// Disconnect is always the icon, labelled by its aria-label / tooltip.
const disconnectButton = screen.getByRole("button", {
name: "Disconnect from server",
});
await user.click(disconnectButton);
expect(onDisconnect).toHaveBeenCalledTimes(1);
});
it("renders all available tabs", () => {
renderWithMantine(<ViewHeader {...connectedProps} />);
expect(screen.getAllByText("Resources").length).toBeGreaterThan(0);
expect(screen.getAllByText("Prompts").length).toBeGreaterThan(0);
});
it("renders the SegmentedControl and the disconnect icon on wide viewports", async () => {
mediaQueryMock.value = true;
const user = userEvent.setup();
const onTabChange = vi.fn();
const onDisconnect = vi.fn();
renderWithMantine(
<ViewHeader
{...connectedProps}
onTabChange={onTabChange}
onDisconnect={onDisconnect}
/>,
);
// Disconnect is always the icon (labelled by its tooltip / aria-label).
const disconnectBtn = screen.getByRole("button", {
name: "Disconnect from server",
});
await user.click(disconnectBtn);
expect(onDisconnect).toHaveBeenCalledTimes(1);
// SegmentedControl exposes its options as radios.
const radios = screen.getAllByRole("radio");
expect(radios.length).toBeGreaterThan(0);
});
it("animates the connected tab bar in with the slide-down enter (#1450)", () => {
mediaQueryMock.value = true;
renderWithMantine(<ViewHeader {...connectedProps} />);
// The tab bar lives in a crossfade cell marked for the enter animation.
const cell = screen.getAllByRole("radio")[0]!.closest("[data-anim]");
expect(cell?.getAttribute("data-anim")).toBe("in");
});
it("crossfades on disconnect: tab bar exits while the title enters, then the bar unmounts (#1450)", async () => {
mediaQueryMock.value = true;
// Asserts the mid-flight exit ("out") state, so it needs real transitions.
// The title's concurrent *enter* leaves no unmount to await, so the armed
// settle (settleMs) drains it before teardown (#1786).
const { rerender } = renderWithMantineTransitions(
<ViewHeader {...connectedProps} />,
{ settleMs: TRANSITION_SETTLE_MS },
);
expect(screen.getAllByRole("radio").length).toBeGreaterThan(0);
// Disconnect: the connected header is replaced, but the tab bar stays in
// the DOM (now marked for the exit animation) until the keep-alive
// Transition's exit window elapses — so it isn't removed synchronously.
rerender(
<ViewHeader
connected={false}
onToggleTheme={vi.fn()}
onOpenClientSettings={vi.fn()}
/>,
);
const exitingCell = screen
.getAllByRole("radio")[0]!
.closest("[data-anim]");
expect(exitingCell?.getAttribute("data-anim")).toBe("out");
// The title enters (staggered) in its own cell.
const title = await screen.findByText("MCP Inspector");
expect(title.closest("[data-anim]")?.getAttribute("data-anim")).toBe(
"in",
);
// After the exit transition the tab bar is removed from the DOM entirely.
await waitFor(() =>
expect(screen.queryAllByRole("radio").length).toBe(0),
);
});
it("glows a tab added after the connect grace window, not during it (#1450)", () => {
vi.useFakeTimers();
try {
mediaQueryMock.value = true;
const { rerender } = renderWithMantine(
<ViewHeader {...connectedProps} />,
);
// During the post-connect grace window an async-resolved list (adding
// "Apps") counts as part of the initial set and does not glow.
rerender(
<ViewHeader
{...connectedProps}
availableTabs={["Tools", "Apps", "Resources", "Prompts"]}
/>,
);
expect(document.querySelector('[data-glow="on"]')).toBeNull();
// Once the grace window elapses, a genuine mid-session addition glows.
act(() => {
vi.advanceTimersByTime(2000);
});
rerender(
<ViewHeader
{...connectedProps}
availableTabs={["Tools", "Apps", "Resources", "Prompts", "Tasks"]}
/>,
);
const glowing = document.querySelectorAll('[data-glow="on"]');
expect(glowing.length).toBe(1);
expect(glowing[0]?.textContent).toBe("Tasks");
} finally {
vi.useRealTimers();
}
});
it("animates the server name and disconnect controls in on connect, out on disconnect (#1450)", async () => {
mediaQueryMock.value = true;
// Asserts the mid-flight exit ("out") state, so it needs real transitions.
// The concurrent enter cells leave no unmount to await, so the armed settle
// (settleMs) drains them before teardown (#1786).
const { rerender } = renderWithMantineTransitions(
<ViewHeader {...connectedProps} />,
{ settleMs: TRANSITION_SETTLE_MS },
);
// Connected: the server name and the Disconnect control are in their
// enter cells.
expect(
screen
.getByText("my-mcp-server")
.closest("[data-anim]")
?.getAttribute("data-anim"),
).toBe("in");
expect(
screen
.getByRole("button", { name: "Disconnect from server" })
.closest("[data-anim]")
?.getAttribute("data-anim"),
).toBe("in");
// Disconnect: both stay mounted (animating out) before unmounting.
rerender(
<ViewHeader
connected={false}
onToggleTheme={vi.fn()}
onOpenClientSettings={vi.fn()}
/>,
);
expect(
screen
.getByText("my-mcp-server")
.closest("[data-anim]")
?.getAttribute("data-anim"),
).toBe("out");
expect(
screen
.getByRole("button", { name: "Disconnect from server" })
.closest("[data-anim]")
?.getAttribute("data-anim"),
).toBe("out");
// The server name and the Disconnect button are removed by two
// independent Transition exits; on a slow runner the button's exit can
// lag the name's, so each "removed" assertion gets its own waitFor rather
// than assuming the two transitions complete in lockstep (#1699).
await waitFor(() =>
expect(screen.queryByText("my-mcp-server")).not.toBeInTheDocument(),
);
await waitFor(() =>
expect(
screen.queryByRole("button", { name: "Disconnect from server" }),
).not.toBeInTheDocument(),
);
});
it("renders the dark-scheme icon/logo branch under a dark color scheme", () => {
// Rendering under a forced-dark provider exercises the
// `colorScheme === "dark"` branches for the theme icon and the logo src.
renderWithMantine(<ViewHeader {...connectedProps} />, {
colorScheme: "dark",
});
expect(
screen.getByRole("button", { name: "Toggle color scheme" }),
).toBeInTheDocument();
expect(screen.getByAltText("MCP")).toBeInTheDocument();
});
it("crossfades the title out and the connected header in on connect (#1450)", async () => {
mediaQueryMock.value = true;
// Asserts the mid-flight exit ("out") state, so it needs real transitions.
// The title's exit and the server name's enter stay in flight past the
// assertions (the enter leaves no unmount to await), so the armed settle
// (settleMs) drains them before teardown (#1786).
// Start disconnected: the title cell is the entering one.
const { rerender } = renderWithMantineTransitions(
<ViewHeader
connected={false}
onToggleTheme={vi.fn()}
onOpenClientSettings={vi.fn()}
/>,
{ settleMs: TRANSITION_SETTLE_MS },
);
expect(
screen
.getByText("MCP Inspector")
.closest("[data-anim]")
?.getAttribute("data-anim"),
).toBe("in");
// Connect: the title cell stays mounted while it exits ("out"), and the
// connected snapshot is adopted from a previously-null snapshot (the
// `snapshot ? … : ""` and `seenTabsKey ? … : []` empty-base branches).
rerender(<ViewHeader {...connectedProps} />);
expect(
screen
.getByText("MCP Inspector")
.closest("[data-anim]")
?.getAttribute("data-anim"),
).toBe("out");
// The connected server name enters once the keep-alive Transition mounts.
expect(await screen.findByText("my-mcp-server")).toBeInTheDocument();
});
it("disarms the tab glow when the connection drops after the grace window", () => {
vi.useFakeTimers();
try {
mediaQueryMock.value = true;
const { rerender } = renderWithMantine(
<ViewHeader {...connectedProps} />,
);
// Arm the glow by letting the grace window elapse.
act(() => {
vi.advanceTimersByTime(2000);
});
// Disconnect resets glowArmed via adjust-state-during-render.
rerender(
<ViewHeader
connected={false}
onToggleTheme={vi.fn()}
onOpenClientSettings={vi.fn()}
/>,
);
// Reconnect with a new tab: because glowArmed was reset, the freshly
// re-seen tab set is treated as initial and nothing glows.
rerender(
<ViewHeader
{...connectedProps}
availableTabs={["Tools", "Resources", "Prompts", "Tasks"]}
/>,
);
expect(document.querySelector('[data-glow="on"]')).toBeNull();
} finally {
vi.useRealTimers();
}
});
it("invokes onTabChange when a different tab is picked", async () => {
const user = userEvent.setup();
const onTabChange = vi.fn();
renderWithMantine(
<ViewHeader {...connectedProps} onTabChange={onTabChange} />,
);
// Tabs always render as a SegmentedControl (radios); pick another one.
await user.click(screen.getByRole("radio", { name: "Resources" }));
expect(onTabChange).toHaveBeenCalledWith("Resources");
});
});
});