;
}) => (
{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" }),
),
);
});
});