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>
474 lines
17 KiB
TypeScript
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");
|
|
});
|
|
});
|
|
});
|