import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { ProtocolErrorCode, ProtocolError, AuthorizationServerMismatchError, } from "@modelcontextprotocol/client"; import { UrlElicitationLoopError } from "@inspector/core/mcp/urlElicitation.js"; import { ToolCallCancelledError } from "@inspector/core/mcp/toolCallCancelledError.js"; import { renderWithMantine, screen, waitFor, act, } from "./test/renderWithMantine"; import userEvent from "@testing-library/user-event"; // Spy on the toast layer so the progress-notification tests can assert the // show/update calls without mounting Mantine's portal. // `vi.hoisted` lets the mock factory (hoisted above imports) reach the spies. const { notificationsMock } = vi.hoisted(() => ({ notificationsMock: { show: vi.fn(), update: vi.fn(), hide: vi.fn(), clean: vi.fn(), }, })); vi.mock("@mantine/notifications", () => ({ notifications: notificationsMock, })); // Shared spy for MessageLogState.clearMessages so a test can inspect the // predicate the panel Clear passes (keep-pinned vs clear-all). const { messageLogClear } = vi.hoisted(() => ({ messageLogClear: vi.fn() })); // App is a wiring component: it owns session-scoped UI state (the per-call // result panels and the optimistic log level) and resets it when the active // InspectorClient emits `disconnect`. These tests exercise that reset in // isolation by mocking the InspectorClient (a fake EventTarget we can fire // `disconnect` on), the per-server state managers, the core hooks, and the // InspectorView (a thin double that surfaces the props under test and lets us // trigger the handlers). See #1368. // --- Fake InspectorClient --------------------------------------------------- // Extends EventTarget so the App's `addEventListener("disconnect", …)` wiring // is real; the test fires `dispatchEvent(new Event("disconnect"))` to simulate // any of the three disconnect paths (toggle, header button, transport failure). vi.mock("@inspector/core/mcp/index.js", async (importOriginal) => { const actual = await importOriginal(); // When set, the next `connect()` rejects with this value (one-shot), so a test // can exercise the handshake-failure path. Cleared after it fires. let nextConnectRejection: unknown = null; // Same one-shot arming for the `/oauth/callback` token exchange, so a test can // exercise the callback-leg failure paths (#1808). let nextResumeRejection: unknown = null; class FakeInspectorClient extends EventTarget { connect = vi.fn(() => { if (nextConnectRejection !== null) { const err = nextConnectRejection; nextConnectRejection = null; return Promise.reject(err); } return Promise.resolve(undefined); }); disconnect = vi.fn().mockResolvedValue(undefined); callTool = vi .fn() .mockResolvedValue({ success: true, result: { acts: [] } }); callToolStream = vi .fn() .mockResolvedValue({ success: true, result: { acts: [] } }); cancelRequestorTask = vi.fn().mockResolvedValue(undefined); isTasksExtensionNegotiated = vi.fn().mockReturnValue(false); getRequestorTask = vi.fn().mockResolvedValue({ taskId: "t", status: "working", ttl: null, createdAt: "", lastUpdatedAt: "", }); cancelToolCall = vi.fn().mockReturnValue(true); getPrompt = vi.fn().mockResolvedValue({ result: { messages: [] } }); readResource = vi .fn() .mockResolvedValue({ result: { contents: [] }, timestamp: 1 }); setLoggingLevel = vi.fn().mockResolvedValue(undefined); listTools = vi.fn().mockResolvedValue({ tools: [] }); listPrompts = vi.fn().mockResolvedValue({ prompts: [] }); listResources = vi.fn().mockResolvedValue({ resources: [] }); listResourceTemplates = vi .fn() .mockResolvedValue({ resourceTemplates: [] }); listRequestorTasks = vi.fn().mockResolvedValue({ tasks: [] }); ping = vi.fn().mockResolvedValue(undefined); getOAuthFlowState = vi.fn().mockReturnValue(undefined); getOAuthState = vi.fn().mockResolvedValue(undefined); getPendingSamples = vi.fn().mockReturnValue([]); getPendingElicitations = vi.fn().mockReturnValue([]); getRoots = vi.fn().mockReturnValue([]); setRoots = vi.fn().mockResolvedValue(undefined); setServerSettings = vi.fn(); resumeAfterOAuth = vi.fn(() => { if (nextResumeRejection !== null) { const err = nextResumeRejection; nextResumeRejection = null; return Promise.reject(err); } return Promise.resolve(undefined); }); checkAuthChallengeSatisfied = vi.fn().mockResolvedValue(true); clearOAuthTokens = vi.fn().mockResolvedValue(undefined); } const instances: FakeInspectorClient[] = []; return { ...actual, InspectorClient: vi.fn(function () { const client = new FakeInspectorClient(); instances.push(client); return client; }), // Test-only handle so the test can grab the live instance and fire events. __clientInstances: instances, // Test-only: arm the next connect() to reject (handshake-failure path). __rejectNextConnect: (err: unknown) => { nextConnectRejection = err; }, // Test-only: arm the next resumeAfterOAuth() to reject (callback-leg failure). __rejectNextResumeAfterOAuth: (err: unknown) => { nextResumeRejection = err; }, }; }); // Per-server state managers — App constructs nine of them per connect and // calls `destroy()` on teardown. Replace each with a no-op constructor. vi.mock("@inspector/core/mcp/state/managedToolsState.js", () => ({ ManagedToolsState: vi.fn(function () { return { destroy: vi.fn() }; }), })); vi.mock("@inspector/core/mcp/state/managedPromptsState.js", () => ({ ManagedPromptsState: vi.fn(function () { return { destroy: vi.fn() }; }), })); vi.mock("@inspector/core/mcp/state/managedResourcesState.js", () => ({ ManagedResourcesState: vi.fn(function () { return { destroy: vi.fn() }; }), })); vi.mock("@inspector/core/mcp/state/managedResourceTemplatesState.js", () => ({ ManagedResourceTemplatesState: vi.fn(function () { return { destroy: vi.fn() }; }), })); vi.mock("@inspector/core/mcp/state/managedRequestorTasksState.js", () => ({ ManagedRequestorTasksState: vi.fn(function () { return { destroy: vi.fn() }; }), })); vi.mock("@inspector/core/mcp/state/resourceSubscriptionsState.js", () => ({ ResourceSubscriptionsState: vi.fn(function () { return { destroy: vi.fn() }; }), })); vi.mock("@inspector/core/mcp/state/messageLogState.js", () => ({ MessageLogState: vi.fn(function () { return { destroy: vi.fn(), clearMessages: messageLogClear }; }), })); // Extends EventTarget so the App's `fetchRequestBodyDropped` subscription is // real; the test fires `dispatchEvent(new CustomEvent("fetchRequestBodyDropped", // { detail }))` on the tracked instance to drive the body-dropped toast. vi.mock("@inspector/core/mcp/state/fetchRequestLogState.js", () => { class FakeFetchRequestLogState extends EventTarget { destroy = vi.fn(); getFetchRequests = vi.fn(() => []); setMaxFetchRequests = vi.fn(); } const instances: FakeFetchRequestLogState[] = []; return { FetchRequestLogState: vi.fn(function () { const inst = new FakeFetchRequestLogState(); instances.push(inst); return inst; }), __fetchLogInstances: instances, }; }); vi.mock("@inspector/core/mcp/state/stderrLogState.js", () => ({ StderrLogState: vi.fn(function () { return { destroy: vi.fn() }; }), })); vi.mock("@inspector/core/mcp/remote/index.js", () => ({ RemoteInspectorClientStorage: vi.fn(function () { return { saveSession: vi.fn() }; }), })); vi.mock("./lib/environmentFactory", () => ({ createWebEnvironment: vi.fn(() => ({ environment: {} })), })); // --- Core hooks ------------------------------------------------------------- // One server is available; the tools list carries the `get_acts` tool the // repro runs. Everything else returns empty. const SERVER_A = { id: "A", name: "PlotRocket", config: { type: "stdio", command: "node" }, connection: { status: "disconnected" }, }; // Stable spy so tests can assert the sidebar paginated toggle persisted the // `paginatedLists` setting (#1721). `vi.hoisted` so it exists when the hoisted // `vi.mock` factory closes over it. const { updateServerSettingsSpy } = vi.hoisted(() => ({ updateServerSettingsSpy: vi.fn(() => Promise.resolve()), })); // Stable spy for the tools list-changed acknowledgement, so a test can assert // the paginated Refresh clears the indicator (#1721). const { clearToolsListChangedSpy } = vi.hoisted(() => ({ clearToolsListChangedSpy: vi.fn(), })); vi.mock("@inspector/core/react/useServers.js", () => ({ useServers: vi.fn(() => ({ servers: [SERVER_A], addServer: vi.fn(), updateServer: vi.fn(), updateServerSettings: updateServerSettingsSpy, removeServer: vi.fn(), })), })); vi.mock("@inspector/core/react/useInspectorClient.js", () => ({ useInspectorClient: vi.fn(() => ({ status: "connected", capabilities: {}, clientCapabilities: {}, // Undefined models a modern server that omitted the optional serverInfo. As // of #1772 `initializeResult` is still built when connected (with a // catalog-name fallback), so this exercises that path — see the "#1772" // describe block. serverInfo: undefined, instructions: undefined, })), })); vi.mock("@inspector/core/react/useManagedTools.js", () => ({ useManagedTools: vi.fn(() => ({ tools: [{ name: "get_acts", inputSchema: { type: "object" } }], refresh: vi.fn(), clearListChanged: clearToolsListChangedSpy, })), })); vi.mock("@inspector/core/react/useManagedPrompts.js", () => ({ useManagedPrompts: vi.fn(() => ({ prompts: [], refresh: vi.fn(), clearListChanged: vi.fn(), })), })); vi.mock("@inspector/core/react/useManagedResources.js", () => ({ useManagedResources: vi.fn(() => ({ resources: [], refresh: vi.fn(), clearListChanged: vi.fn(), })), })); vi.mock("@inspector/core/react/useManagedResourceTemplates.js", () => ({ useManagedResourceTemplates: vi.fn(() => ({ resourceTemplates: [], refresh: vi.fn(), })), })); // Paged (paginated) hooks + state managers (#1721). Mirrors the managed // mocks: the hooks return an empty accumulated list and a resolving loadPage so // usePaginatedList runs without a real transport; the state classes are no-op // constructors App still instantiates/destroys per connect. vi.mock("@inspector/core/react/usePagedTools.js", () => ({ usePagedTools: vi.fn(() => ({ tools: [], nextCursor: undefined, pageCount: 0, loadPage: vi.fn(() => Promise.resolve({ tools: [], nextCursor: undefined }), ), clear: vi.fn(), })), })); vi.mock("@inspector/core/react/usePagedPrompts.js", () => ({ usePagedPrompts: vi.fn(() => ({ prompts: [], nextCursor: undefined, pageCount: 0, loadPage: vi.fn(() => Promise.resolve({ prompts: [], nextCursor: undefined }), ), clear: vi.fn(), })), })); vi.mock("@inspector/core/react/usePagedResources.js", () => ({ usePagedResources: vi.fn(() => ({ resources: [], nextCursor: undefined, pageCount: 0, loadPage: vi.fn(() => Promise.resolve({ resources: [], nextCursor: undefined }), ), clear: vi.fn(), })), })); vi.mock("@inspector/core/mcp/state/pagedToolsState.js", () => ({ PagedToolsState: vi.fn(function () { return { destroy: vi.fn() }; }), })); vi.mock("@inspector/core/mcp/state/pagedPromptsState.js", () => ({ PagedPromptsState: vi.fn(function () { return { destroy: vi.fn() }; }), })); vi.mock("@inspector/core/mcp/state/pagedResourcesState.js", () => ({ PagedResourcesState: vi.fn(function () { return { destroy: vi.fn() }; }), })); vi.mock("@inspector/core/react/useManagedRequestorTasks.js", () => ({ useManagedRequestorTasks: vi.fn(() => ({ tasks: [], refresh: vi.fn().mockResolvedValue([]), clearCompleted: vi.fn(), })), })); vi.mock("@inspector/core/react/useResourceSubscriptions.js", () => ({ useResourceSubscriptions: vi.fn(() => ({ subscriptions: [] })), })); vi.mock("@inspector/core/react/useMessageLog.js", () => ({ useMessageLog: vi.fn(() => ({ messages: [] })), })); vi.mock("@inspector/core/react/useFetchRequestLog.js", () => ({ useFetchRequestLog: vi.fn(() => ({ fetchRequests: [] })), })); vi.mock("@inspector/core/react/useStderrLog.js", () => ({ useStderrLog: vi.fn(() => ({ stderrLogs: [] })), })); vi.mock("@inspector/core/react/useSettingsDraft.js", () => ({ useSettingsDraft: vi.fn(() => ({ // `draft` is widened so tests can override the return with a populated // settings draft via `mockReturnValue` (the roots live-apply-on-close path). draft: undefined as InspectorServerSettings | undefined, onChange: vi.fn(), flush: vi.fn(), })), })); // --- InspectorView double --------------------------------------------------- // Surfaces each piece of session-scoped state under test and exposes buttons // that invoke the App's connect / call-tool / get-prompt / read-resource / // set-log-level handlers. vi.mock("./components/views/InspectorView/InspectorView", () => ({ InspectorView: (props: { toolCallState?: { status?: string }; toolsUi?: { selectedToolKey?: string; formValues: Record; search: string; }; promptsUi?: { selectedPromptName?: string; argumentValues: Record; submittedFor?: string; search: string; }; logsUi?: { filterText: string; visibleLevels: Record }; getPromptState?: { status?: string }; readResourceState?: { status?: string }; currentLogLevel?: string; activeTab?: string; erroredServerId?: string; initializeResult?: { serverInfo: { name: string; version: string } }; onActiveTabChange: (tab: string) => void; onConnectionInfo: () => void; onToggleConnection: (id: string) => void; onToolsUiChange: (next: { selectedToolKey?: string; formValues: Record; search: string; }) => void; onPromptsUiChange: (next: { selectedPromptName?: string; argumentValues: Record; submittedFor?: string; search: string; }) => void; onLogsUiChange: (next: { filterText: string; visibleLevels: Record; }) => void; progressByTaskId?: Record; onCallTool: ( name: string, args: Record, runAsTask?: boolean, ) => void; onGetPrompt: (name: string, args: Record) => void; onReadResource: (uri: string) => void; onSetLogLevel: (level: string) => void; onCancelTask: (taskId: string) => void; onCancelToolCall: () => void; onClearCompletedTasks: () => void; onRefreshTasks: () => void; onServerSettings: (id: string) => void; onClearProtocol: () => void; onReplayProtocol: (id: string) => void; onTogglePinProtocol: (id: string) => void; pinnedProtocolIds?: Set; onRefreshTools: () => void; toolsPagination: { paginated: boolean; canLoadMore: boolean; loadedPages: number; onPaginatedChange: (v: boolean) => void; onLoadMore: () => void; }; }) => (
{props.toolCallState?.status ?? "none"} {Object.keys(props.progressByTaskId ?? {}).join(",") || "none"} {props.toolsUi?.selectedToolKey ?? "none"} {props.toolsUi?.search || "none"} {props.promptsUi?.selectedPromptName ?? "none"} {props.logsUi?.filterText || "none"} {props.getPromptState?.status ?? "none"} {props.readResourceState?.status ?? "none"} {props.currentLogLevel} {props.activeTab ?? "none"} {props.initializeResult ? `name:${props.initializeResult.serverInfo.name || "(empty)"}` : "none"} {props.erroredServerId ?? "none"} {Array.from(props.pinnedProtocolIds ?? []).join(",")} {String(props.toolsPagination.paginated)} {props.toolsPagination.loadedPages}
), })); import App from "./App"; import { SERVER_INFO_NOT_REPORTED_LABEL } from "./components/groups/ConnectionInfoContent/ConnectionInfoContent"; import { OAUTH_CALLBACK_PATH } from "./utils/oauthFlow.js"; import { INSPECTOR_SERVERS_TAB } from "./utils/inspectorTabs.js"; import { readOAuthResumeSnapshot, writeOAuthResumeSnapshot, type OAuthResumeSnapshot, } from "./lib/oauthResume.js"; import * as McpIndex from "@inspector/core/mcp/index.js"; import * as FetchLogModule from "@inspector/core/mcp/state/fetchRequestLogState.js"; import { useManagedRequestorTasks } from "@inspector/core/react/useManagedRequestorTasks.js"; import { useManagedTools } from "@inspector/core/react/useManagedTools.js"; import { usePagedTools } from "@inspector/core/react/usePagedTools.js"; import { useMessageLog } from "@inspector/core/react/useMessageLog.js"; import { useInspectorClient } from "@inspector/core/react/useInspectorClient.js"; import { useSettingsDraft } from "@inspector/core/react/useSettingsDraft.js"; import { useServers } from "@inspector/core/react/useServers.js"; import type { InspectorServerSettings, MessageEntry, ServerEntry, } from "@inspector/core/mcp/types.js"; // Default useInspectorClient return — capabilities empty (no task tool calls). // Individual tests override via vi.mocked(...).mockReturnValue(...). const DEFAULT_USE_INSPECTOR_CLIENT: ReturnType = { status: "connected", capabilities: {}, clientCapabilities: {}, serverInfo: undefined, instructions: undefined, excludedTools: [], malformedListItems: [], appRendererClient: null, connect: vi.fn().mockResolvedValue(undefined), disconnect: vi.fn().mockResolvedValue(undefined), }; const clientInstances = ( McpIndex as unknown as { __clientInstances: EventTarget[] } ).__clientInstances; const rejectNextConnect = ( McpIndex as unknown as { __rejectNextConnect: (err: unknown) => void } ).__rejectNextConnect; const rejectNextResumeAfterOAuth = ( McpIndex as unknown as { __rejectNextResumeAfterOAuth: (err: unknown) => void; } ).__rejectNextResumeAfterOAuth; const fetchLogInstances = ( FetchLogModule as unknown as { __fetchLogInstances: EventTarget[] } ).__fetchLogInstances; describe("App failed-connection card border (#1621)", () => { beforeEach(() => { clientInstances.length = 0; vi.mocked(useInspectorClient).mockReturnValue(DEFAULT_USE_INSPECTOR_CLIENT); }); it("flags the server whose connect attempt fails as erroredServerId", async () => { const user = userEvent.setup(); renderWithMantine(); expect(screen.getByTestId("errored-server")).toHaveTextContent("none"); // Arm the next connect() to reject with a plain (non-auth) handshake error. rejectNextConnect(new Error("spawn failed")); await user.click(screen.getByText("connect")); await waitFor(() => expect(screen.getByTestId("errored-server")).toHaveTextContent("A"), ); }); it("clears the flag when a new connection attempt starts", async () => { // Report status "error" so the *second* connect click is treated as a fresh // attempt (not a disconnect of a live session), exercising the clear. vi.mocked(useInspectorClient).mockReturnValue({ ...DEFAULT_USE_INSPECTOR_CLIENT, status: "error", }); const user = userEvent.setup(); renderWithMantine(); rejectNextConnect(new Error("spawn failed")); await user.click(screen.getByText("connect")); await waitFor(() => expect(screen.getByTestId("errored-server")).toHaveTextContent("A"), ); // A new attempt (this one resolves) clears the red-border flag. await user.click(screen.getByText("connect")); await waitFor(() => expect(screen.getByTestId("errored-server")).toHaveTextContent("none"), ); }); }); describe("App initializeResult when connected without serverInfo (#1772)", () => { beforeEach(() => { clientInstances.length = 0; vi.mocked(useInspectorClient).mockReturnValue(DEFAULT_USE_INSPECTOR_CLIENT); }); // A modern-era `server/discover` makes `serverInfo` optional, so a conforming // modern server can be `connected` with `serverInfo === undefined`. The header // (and its whole tab bar) is gated on `initializeResult` downstream, so it must // still be built in that case — otherwise the connected server shows no menu. it("builds initializeResult when connected even though serverInfo is undefined", () => { // DEFAULT_USE_INSPECTOR_CLIENT is exactly this case: connected + no serverInfo. renderWithMantine(); expect(screen.getByTestId("init-result")).not.toHaveTextContent("none"); }); it("falls back to the active server's catalog name when serverInfo is undefined", async () => { const user = userEvent.setup(); renderWithMantine(); // Connect server "A" (PlotRocket) via the mocked InspectorView control so it // becomes the active server; serverInfo is still undefined (default mock), so // the synthesized name must come from the catalog entry. await user.click(screen.getByText("connect")); await waitFor(() => expect(screen.getByTestId("init-result")).toHaveTextContent( "name:PlotRocket", ), ); }); it("does not build initializeResult while disconnected", () => { vi.mocked(useInspectorClient).mockReturnValue({ ...DEFAULT_USE_INSPECTOR_CLIENT, status: "disconnected", }); renderWithMantine(); expect(screen.getByTestId("init-result")).toHaveTextContent("none"); }); it("uses the reported serverInfo name when present (legacy / stamped modern)", () => { vi.mocked(useInspectorClient).mockReturnValue({ ...DEFAULT_USE_INSPECTOR_CLIENT, serverInfo: { name: "real-server", version: "2.0.0" }, }); renderWithMantine(); expect(screen.getByTestId("init-result")).toHaveTextContent( "name:real-server", ); }); // Pins the App → ConnectionInfoModal wiring of `serverInfoReported`: without // this, hard-coding it to `true` would keep the suite green and silently // reintroduce the fidelity bug. it("passes serverInfoReported=false to the modal, which shows 'not reported' (serverInfo omitted)", async () => { const user = userEvent.setup(); renderWithMantine(); await user.click(screen.getByText("connect")); // active server = A await user.click(screen.getByText("open-connection-info")); await waitFor(() => expect(screen.getAllByText(SERVER_INFO_NOT_REPORTED_LABEL)).toHaveLength( 2, ), ); // The synthesized catalog name is not presented as the server's report. // Exact-match query on purpose: the mocked InspectorView's `init-result` span // prints "name:PlotRocket" (prefixed), so this only matches a bare "PlotRocket" // — i.e. the modal's Name cell — not the harness span. expect(screen.queryByText("PlotRocket")).not.toBeInTheDocument(); }); it("passes serverInfoReported=true to the modal, which shows the reported name", async () => { vi.mocked(useInspectorClient).mockReturnValue({ ...DEFAULT_USE_INSPECTOR_CLIENT, serverInfo: { name: "real-server", version: "2.0.0" }, }); const user = userEvent.setup(); renderWithMantine(); await user.click(screen.getByText("connect")); await user.click(screen.getByText("open-connection-info")); await waitFor(() => expect(screen.getByText("real-server")).toBeInTheDocument(), ); expect( screen.queryByText(SERVER_INFO_NOT_REPORTED_LABEL), ).not.toBeInTheDocument(); }); }); describe("App session-scoped state reset on disconnect", () => { beforeEach(() => { clientInstances.length = 0; }); it("clears the per-call panels and resets the log level on client disconnect", async () => { const user = userEvent.setup(); renderWithMantine(); // Connect: builds the InspectorClient and registers the disconnect listener. await user.click(screen.getByText("connect")); await waitFor(() => expect(clientInstances).toHaveLength(1)); // Fill all three per-call panels with their (resolved) results. await user.click(screen.getByText("call")); await user.click(screen.getByText("get-prompt")); await user.click(screen.getByText("read-resource")); await waitFor(() => { expect(screen.getByTestId("tool-status")).toHaveTextContent("ok"); expect(screen.getByTestId("prompt-status")).toHaveTextContent("ok"); expect(screen.getByTestId("resource-status")).toHaveTextContent("ok"); }); // Set App-owned per-screen UI state (selection + search + filter) — all of // it persists across navigation, so all of it must reset on disconnect // (#1417). A representative sample across screens exercises the shared // `resetSessionScopedUiState` wiring. await user.click(screen.getByText("select-tool")); await user.click(screen.getByText("set-tool-search")); await user.click(screen.getByText("select-prompt")); await user.click(screen.getByText("set-log-filter")); await waitFor(() => { expect(screen.getByTestId("selected-tool")).toHaveTextContent("get_acts"); expect(screen.getByTestId("tool-search")).toHaveTextContent("act"); expect(screen.getByTestId("selected-prompt")).toHaveTextContent("greet"); expect(screen.getByTestId("log-filter")).toHaveTextContent("err"); }); // Bump the optimistic log level off its "info" default. await user.click(screen.getByText("set-level")); await waitFor(() => expect(screen.getByTestId("log-level")).toHaveTextContent("debug"), ); // Disconnect: every panel empties, all per-screen UI state clears, and the // level returns to "info". act(() => { clientInstances[0].dispatchEvent(new Event("disconnect")); }); await waitFor(() => { expect(screen.getByTestId("tool-status")).toHaveTextContent("none"); expect(screen.getByTestId("prompt-status")).toHaveTextContent("none"); expect(screen.getByTestId("resource-status")).toHaveTextContent("none"); }); expect(screen.getByTestId("selected-tool")).toHaveTextContent("none"); expect(screen.getByTestId("tool-search")).toHaveTextContent("none"); expect(screen.getByTestId("selected-prompt")).toHaveTextContent("none"); expect(screen.getByTestId("log-filter")).toHaveTextContent("none"); expect(screen.getByTestId("log-level")).toHaveTextContent("info"); }); it("persists the selected tool across navigation within a live session", async () => { const user = userEvent.setup(); renderWithMantine(); await user.click(screen.getByText("connect")); await waitFor(() => expect(clientInstances).toHaveLength(1)); // The selection lives in App (not the unmounting ToolsScreen), so once set // it stays put through re-renders / tab switches until the session ends. await user.click(screen.getByText("select-tool")); await waitFor(() => expect(screen.getByTestId("selected-tool")).toHaveTextContent("get_acts"), ); }); it("drops the previous tool's result when a different tool is selected", async () => { const user = userEvent.setup(); renderWithMantine(); await user.click(screen.getByText("connect")); await waitFor(() => expect(clientInstances).toHaveLength(1)); // Select a tool and run it so the result panel is populated. await user.click(screen.getByText("select-tool")); await user.click(screen.getByText("call")); await waitFor(() => expect(screen.getByTestId("tool-status")).toHaveTextContent("ok"), ); // Selecting a *different* tool clears the stale result so it doesn't linger // under the new selection. await user.click(screen.getByText("select-other-tool")); await waitFor(() => { expect(screen.getByTestId("selected-tool")).toHaveTextContent( "other_tool", ); expect(screen.getByTestId("tool-status")).toHaveTextContent("none"); }); }); it("keeps the result when the same tool stays selected (search/form edits)", async () => { const user = userEvent.setup(); renderWithMantine(); await user.click(screen.getByText("connect")); await waitFor(() => expect(clientInstances).toHaveLength(1)); await user.click(screen.getByText("select-tool")); await user.click(screen.getByText("call")); await waitFor(() => expect(screen.getByTestId("tool-status")).toHaveTextContent("ok"), ); // A search keystroke leaves `selectedToolKey` unchanged, so the result // stays put. await user.click(screen.getByText("set-tool-search")); await waitFor(() => expect(screen.getByTestId("tool-search")).toHaveTextContent("act"), ); expect(screen.getByTestId("tool-status")).toHaveTextContent("ok"); }); }); describe("App tool progress toasts", () => { beforeEach(() => { clientInstances.length = 0; notificationsMock.show.mockClear(); notificationsMock.update.mockClear(); notificationsMock.hide.mockClear(); }); it("shows a toast on the first progress tick and updates it on later ticks", async () => { const user = userEvent.setup(); renderWithMantine(); await user.click(screen.getByText("connect")); await waitFor(() => expect(clientInstances).toHaveLength(1)); // First tick of a progress stream → a fresh toast keyed by its stream id. act(() => { clientInstances[0].dispatchEvent( new CustomEvent("progressNotification", { detail: { progress: 1, total: 4, message: "Working" }, }), ); }); expect(notificationsMock.show).toHaveBeenCalledTimes(1); const shown = notificationsMock.show.mock.calls[0][0]; expect(shown.title).toBe("Tool progress"); expect(shown.message).toBe("Working — 1 / 4 (25%)"); // Second tick on the same stream → the existing toast is updated, not // stacked, so a chatty server doesn't flood the corner. act(() => { clientInstances[0].dispatchEvent( new CustomEvent("progressNotification", { detail: { progress: 2, total: 4, message: "Working" }, }), ); }); expect(notificationsMock.show).toHaveBeenCalledTimes(1); expect(notificationsMock.update).toHaveBeenCalledTimes(1); expect(notificationsMock.update.mock.calls[0][0].message).toBe( "Working — 2 / 4 (50%)", ); }); it("formats a totalless progress tick as the bare count", async () => { const user = userEvent.setup(); renderWithMantine(); await user.click(screen.getByText("connect")); await waitFor(() => expect(clientInstances).toHaveLength(1)); act(() => { clientInstances[0].dispatchEvent( new CustomEvent("progressNotification", { detail: { progress: 7 }, }), ); }); expect(notificationsMock.show.mock.calls[0][0].message).toBe("7"); }); it("dismisses still-visible progress toasts when the client is torn down", async () => { const user = userEvent.setup(); const { unmount } = renderWithMantine(); await user.click(screen.getByText("connect")); await waitFor(() => expect(clientInstances).toHaveLength(1)); act(() => { clientInstances[0].dispatchEvent( new CustomEvent("progressNotification", { detail: { progress: 1, total: 4 }, }), ); }); const id = notificationsMock.show.mock.calls[0][0].id; // Tearing down the client (here via unmount; same path as a server swap) // hides the live toast so it can't linger into — or race with — the next // session, rather than waiting out its auto-close window. unmount(); expect(notificationsMock.hide).toHaveBeenCalledWith(id); }); }); describe("App network-log body-dropped toast", () => { beforeEach(() => { clientInstances.length = 0; fetchLogInstances.length = 0; notificationsMock.show.mockClear(); notificationsMock.hide.mockClear(); }); it("shows a deduped toast when the fetch log emits fetchRequestBodyDropped", async () => { const user = userEvent.setup(); renderWithMantine(); await user.click(screen.getByText("connect")); await waitFor(() => expect(fetchLogInstances).toHaveLength(1)); act(() => { fetchLogInstances[0].dispatchEvent( new CustomEvent("fetchRequestBodyDropped", { detail: { id: "req-1", maxFetchRequests: 1000 }, }), ); }); expect(notificationsMock.show).toHaveBeenCalledTimes(1); const shown = notificationsMock.show.mock.calls[0][0]; expect(shown.title).toBe("Network log: response body dropped"); // Stable per-server id + no auto-close so a storm dedupes into one toast. expect(typeof shown.id).toBe("string"); expect(shown.autoClose).toBe(false); }); it("opens the settings modal (Options/Network Log Size) from the toast link", async () => { const user = userEvent.setup(); renderWithMantine(); await user.click(screen.getByText("connect")); await waitFor(() => expect(fetchLogInstances).toHaveLength(1)); act(() => { fetchLogInstances[0].dispatchEvent( new CustomEvent("fetchRequestBodyDropped", { detail: { id: "req-1", maxFetchRequests: 500 }, }), ); }); // The toast message is a React node carrying the "Adjust" link; render it // and click the link to exercise the onAdjust handler (hide toast + open // settings modal for the active server). const message = notificationsMock.show.mock.calls[0][0].message; renderWithMantine(message); await user.click( screen.getByRole("button", { name: /Adjust Network Log Size for this server/, }), ); expect(notificationsMock.hide).toHaveBeenCalled(); // The settings modal is now open on the Options section, showing the field. await waitFor(() => expect(screen.getByLabelText(/Network Log Size/)).toBeInTheDocument(), ); }); }); describe("App mid-session error toast", () => { beforeEach(() => { clientInstances.length = 0; notificationsMock.show.mockClear(); vi.mocked(useInspectorClient).mockReturnValue(DEFAULT_USE_INSPECTOR_CLIENT); }); afterEach(() => { vi.mocked(useInspectorClient).mockReturnValue(DEFAULT_USE_INSPECTOR_CLIENT); }); it("toasts the lastError with a generic title when no server is active", () => { // `lastError` is set but nothing has been connected, so the active-server // name ref is empty and the toast falls back to "Connection lost". vi.mocked(useInspectorClient).mockReturnValue({ ...DEFAULT_USE_INSPECTOR_CLIENT, lastError: "stdio subprocess crashed", }); renderWithMantine(); expect(notificationsMock.show).toHaveBeenCalledTimes(1); const shown = notificationsMock.show.mock.calls[0][0]; expect(shown.title).toBe("Connection lost"); expect(shown.message).toBe("stdio subprocess crashed"); expect(shown.color).toBe("red"); }); it("names the active server in the toast after a session has connected", async () => { const user = userEvent.setup(); renderWithMantine(); // Connect first so the active-server name ref is populated with SERVER_A. await user.click(screen.getByText("connect")); await waitFor(() => expect(clientInstances).toHaveLength(1)); // The transport now dies mid-session: `lastError` becomes set and the // client's `disconnect` event clears the active server. The name ref // survives, so the toast still names the server (PlotRocket). vi.mocked(useInspectorClient).mockReturnValue({ ...DEFAULT_USE_INSPECTOR_CLIENT, lastError: "SSE stream dropped", }); act(() => { clientInstances[0].dispatchEvent(new CustomEvent("disconnect")); }); await waitFor(() => expect(notificationsMock.show).toHaveBeenCalled()); const shown = notificationsMock.show.mock.calls.at(-1)?.[0]; expect(shown.title).toBe('Connection to "PlotRocket" lost'); expect(shown.message).toBe("SSE stream dropped"); expect(shown.color).toBe("red"); }); }); describe("App pending server-initiated request modal", () => { beforeEach(() => { clientInstances.length = 0; }); it("opens the modal on a pending sample, resolves it, and closes", async () => { const user = userEvent.setup(); renderWithMantine(); await user.click(screen.getByText("connect")); await waitFor(() => expect(clientInstances).toHaveLength(1)); // The usePendingClientRequests hook subscribes to the live client's // `pendingSamplesChange` event; firing it drives the App-owned modal that // InspectorView does not render. Mirrors how the client enqueues a // server-initiated sampling request mid tool-call. const respond = vi.fn().mockResolvedValue(undefined); const sample = { id: "sample-1", request: { params: { messages: [{ role: "user", content: { type: "text", text: "Hi" } }], maxTokens: 256, }, }, respond, reject: vi.fn(), }; act(() => { clientInstances[0].dispatchEvent( new CustomEvent("pendingSamplesChange", { detail: [sample] }), ); }); await waitFor(() => expect(screen.getByText("Sampling Request")).toBeInTheDocument(), ); // Resolving via the modal calls the queued request's respond() — this is // what unblocks the originating call (the "spinner clears" criterion). await user.click(screen.getByRole("button", { name: "Send Response" })); expect(respond).toHaveBeenCalledTimes(1); // The client clearing its queue (empty event) closes the modal. act(() => { clientInstances[0].dispatchEvent( new CustomEvent("pendingSamplesChange", { detail: [] }), ); }); await waitFor(() => expect(screen.queryByText("Sampling Request")).not.toBeInTheDocument(), ); }); }); describe("App task wiring", () => { beforeEach(() => { clientInstances.length = 0; notificationsMock.show.mockClear(); notificationsMock.update.mockClear(); notificationsMock.hide.mockClear(); // Restore the default task-hook return between tests that override it. vi.mocked(useManagedRequestorTasks).mockReturnValue({ tasks: [], refresh: vi.fn().mockResolvedValue([]), clearCompleted: vi.fn(), }); // Restore the default capabilities (no task tool calls) between tests. vi.mocked(useInspectorClient).mockReturnValue(DEFAULT_USE_INSPECTOR_CLIENT); }); it("routes a Run-as-task call through callToolStream with the server's TTL", async () => { // onCallTool only task-augments when the server advertises task tool calls. vi.mocked(useInspectorClient).mockReturnValue({ ...DEFAULT_USE_INSPECTOR_CLIENT, capabilities: { tasks: { requests: { tools: { call: {} } } } }, }); const user = userEvent.setup(); renderWithMantine(); await user.click(screen.getByText("connect")); await waitFor(() => expect(clientInstances).toHaveLength(1)); await user.click(screen.getByText("call-as-task")); const client = clientInstances[0] as unknown as { callToolStream: ReturnType; callTool: ReturnType; }; await waitFor(() => expect(client.callToolStream).toHaveBeenCalledTimes(1)); expect(client.callTool).not.toHaveBeenCalled(); // 5th arg is the task options; TTL falls back to the 60000 default since // SERVER_A has no `settings.taskTtl`. expect(client.callToolStream.mock.calls[0][4]).toEqual({ ttl: 60000 }); }); it("does NOT task-augment when the server lacks task-tool-call support", async () => { // Default capabilities (no tasks.requests.tools.call). A stale run-as-task // request must fall back to the normal callTool path, never callToolStream. const user = userEvent.setup(); renderWithMantine(); await user.click(screen.getByText("connect")); await waitFor(() => expect(clientInstances).toHaveLength(1)); await user.click(screen.getByText("call-as-task")); const client = clientInstances[0] as unknown as { callToolStream: ReturnType; callTool: ReturnType; }; await waitFor(() => expect(client.callTool).toHaveBeenCalledTimes(1)); expect(client.callToolStream).not.toHaveBeenCalled(); }); it("surfaces a cancel failure as a red toast", async () => { const user = userEvent.setup(); renderWithMantine(); await user.click(screen.getByText("connect")); await waitFor(() => expect(clientInstances).toHaveLength(1)); ( clientInstances[0] as unknown as { cancelRequestorTask: ReturnType; } ).cancelRequestorTask.mockRejectedValueOnce(new Error("nope")); await user.click(screen.getByText("cancel-task")); await waitFor(() => expect(notificationsMock.show).toHaveBeenCalledWith( expect.objectContaining({ title: "Failed to cancel task", color: "red", }), ), ); }); it("cancels the underlying task when a task-augmented tool call is cancelled", async () => { const user = userEvent.setup(); renderWithMantine(); await user.click(screen.getByText("connect")); await waitFor(() => expect(clientInstances).toHaveLength(1)); const client = clientInstances[0] as unknown as { cancelRequestorTask: ReturnType; }; // callToolStream surfaces the created task's id via `toolCallTaskUpdated` // mid-call; App stashes it so Cancel knows which task to cancel (#1455). act(() => { clientInstances[0].dispatchEvent( new CustomEvent("toolCallTaskUpdated", { detail: { taskId: "task-42", task: { taskId: "task-42" } }, }), ); }); await user.click(screen.getByText("cancel-tool-call")); expect(client.cancelRequestorTask).toHaveBeenCalledWith("task-42"); }); it("Cancel on a task-input-required elicitation cancels the task, not answers it (#1631)", async () => { // The user-facing half of the cancelable-loop fix: clicking Cancel on a // MODERN task's input_required modal must cancel the underlying task (so the // poll unblocks) rather than send an { action: "cancel" } answer that a // non-advancing server would just re-prompt on. const user = userEvent.setup(); renderWithMantine(); await user.click(screen.getByText("connect")); await waitFor(() => expect(clientInstances).toHaveLength(1)); const client = clientInstances[0] as unknown as { cancelRequestorTask: ReturnType; }; const respond = vi.fn().mockResolvedValue(undefined); const elicitation = { id: "elicitation-1", origin: "task-input-required", taskId: "task-77", request: { params: { message: "Approve this task before it continues?", requestedSchema: { type: "object", properties: { approved: { type: "boolean", title: "Approved" } }, }, }, }, respond, }; act(() => { clientInstances[0].dispatchEvent( new CustomEvent("pendingElicitationsChange", { detail: [elicitation] }), ); }); await waitFor(() => expect( screen.getByText(/Approve this task before it continues/), ).toBeInTheDocument(), ); await user.click(screen.getByRole("button", { name: "Cancel" })); expect(client.cancelRequestorTask).toHaveBeenCalledWith("task-77"); // The task is cancelled instead of answering the request. expect(respond).not.toHaveBeenCalled(); }); it("does not re-cancel on a rapid second Cancel click", async () => { const user = userEvent.setup(); renderWithMantine(); await user.click(screen.getByText("connect")); await waitFor(() => expect(clientInstances).toHaveLength(1)); const client = clientInstances[0] as unknown as { cancelRequestorTask: ReturnType; }; act(() => { clientInstances[0].dispatchEvent( new CustomEvent("toolCallTaskUpdated", { detail: { taskId: "task-42", task: { taskId: "task-42" } }, }), ); }); // Two clicks before the call resolves must cancel only once — the second // finds the ref already cleared, avoiding a spurious cancel of a terminal // task. await user.click(screen.getByText("cancel-tool-call")); await user.click(screen.getByText("cancel-tool-call")); expect(client.cancelRequestorTask).toHaveBeenCalledTimes(1); }); it("aborts the request (not a task) when an ordinary tool call is cancelled", async () => { const user = userEvent.setup(); renderWithMantine(); await user.click(screen.getByText("connect")); await waitFor(() => expect(clientInstances).toHaveLength(1)); const client = clientInstances[0] as unknown as { cancelRequestorTask: ReturnType; cancelToolCall: ReturnType; }; // A stale task id from an earlier task call... act(() => { clientInstances[0].dispatchEvent( new CustomEvent("toolCallTaskUpdated", { detail: { taskId: "old-task", task: { taskId: "old-task" } }, }), ); }); // ...is cleared when a new ordinary call starts, so Cancel routes to the // request-abort path (notifications/cancelled), not the task API (#1458). await user.click(screen.getByText("call")); await waitFor(() => expect(screen.getByTestId("tool-status")).toHaveTextContent("ok"), ); await user.click(screen.getByText("cancel-tool-call")); expect(client.cancelToolCall).toHaveBeenCalledTimes(1); expect(client.cancelRequestorTask).not.toHaveBeenCalled(); }); it("clears the executing state and toasts when a cancelled call rejects", async () => { const user = userEvent.setup(); renderWithMantine(); await user.click(screen.getByText("connect")); await waitFor(() => expect(clientInstances).toHaveLength(1)); // The aborted call rejects with ToolCallCancelledError (the SDK already sent // notifications/cancelled). App treats that as a clean cancel, not a failure. ( clientInstances[0] as unknown as { callTool: ReturnType; } ).callTool.mockRejectedValueOnce(new ToolCallCancelledError("get_acts")); await user.click(screen.getByText("call")); // The result panel returns to idle (no error state)... await waitFor(() => expect(screen.getByTestId("tool-status")).toHaveTextContent("none"), ); // ...and a confirmation toast acknowledges the cancellation. expect(notificationsMock.show).toHaveBeenCalledWith( expect.objectContaining({ title: "Tool call cancelled" }), ); }); it("shows a URL-elicitation toast when a tool call fails with a no-list -32042", async () => { const user = userEvent.setup(); renderWithMantine(); await user.click(screen.getByText("connect")); await waitFor(() => expect(clientInstances).toHaveLength(1)); ( clientInstances[0] as unknown as { callTool: ReturnType; } ).callTool.mockRejectedValueOnce( new ProtocolError( ProtocolErrorCode.UrlElicitationRequired, "This request requires browser-based authorization.", ), ); await user.click(screen.getByText("call")); await waitFor(() => expect(notificationsMock.show).toHaveBeenCalledWith( expect.objectContaining({ title: "URL elicitation required", color: "yellow", }), ), ); }); it("shows a loop toast when a tool call aborts on a repeated URL elicitation", async () => { const user = userEvent.setup(); renderWithMantine(); await user.click(screen.getByText("connect")); await waitFor(() => expect(clientInstances).toHaveLength(1)); ( clientInstances[0] as unknown as { callTool: ReturnType; } ).callTool.mockRejectedValueOnce( new UrlElicitationLoopError("https://example.com/authorize"), ); await user.click(screen.getByText("call")); await waitFor(() => expect(notificationsMock.show).toHaveBeenCalledWith( expect.objectContaining({ title: "URL elicitation loop", color: "yellow", }), ), ); }); it("surfaces a refresh failure as a red toast", async () => { vi.mocked(useManagedRequestorTasks).mockReturnValue({ tasks: [], refresh: vi.fn().mockRejectedValue(new Error("list boom")), clearCompleted: vi.fn(), }); const user = userEvent.setup(); renderWithMantine(); await user.click(screen.getByText("connect")); await waitFor(() => expect(clientInstances).toHaveLength(1)); await user.click(screen.getByText("refresh-tasks")); await waitFor(() => expect(notificationsMock.show).toHaveBeenCalledWith( expect.objectContaining({ title: "Failed to refresh tasks", color: "red", }), ), ); }); it("clear-completed calls through to the hook's clearCompleted", async () => { const clearCompleted = vi.fn(); vi.mocked(useManagedRequestorTasks).mockReturnValue({ tasks: [], refresh: vi.fn().mockResolvedValue([]), clearCompleted, }); const user = userEvent.setup(); renderWithMantine(); await user.click(screen.getByText("connect")); await waitFor(() => expect(clientInstances).toHaveLength(1)); await user.click(screen.getByText("clear-completed")); expect(clearCompleted).toHaveBeenCalledTimes(1); }); it("shows a task-status toast, updates it, and hides it on terminal status", async () => { const user = userEvent.setup(); renderWithMantine(); await user.click(screen.getByText("connect")); await waitFor(() => expect(clientInstances).toHaveLength(1)); // First status → a fresh toast keyed by the task id. act(() => { clientInstances[0].dispatchEvent( new CustomEvent("taskStatusChange", { detail: { taskId: "task-1", task: { status: "working", statusMessage: "Interpreting" }, }, }), ); }); expect(notificationsMock.show).toHaveBeenCalledTimes(1); const shown = notificationsMock.show.mock.calls[0][0]; expect(shown.title).toBe("Task working"); expect(shown.message).toBe("Interpreting"); // Next status on the same task → the existing toast is updated, not stacked. act(() => { clientInstances[0].dispatchEvent( new CustomEvent("taskStatusChange", { detail: { taskId: "task-1", task: { status: "working", statusMessage: "Still going" }, }, }), ); }); expect(notificationsMock.show).toHaveBeenCalledTimes(1); expect(notificationsMock.update).toHaveBeenCalledTimes(1); // Terminal status → the toast is hidden. act(() => { clientInstances[0].dispatchEvent( new CustomEvent("taskStatusChange", { detail: { taskId: "task-1", task: { status: "completed", statusMessage: "Done" }, }, }), ); }); expect(notificationsMock.hide).toHaveBeenCalledWith(shown.id); }); it("builds progressByTaskId from requestorTaskProgress and prunes it on terminal status", async () => { const user = userEvent.setup(); renderWithMantine(); await user.click(screen.getByText("connect")); await waitFor(() => expect(clientInstances).toHaveLength(1)); expect(screen.getByTestId("task-progress-keys")).toHaveTextContent("none"); act(() => { clientInstances[0].dispatchEvent( new CustomEvent("requestorTaskProgress", { detail: { taskId: "task-1", progress: { progress: 2, total: 5, message: "Halfway" }, }, }), ); }); await waitFor(() => expect(screen.getByTestId("task-progress-keys")).toHaveTextContent( "task-1", ), ); // A terminal task update prunes the entry. act(() => { clientInstances[0].dispatchEvent( new CustomEvent("requestorTaskUpdated", { detail: { taskId: "task-1", task: { status: "completed", statusMessage: "Done" }, }, }), ); }); await waitFor(() => expect(screen.getByTestId("task-progress-keys")).toHaveTextContent( "none", ), ); }); it("shows a 'Task cancelled' toast when a task is cancelled", async () => { const user = userEvent.setup(); renderWithMantine(); await user.click(screen.getByText("connect")); await waitFor(() => expect(clientInstances).toHaveLength(1)); // No live status toast for this task — cancellation shows a fresh one. act(() => { clientInstances[0].dispatchEvent( new CustomEvent("taskCancelled", { detail: { taskId: "task-1" } }), ); }); expect(notificationsMock.show).toHaveBeenCalledWith( expect.objectContaining({ title: "Task cancelled", color: "gray" }), ); }); it("converts a running task's live toast into the cancellation toast", async () => { const user = userEvent.setup(); renderWithMantine(); await user.click(screen.getByText("connect")); await waitFor(() => expect(clientInstances).toHaveLength(1)); // A running task has an open "Task working" toast... act(() => { clientInstances[0].dispatchEvent( new CustomEvent("taskStatusChange", { detail: { taskId: "task-1", task: { status: "working", statusMessage: "Interpreting" }, }, }), ); }); const liveId = notificationsMock.show.mock.calls[0][0].id; notificationsMock.show.mockClear(); // ...which the cancel replaces in place (update), not a stacked toast. act(() => { clientInstances[0].dispatchEvent( new CustomEvent("taskCancelled", { detail: { taskId: "task-1" } }), ); }); expect(notificationsMock.show).not.toHaveBeenCalled(); expect(notificationsMock.update).toHaveBeenCalledWith( expect.objectContaining({ id: liveId, title: "Task cancelled" }), ); }); }); // Live-apply roots on settings-dialog close: the App diffs the final draft // roots against what the connected client advertises and calls `setRoots` // once (not per keystroke), and only for the active server. App.tsx is // excluded from the coverage gate, but the acceptance criterion ("editing // roots on a live connection notifies the server on dialog close") lives // here, so it's worth a direct test of the gate/diff/notify path. type RootsFakeClient = EventTarget & { setRoots: ReturnType; getRoots: ReturnType; }; const settingsWithRoots = ( roots: InspectorServerSettings["roots"], ): InspectorServerSettings => ({ headers: [], env: [], metadata: [], connectionTimeout: 0, requestTimeout: 0, taskTtl: 60000, maxFetchRequests: 1000, roots, }); describe("App roots live-apply on settings-dialog close", () => { beforeEach(() => { clientInstances.length = 0; vi.mocked(useInspectorClient).mockReturnValue(DEFAULT_USE_INSPECTOR_CLIENT); }); afterEach(() => { // Restore the default empty draft so the override doesn't leak. vi.mocked(useSettingsDraft).mockReturnValue({ draft: undefined, onChange: vi.fn(), flush: vi.fn(), }); }); async function openSettingsForConnectedServer( draft: InspectorServerSettings, ): Promise { vi.mocked(useSettingsDraft).mockReturnValue({ draft, onChange: vi.fn(), flush: vi.fn(), }); const user = userEvent.setup(); renderWithMantine(); await user.click(screen.getByText("connect")); await waitFor(() => expect(clientInstances).toHaveLength(1)); await user.click(screen.getByText("open-settings")); await waitFor(() => expect(screen.getByText("Server Settings")).toBeInTheDocument(), ); return clientInstances[0] as RootsFakeClient; } async function closeModal(user: ReturnType) { const closeBtn = document.querySelector( "button.mantine-CloseButton-root", ) as HTMLButtonElement | null; expect(closeBtn).not.toBeNull(); await user.click(closeBtn!); } it("calls setRoots once with cleaned roots when roots changed on the active server", async () => { const user = userEvent.setup(); const client = await openSettingsForConnectedServer( // A blank-uri row (left mid-edit) must be dropped; the named root kept. settingsWithRoots([{ uri: "file:///x", name: "X" }, { uri: "" }]), ); // Client currently advertises no roots → the draft differs → notify. client.getRoots.mockReturnValue([]); await closeModal(user); await waitFor(() => expect(client.setRoots).toHaveBeenCalledTimes(1)); expect(client.setRoots).toHaveBeenCalledWith([ { uri: "file:///x", name: "X" }, ]); }); it("does not call setRoots when the cleaned roots match what the client advertises", async () => { const user = userEvent.setup(); const client = await openSettingsForConnectedServer( settingsWithRoots([{ uri: "file:///x" }]), ); // Same roots already advertised → no notification on close. client.getRoots.mockReturnValue([{ uri: "file:///x" }]); await closeModal(user); // Let any close-handler microtasks settle, then assert no notification. await waitFor(() => expect(screen.queryByText("Server Settings")).not.toBeInTheDocument(), ); expect(client.setRoots).not.toHaveBeenCalled(); }); }); describe("App history pin/replay", () => { const replayableEntry: MessageEntry = { id: "hist-1", timestamp: new Date("2026-06-06T22:00:00Z"), direction: "request", message: { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "get_acts", arguments: { city: "SF" } }, }, }; beforeEach(() => { clientInstances.length = 0; notificationsMock.show.mockClear(); vi.mocked(useInspectorClient).mockReturnValue(DEFAULT_USE_INSPECTOR_CLIENT); vi.mocked(useMessageLog).mockReturnValue({ messages: [] }); }); it("toggles a pinned history id and passes the set down to the view", async () => { const user = userEvent.setup(); renderWithMantine(); await user.click(screen.getByText("connect")); await waitFor(() => expect(clientInstances).toHaveLength(1)); expect(screen.getByTestId("pinned-history")).toHaveTextContent(""); await user.click(screen.getByText("toggle-pin")); expect(screen.getByTestId("pinned-history")).toHaveTextContent("hist-1"); await user.click(screen.getByText("toggle-pin")); expect(screen.getByTestId("pinned-history")).toHaveTextContent(""); }); it("panel Clear removes unpinned history but keeps pinned entries", async () => { const user = userEvent.setup(); renderWithMantine(); await user.click(screen.getByText("connect")); await waitFor(() => expect(clientInstances).toHaveLength(1)); await user.click(screen.getByText("toggle-pin")); expect(screen.getByTestId("pinned-history")).toHaveTextContent("hist-1"); messageLogClear.mockClear(); await user.click(screen.getByText("clear-history")); expect(messageLogClear).toHaveBeenCalledTimes(1); const predicate = messageLogClear.mock.calls[0][0] as (m: { id: string; }) => boolean; // The predicate is "should remove?" — pinned survives (false), unpinned is // removed (true). expect(predicate({ id: "hist-1" })).toBe(false); expect(predicate({ id: "other" })).toBe(true); }); it("replays a tools/call entry by re-issuing callTool with the recorded args", async () => { vi.mocked(useMessageLog).mockReturnValue({ messages: [replayableEntry] }); const user = userEvent.setup(); renderWithMantine(); await user.click(screen.getByText("connect")); await waitFor(() => expect(clientInstances).toHaveLength(1)); await user.click(screen.getByText("replay-history")); const client = clientInstances[0] as unknown as { callTool: ReturnType; }; await waitFor(() => expect(client.callTool).toHaveBeenCalledTimes(1)); expect(client.callTool.mock.calls[0][1]).toEqual({ city: "SF" }); }); it("replays a tools/list entry via listTools, preserving the cursor", async () => { vi.mocked(useMessageLog).mockReturnValue({ messages: [ { ...replayableEntry, message: { jsonrpc: "2.0", id: 6, method: "tools/list", params: { cursor: "page-2" }, }, }, ], }); const user = userEvent.setup(); renderWithMantine(); await user.click(screen.getByText("connect")); await waitFor(() => expect(clientInstances).toHaveLength(1)); await user.click(screen.getByText("replay-history")); const client = clientInstances[0] as unknown as { listTools: ReturnType; }; await waitFor(() => expect(client.listTools).toHaveBeenCalledWith("page-2"), ); }); it("replays a tasks/list entry via listRequestorTasks", async () => { vi.mocked(useMessageLog).mockReturnValue({ messages: [ { ...replayableEntry, message: { jsonrpc: "2.0", id: 7, method: "tasks/list" }, }, ], }); const user = userEvent.setup(); renderWithMantine(); await user.click(screen.getByText("connect")); await waitFor(() => expect(clientInstances).toHaveLength(1)); await user.click(screen.getByText("replay-history")); const client = clientInstances[0] as unknown as { listRequestorTasks: ReturnType; }; await waitFor(() => expect(client.listRequestorTasks).toHaveBeenCalledTimes(1), ); }); it("toasts when replaying an unsupported method", async () => { vi.mocked(useMessageLog).mockReturnValue({ messages: [ { ...replayableEntry, message: { jsonrpc: "2.0", id: 2, method: "logging/setLevel", params: { level: "debug" }, }, }, ], }); const user = userEvent.setup(); renderWithMantine(); await user.click(screen.getByText("connect")); await waitFor(() => expect(clientInstances).toHaveLength(1)); await user.click(screen.getByText("replay-history")); await waitFor(() => expect(notificationsMock.show).toHaveBeenCalledWith( expect.objectContaining({ title: "Can't replay", color: "yellow" }), ), ); }); it("replays a prompts/get entry via getPrompt", async () => { vi.mocked(useMessageLog).mockReturnValue({ messages: [ { ...replayableEntry, message: { jsonrpc: "2.0", id: 3, method: "prompts/get", params: { name: "greet", arguments: { who: "x" } }, }, }, ], }); const user = userEvent.setup(); renderWithMantine(); await user.click(screen.getByText("connect")); await waitFor(() => expect(clientInstances).toHaveLength(1)); await user.click(screen.getByText("replay-history")); const client = clientInstances[0] as unknown as { getPrompt: ReturnType; }; await waitFor(() => expect(client.getPrompt).toHaveBeenCalledWith("greet", { who: "x" }), ); }); it("replays a resources/read entry via readResource", async () => { vi.mocked(useMessageLog).mockReturnValue({ messages: [ { ...replayableEntry, message: { jsonrpc: "2.0", id: 4, method: "resources/read", params: { uri: "res://x" }, }, }, ], }); const user = userEvent.setup(); renderWithMantine(); await user.click(screen.getByText("connect")); await waitFor(() => expect(clientInstances).toHaveLength(1)); await user.click(screen.getByText("replay-history")); const client = clientInstances[0] as unknown as { readResource: ReturnType; }; await waitFor(() => expect(client.readResource).toHaveBeenCalledWith("res://x"), ); }); it("toasts when the replayed tool is no longer available", async () => { vi.mocked(useMessageLog).mockReturnValue({ messages: [ { ...replayableEntry, message: { jsonrpc: "2.0", id: 5, method: "tools/call", params: { name: "gone", arguments: {} }, }, }, ], }); const user = userEvent.setup(); renderWithMantine(); await user.click(screen.getByText("connect")); await waitFor(() => expect(clientInstances).toHaveLength(1)); await user.click(screen.getByText("replay-history")); await waitFor(() => expect(notificationsMock.show).toHaveBeenCalledWith( expect.objectContaining({ title: "Can't replay", color: "yellow" }), ), ); }); }); // The `/oauth/callback` handler must reject a returned `state` that does not // parse to the expected 64-char-hex authId shape (a forgery indicator) instead // of silently proceeding. See #1562. describe("App OAuth callback state validation", () => { const originalUrl = window.location.href; beforeEach(() => { notificationsMock.show.mockClear(); window.sessionStorage.clear(); }); afterEach(() => { window.history.replaceState({}, "", originalUrl); }); it("rejects an unparseable state param with a clear error toast", async () => { window.history.replaceState( {}, "", "/oauth/callback?code=abc123&state=not-a-valid-state", ); renderWithMantine(); await waitFor(() => expect(notificationsMock.show).toHaveBeenCalledWith( expect.objectContaining({ title: "OAuth callback rejected", color: "red", }), ), ); }); it("does not reject when the state param parses to a valid authId", async () => { // A well-formed 64-char-hex state passes the shape guard, so the handler // proceeds to the server-matching step. With the resume snapshot pointing at // a server id that is not registered, that step surfaces the "could not be // matched" toast — asserting on that specific downstream toast proves the // state was accepted (never the "OAuth callback rejected" toast) rather than // relying on an indirect "some toast fired" check. writeOAuthResumeSnapshot({ version: 1, serverId: "server-that-does-not-exist", activeTab: "Tools", authKind: "reauth", tabUi: {}, }); window.history.replaceState( {}, "", `/oauth/callback?code=abc123&state=${"a".repeat(64)}`, ); renderWithMantine(); await waitFor(() => expect(notificationsMock.show).toHaveBeenCalledWith( expect.objectContaining({ title: "OAuth callback could not be matched", }), ), ); expect(notificationsMock.show).not.toHaveBeenCalledWith( expect.objectContaining({ title: "OAuth callback rejected" }), ); }); }); describe("App OAuth resume lifecycle", () => { const storage = new Map(); const writeTestOAuthSnapshot = ( overrides?: Partial, ): void => { writeOAuthResumeSnapshot({ version: 1, serverId: "A", activeTab: "Tools", authKind: "reauth", tabUi: {}, ...overrides, }); }; beforeEach(() => { clientInstances.length = 0; storage.clear(); notificationsMock.show.mockClear(); vi.stubGlobal("sessionStorage", { getItem: (key: string) => storage.get(key) ?? null, setItem: (key: string, value: string) => { storage.set(key, value); }, removeItem: (key: string) => { storage.delete(key); }, }); window.history.replaceState({}, "", "/"); }); afterEach(() => { vi.unstubAllGlobals(); window.history.replaceState({}, "", "/"); }); it("preserves the OAuth resume snapshot when the transport disconnects", async () => { const user = userEvent.setup(); renderWithMantine(); await user.click(screen.getByText("connect")); await waitFor(() => expect(clientInstances).toHaveLength(1)); writeTestOAuthSnapshot(); await user.click(screen.getByText("select-tool")); await waitFor(() => expect(screen.getByTestId("selected-tool")).toHaveTextContent("get_acts"), ); act(() => { clientInstances[0].dispatchEvent(new Event("disconnect")); }); await waitFor(() => expect(screen.getByTestId("selected-tool")).toHaveTextContent("none"), ); expect(readOAuthResumeSnapshot()?.serverId).toBe("A"); }); it("preserves the OAuth resume snapshot when reconnect rebuilds the client", async () => { const user = userEvent.setup(); renderWithMantine(); await user.click(screen.getByText("connect")); await waitFor(() => expect(clientInstances).toHaveLength(1)); writeTestOAuthSnapshot(); act(() => { clientInstances[0].dispatchEvent(new Event("disconnect")); }); expect(readOAuthResumeSnapshot()?.serverId).toBe("A"); await user.click(screen.getByText("connect")); await waitFor(() => expect(clientInstances).toHaveLength(2)); expect(readOAuthResumeSnapshot()?.serverId).toBe("A"); }); it("clears the OAuth resume snapshot on explicit disconnect toggle", async () => { const user = userEvent.setup(); renderWithMantine(); await user.click(screen.getByText("connect")); await waitFor(() => expect(clientInstances).toHaveLength(1)); writeTestOAuthSnapshot(); expect(readOAuthResumeSnapshot()?.serverId).toBe("A"); await user.click(screen.getByText("connect")); await waitFor(() => expect(readOAuthResumeSnapshot()).toBeUndefined()); }); it("matches the OAuth callback to the pending server when a resume snapshot exists", async () => { writeTestOAuthSnapshot(); window.history.replaceState({}, "", `${OAUTH_CALLBACK_PATH}?code=test`); renderWithMantine(); await waitFor(() => expect(clientInstances).toHaveLength(1)); const client = clientInstances[0] as unknown as { resumeAfterOAuth: ReturnType; }; await waitFor(() => expect(client.resumeAfterOAuth).toHaveBeenCalledWith( "test", expect.any(Object), ), ); expect(readOAuthResumeSnapshot()).toBeUndefined(); expect( notificationsMock.show.mock.calls.some( ([args]) => args.title === "OAuth callback could not be matched", ), ).toBe(false); }); it("does not restore a stale tab after callback consume and reconnect", async () => { writeTestOAuthSnapshot({ activeTab: "Tools" }); window.history.replaceState({}, "", `${OAUTH_CALLBACK_PATH}?code=test`); renderWithMantine(); await waitFor(() => expect(screen.getByTestId("active-tab")).toHaveTextContent("Tools"), ); expect(readOAuthResumeSnapshot()).toBeUndefined(); window.history.replaceState({}, "", "/"); const user = userEvent.setup(); // Explicit disconnect while still on Tools (InspectorView clamps to Servers // visually, but App must reset activeTab so reconnect does not pop back). await user.click(screen.getByText("connect")); await waitFor(() => expect(screen.getByTestId("active-tab")).toHaveTextContent( INSPECTOR_SERVERS_TAB, ), ); await user.click(screen.getByText("connect")); await waitFor(() => expect(screen.getByTestId("active-tab")).toHaveTextContent( INSPECTOR_SERVERS_TAB, ), ); await user.click(screen.getByText("connect")); await waitFor(() => expect(screen.getByTestId("active-tab")).toHaveTextContent( INSPECTOR_SERVERS_TAB, ), ); }); }); // A callback that lands without recorded SEP-2352 discovery state used to // dead-end with the SDK's raw `AuthorizationServerMismatchError` text. It now // raises an explicit recovery affordance — but only for that case; a genuine // cross-authorization-server mismatch stays a security error. See #1808. describe("App OAuth callback issuer-binding failures (#1808)", () => { const storage = new Map(); const HTTP_SERVER: ServerEntry = { id: "A", name: "PlotRocket", config: { type: "streamable-http", url: "https://mcp.example.com/mcp" }, connection: { status: "disconnected" }, }; const STDIO_SERVER: ServerEntry = { id: "A", name: "PlotRocket", config: { type: "stdio", command: "node" }, connection: { status: "disconnected" }, }; /** Full `useServers` shape so the override typechecks like the real hook. */ const useServersResult = ( servers: ServerEntry[], ): ReturnType => ({ servers, loading: false, error: undefined, refresh: vi.fn().mockResolvedValue(undefined), addServer: vi.fn().mockResolvedValue(undefined), updateServer: vi.fn().mockResolvedValue(undefined), updateServerSettings: updateServerSettingsSpy, removeServer: vi.fn().mockResolvedValue(undefined), reorderServers: vi.fn().mockResolvedValue(undefined), importSource: vi.fn().mockResolvedValue({ servers: {} }), }); // A real SDK error, not a look-alike: the SDK's `mcpBrand` is static, so a // fabricated instance-branded object would exercise a shape that cannot occur. const mismatchError = (recordedIssuer: string): Error => new AuthorizationServerMismatchError( recordedIssuer, "https://as.example.com", ); const renderCallbackWithFailure = (err: Error) => { writeOAuthResumeSnapshot({ version: 1, serverId: "A", activeTab: "Tools", authKind: "reauth", tabUi: {}, }); window.history.replaceState({}, "", `${OAUTH_CALLBACK_PATH}?code=test`); rejectNextResumeAfterOAuth(err); renderWithMantine(); }; beforeEach(() => { clientInstances.length = 0; storage.clear(); notificationsMock.show.mockClear(); vi.mocked(useInspectorClient).mockReturnValue({ ...DEFAULT_USE_INSPECTOR_CLIENT, status: "disconnected", }); vi.mocked(useServers).mockReturnValue(useServersResult([HTTP_SERVER])); vi.stubGlobal("sessionStorage", { getItem: (key: string) => storage.get(key) ?? null, setItem: (key: string, value: string) => { storage.set(key, value); }, removeItem: (key: string) => { storage.delete(key); }, }); window.history.replaceState({}, "", "/"); }); afterEach(() => { vi.unstubAllGlobals(); vi.mocked(useInspectorClient).mockReturnValue(DEFAULT_USE_INSPECTOR_CLIENT); vi.mocked(useServers).mockReturnValue(useServersResult([STDIO_SERVER])); window.history.replaceState({}, "", "/"); }); it("offers an explicit re-authorize affordance when the recorded state was lost", async () => { renderCallbackWithFailure( mismatchError( "discoveryState was not available on the callback leg; ensure your provider persists discoveryState alongside codeVerifier", ), ); expect( await screen.findByText("Authorization state was lost"), ).toBeInTheDocument(); expect( screen.getByRole("button", { name: "Authorize again" }), ).toBeInTheDocument(); // The raw SDK wording never reaches the user. expect(screen.queryByText(/discoveryState/)).not.toBeInTheDocument(); expect(screen.queryByText("Re-authentication required")).toBeNull(); }); it("clears the stale OAuth state and reconnects when the affordance is used", async () => { const user = userEvent.setup(); renderCallbackWithFailure( mismatchError("discoveryState was not available on the callback leg"), ); const button = await screen.findByRole("button", { name: "Authorize again", }); await waitFor(() => expect(clientInstances).toHaveLength(1)); const callbackClient = clientInstances[0] as unknown as { clearOAuthTokens: ReturnType; }; await user.click(button); await waitFor(() => expect(callbackClient.clearOAuthTokens).toHaveBeenCalled(), ); // A fresh client is built for the retry connect. await waitFor(() => expect(clientInstances.length).toBeGreaterThan(1)); expect(screen.queryByText("Authorization state was lost")).toBeNull(); }); it("treats a genuine issuer mismatch as a security error with no recovery button", async () => { renderCallbackWithFailure(mismatchError("https://old.example.com")); await waitFor(() => expect(notificationsMock.show).toHaveBeenCalledWith( expect.objectContaining({ title: "Authorization server mismatch", color: "red", }), ), ); expect(screen.queryByText("Authorization state was lost")).toBeNull(); expect( screen.queryByRole("button", { name: "Authorize again" }), ).toBeNull(); }); it("falls back to the generic re-auth banner for an unrelated callback failure", async () => { renderCallbackWithFailure(new Error("token endpoint exploded")); expect( await screen.findByText("Re-authentication required"), ).toBeInTheDocument(); expect(screen.queryByText("Authorization state was lost")).toBeNull(); }); }); describe("App paginated list pagination toggle (#1721)", () => { beforeEach(() => { clientInstances.length = 0; updateServerSettingsSpy.mockClear(); }); it("persists and live-pushes paginatedLists when the sidebar toggle flips", async () => { const user = userEvent.setup(); renderWithMantine(); await user.click(screen.getByText("connect")); await waitFor(() => expect(clientInstances).toHaveLength(1)); expect(screen.getByTestId("tools-paginated")).toHaveTextContent("false"); await user.click(screen.getByText("paginated-on")); // Optimistic UI flip is immediate. await waitFor(() => expect(screen.getByTestId("tools-paginated")).toHaveTextContent("true"), ); // Persisted to the server settings (survives reconnects). expect(updateServerSettingsSpy).toHaveBeenCalledWith( "A", expect.objectContaining({ paginatedLists: true }), ); // Live-pushed to the client so the managed state's gating reads it now. const client = clientInstances[0] as unknown as { setServerSettings: ReturnType; }; expect(client.setServerSettings).toHaveBeenCalledWith( expect.objectContaining({ paginatedLists: true }), ); // Toggling back off persists false. await user.click(screen.getByText("paginated-off")); await waitFor(() => expect(updateServerSettingsSpy).toHaveBeenCalledWith( "A", expect.objectContaining({ paginatedLists: false }), ), ); }); it("routes Refresh and Load-next-page in paginated mode and clears the indicator", async () => { const user = userEvent.setup(); clearToolsListChangedSpy.mockClear(); renderWithMantine(); await user.click(screen.getByText("connect")); await waitFor(() => expect(clientInstances).toHaveLength(1)); await user.click(screen.getByText("paginated-on")); await waitFor(() => expect(screen.getByTestId("tools-paginated")).toHaveTextContent("true"), ); // Exercise the mode-aware Refresh (paginated → reload page 1) and the // Load-next-page control; both should run without error. await user.click(screen.getByText("refresh-tools")); await user.click(screen.getByText("load-more-tools")); expect(screen.getByTestId("tools-paginated")).toHaveTextContent("true"); // The paginated Refresh must acknowledge the managed list-changed // indicator (the paged reload bypasses the managed hook's refresh) (#1721). expect(clearToolsListChangedSpy).toHaveBeenCalled(); }); it("reverts the optimistic toggle when persisting the setting fails (#1721)", async () => { const user = userEvent.setup(); updateServerSettingsSpy.mockRejectedValueOnce(new Error("disk full")); renderWithMantine(); await user.click(screen.getByText("connect")); await waitFor(() => expect(clientInstances).toHaveLength(1)); await user.click(screen.getByText("paginated-on")); // The optimistic flip is rolled back once the persist rejects, so the UI // reflects the (unchanged) persisted value rather than the failed edit. await waitFor(() => expect(screen.getByTestId("tools-paginated")).toHaveTextContent("false"), ); // The live client setting was rolled back too (last call reverts it). const client = clientInstances[0] as unknown as { setServerSettings: ReturnType; }; const lastPush = client.setServerSettings.mock.calls.at(-1)?.[0] as { paginatedLists?: boolean; }; expect(lastPush?.paginatedLists).toBeFalsy(); }); }); // A background command runs through `runCommandInBackground`, which owns the // rejection. Before #2049 the 17 `void runWithCommandAuthRecovery(...)` sites // had no handler, so any non-auth failure of the wrapped operation became an // unhandled rejection in the browser — invisible to the type-aware // no-floating-promises rule (`void` is its sanctioned suppression) and fatal to // `smoke:web:browser`, which hard-fails on exactly that signal. describe("App background command rejections (#2049)", () => { const LIST_FAILURE = new Error("list boom"); /** * Record process-level unhandled rejections for the duration of a test. * Asserting on the captured list — rather than relying on vitest failing the * whole run — is what attributes a regression to this test rather than to * whichever file happened to be running when the rejection surfaced. */ const captureUnhandledRejections = () => { const seen: unknown[] = []; const onUnhandled = (reason: unknown) => { seen.push(reason); }; process.on("unhandledRejection", onUnhandled); return { seen, stop: () => { process.off("unhandledRejection", onUnhandled); }, }; }; // Node reports an unhandled rejection at the end of the turn, so drain the // macrotask queue before asserting that none was reported. const settleRejections = async () => { await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); }; beforeEach(() => { clientInstances.length = 0; notificationsMock.show.mockClear(); }); afterEach(() => { // Restore the module defaults the other blocks render against. vi.mocked(useManagedTools).mockReturnValue({ tools: [{ name: "get_acts", inputSchema: { type: "object" } }], error: null, listChanged: false, refresh: vi.fn().mockResolvedValue([]), clearListChanged: clearToolsListChangedSpy, }); vi.mocked(usePagedTools).mockReturnValue({ tools: [], nextCursor: undefined, pageCount: 0, error: null, loadPage: vi.fn(() => Promise.resolve({ tools: [], nextCursor: undefined }), ), clear: vi.fn(), }); }); const connect = async () => { const user = userEvent.setup(); renderWithMantine(); await user.click(screen.getByText("connect")); await waitFor(() => expect(clientInstances).toHaveLength(1)); return user; }; it("does not leak an unhandled rejection when an all-pages Refresh fails", async () => { vi.mocked(useManagedTools).mockReturnValue({ tools: [], error: LIST_FAILURE, listChanged: false, refresh: vi.fn().mockRejectedValue(LIST_FAILURE), clearListChanged: clearToolsListChangedSpy, }); const rejections = captureUnhandledRejections(); try { const user = await connect(); await user.click(screen.getByText("refresh-tools")); await settleRejections(); expect(rejections.seen).toEqual([]); } finally { rejections.stop(); } // The panel already renders the failure with a Retry, so no toast is added // on top of it — that is the whole reason this site swallows the rejection. expect(notificationsMock.show).not.toHaveBeenCalled(); }); it("does not leak an unhandled rejection when a paginated Refresh or Load-next-page fails", async () => { vi.mocked(usePagedTools).mockReturnValue({ tools: [], // A cursor is what makes Load-next-page actually fetch. nextCursor: "cursor-1", pageCount: 1, error: LIST_FAILURE, loadPage: vi.fn().mockRejectedValue(LIST_FAILURE), clear: vi.fn(), }); const rejections = captureUnhandledRejections(); try { const user = await connect(); // Flipping the mode drives its own page-1 load, which fails too. await user.click(screen.getByText("paginated-on")); await waitFor(() => expect(screen.getByTestId("tools-paginated")).toHaveTextContent("true"), ); await user.click(screen.getByText("refresh-tools")); await user.click(screen.getByText("load-more-tools")); await settleRejections(); expect(rejections.seen).toEqual([]); } finally { rejections.stop(); } }); it("toasts a set-log-level failure instead of leaking it", async () => { const rejections = captureUnhandledRejections(); try { const user = await connect(); // Single assertion: the fake client IS an EventTarget, so narrowing it // to that intersection needs no `as unknown as` detour. const client = clientInstances[0] as EventTarget & { setLoggingLevel: ReturnType; }; client.setLoggingLevel.mockRejectedValueOnce(new Error("no logging")); await user.click(screen.getByText("set-level")); await waitFor(() => expect(notificationsMock.show).toHaveBeenCalledWith( expect.objectContaining({ title: "Failed to set log level", message: "no logging", color: "red", }), ), ); await settleRejections(); expect(rejections.seen).toEqual([]); } finally { rejections.stop(); } }); });