import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { ErrorCode, McpError } from "@modelcontextprotocol/sdk/types.js"; 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", () => { class FakeInspectorClient extends EventTarget { connect = vi.fn().mockResolvedValue(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); 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); getOAuthState = vi.fn().mockReturnValue(undefined); getPendingSamples = vi.fn().mockReturnValue([]); getPendingElicitations = vi.fn().mockReturnValue([]); getRoots = vi.fn().mockReturnValue([]); setRoots = vi.fn().mockResolvedValue(undefined); setServerSettings = vi.fn(); } const instances: FakeInspectorClient[] = []; return { 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, }; }); // 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" }, }; vi.mock("@inspector/core/react/useServers.js", () => ({ useServers: vi.fn(() => ({ servers: [SERVER_A], addServer: vi.fn(), updateServer: vi.fn(), updateServerSettings: vi.fn(), removeServer: vi.fn(), })), })); vi.mock("@inspector/core/react/useInspectorClient.js", () => ({ useInspectorClient: vi.fn(() => ({ status: "connected", capabilities: {}, clientCapabilities: {}, // Left undefined so `initializeResult` stays undefined and the // ConnectionInfoModal (gated on it) never mounts during the test. serverInfo: undefined, instructions: undefined, })), })); vi.mock("@inspector/core/react/useManagedTools.js", () => ({ useManagedTools: vi.fn(() => ({ tools: [{ name: "get_acts", inputSchema: { type: "object" } }], refresh: vi.fn(), })), })); vi.mock("@inspector/core/react/useManagedPrompts.js", () => ({ useManagedPrompts: vi.fn(() => ({ prompts: [], refresh: vi.fn() })), })); vi.mock("@inspector/core/react/useManagedResources.js", () => ({ useManagedResources: vi.fn(() => ({ resources: [], refresh: vi.fn() })), })); vi.mock("@inspector/core/react/useManagedResourceTemplates.js", () => ({ useManagedResourceTemplates: vi.fn(() => ({ resourceTemplates: [], refresh: 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/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?: { selectedToolName?: 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; onToggleConnection: (id: string) => void; onToolsUiChange: (next: { selectedToolName?: 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; onClearHistory: () => void; onReplayHistory: (id: string) => void; onTogglePinHistory: (id: string) => void; pinnedHistoryIds?: Set; }) => (
{props.toolCallState?.status ?? "none"} {Object.keys(props.progressByTaskId ?? {}).join(",") || "none"} {props.toolsUi?.selectedToolName ?? "none"} {props.toolsUi?.search || "none"} {props.promptsUi?.selectedPromptName ?? "none"} {props.logsUi?.filterText || "none"} {props.getPromptState?.status ?? "none"} {props.readResourceState?.status ?? "none"} {props.currentLogLevel} {Array.from(props.pinnedHistoryIds ?? []).join(",")}
), })); import App from "./App"; 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 { useMessageLog } from "@inspector/core/react/useMessageLog.js"; import { useInspectorClient } from "@inspector/core/react/useInspectorClient.js"; import { useSettingsDraft } from "@inspector/core/react/useSettingsDraft.js"; import type { InspectorServerSettings, MessageEntry, } 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, appRendererClient: null, connect: vi.fn().mockResolvedValue(undefined), disconnect: vi.fn().mockResolvedValue(undefined), }; const clientInstances = ( McpIndex as unknown as { __clientInstances: EventTarget[] } ).__clientInstances; const fetchLogInstances = ( FetchLogModule as unknown as { __fetchLogInstances: EventTarget[] } ).__fetchLogInstances; 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 `selectedToolName` 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("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 McpError( ErrorCode.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" }), ), ); }); });