inspector/clients/web/src/App.test.tsx
cliffhall 0d81cf7e84 test: cover the App-level shared-key clear suppression (#2217)
Copilot: `oauthClearKey`'s unit tests prove the derivation, but a
regression that dropped the `has` check, added or deleted the wrong key,
or invoked the clear twice would still have passed.

Adds an App.test.tsx case that connects entry A, holds its clear open on
a deferred `clearOAuthTokens`, then drives a clear for entry B — a
different catalog id against the same URL — and asserts the underlying
clear ran exactly once, and once more after the first settles, so the
key is proven released rather than leaking a permanently dead control.
Verified to fail against the id-keyed guard.

Two supporting changes it needs:

- an `open-settings-b` control on the mocked InspectorView, the only way
  to reach a settings modal for a server other than the active one;
- `@inspector/core/mcp/remote/index.js` becomes a partial mock.
  `getWebProxiedFetch` reaches for `createRemoteFetch` there, and the
  bare stub threw a missing-export error before the clear under test ever
  ran — the failure surfaced as the generic "Could not clear the stored
  OAuth state" toast.

The case carries an explicit 20s timeout and `delay: null`: it drives
three full modal interaction sequences against the whole App tree, which
runs past the 5s default when the suite is under load.

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

4343 lines
160 KiB
TypeScript

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";
import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js";
import { RemoteOAuthStorage } from "@inspector/core/auth/remote/storage-remote.js";
// Spy on the toast layer so the progress-notification tests can assert the
// show/update calls without mounting Mantine's <Notifications/> 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 the real TypedEventTarget 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).
//
// The real base rather than a bare EventTarget because the hooks this fake is
// handed to read state through `useStoreSnapshot`, which caches its snapshot
// against `getEventRevision` — a method the base owns and a bare EventTarget
// does not have (#1955). Inheriting it also means the counter is advanced by
// the `dispatchEvent` calls below, exactly as it would be in production.
vi.mock("@inspector/core/mcp/index.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@inspector/core/mcp/index.js")>();
const { TypedEventTarget } =
await import("@inspector/core/mcp/typedEventTarget.js");
// Each armed value makes one `connect()` reject, in FIFO order, so a test can
// exercise the handshake-failure path — or a two-connect sequence such as the
// auth-recovery retry, where the first call rejects with the recovery error
// and the retry behind it fails too. A queue rather than a single slot: with
// one slot the second arming would overwrite the first, and the retry would
// silently succeed.
const connectRejections: unknown[] = [];
// 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;
// And for `authenticate()`, the pre-redirect OAuth leg (discovery + DCR), so a
// test can exercise a failure there — the case that never reaches the "error"
// connection status (#2108).
let nextAuthenticateRejection: unknown = null;
// And for the auth-recovery challenge check, whose *rejection* is a distinct
// control-flow decision from its resolving `false` (#2108): a throw is
// surfaced as a failed attempt rather than falling through to the redirect.
let nextChallengeCheckRejection: unknown = null;
class FakeInspectorClient extends TypedEventTarget<
import("@inspector/core/mcp/index.js").InspectorClientEventMap
> {
connect = vi.fn(() => {
if (connectRejections.length > 0) {
return Promise.reject(connectRejections.shift());
}
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);
});
authenticate = vi.fn(() => {
if (nextAuthenticateRejection !== null) {
const err = nextAuthenticateRejection;
nextAuthenticateRejection = null;
return Promise.reject(err);
}
return Promise.resolve(undefined);
});
checkAuthChallengeSatisfied = vi.fn(() => {
if (nextChallengeCheckRejection !== null) {
const err = nextChallengeCheckRejection;
nextChallengeCheckRejection = null;
return Promise.reject(err);
}
return Promise.resolve(true);
});
// #2144: the clear path reads the returned RFC 7009 outcome.
clearOAuthTokens = vi
.fn()
.mockResolvedValue({ status: "skipped", reason: "no_endpoint" });
}
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).
// Call it more than once to arm consecutive calls.
__rejectNextConnect: (err: unknown) => {
connectRejections.push(err);
},
// Test-only: arm the next resumeAfterOAuth() to reject (callback-leg failure).
__rejectNextResumeAfterOAuth: (err: unknown) => {
nextResumeRejection = err;
},
// Test-only: arm the next authenticate() to reject (pre-redirect OAuth leg).
__rejectNextAuthenticate: (err: unknown) => {
nextAuthenticateRejection = err;
},
// Test-only: arm the next checkAuthChallengeSatisfied() to reject.
__rejectNextChallengeCheck: (err: unknown) => {
nextChallengeCheckRejection = 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", async (importOriginal) => ({
// Partial: `getWebProxiedFetch` (the OAuth clear's revocation fetch, #2144)
// reaches for `createRemoteFetch` from this module, and a bare stub would
// throw a missing-export error before the clear under test ever ran.
...(await importOriginal<
typeof import("@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()),
}));
// Same idea for the edit-modal rename path (#1914): the test needs to make the
// PUT's *reload* fail, which only a spy it controls can do.
const { updateServerSpy, addServerSpy } = vi.hoisted(() => ({
updateServerSpy: vi.fn(() => Promise.resolve()),
addServerSpy: 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", async (importOriginal) => ({
// `ServerListReloadError` is a real class the pagination toggle branches on
// with `instanceof`, so it must be the genuine one — a stub would make the
// check vacuously false and the #1914 branch untestable (#1914 review r1).
...(await importOriginal<
typeof import("@inspector/core/react/useServers.js")
>()),
useServers: vi.fn(() => ({
servers: [SERVER_A],
addServer: addServerSpy,
updateServer: updateServerSpy,
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(),
})),
}));
// --- App bridge factory spy (#2055) -----------------------------------------
// Passes through to the real factory but records the deps App hands it, so a
// test can drive `getListedResourceMeta` — the wiring that carries a
// `resources/list` entry's `_meta.ui` into the sandbox CSP. Without this the
// bridge-factory unit tests would still pass while App stopped supplying it.
const appBridgeFactoryDeps: AppBridgeFactoryDeps[] = [];
vi.mock(
"./components/elements/AppRenderer/createAppBridgeFactory",
async (importOriginal) => {
const actual =
await importOriginal<
typeof import("./components/elements/AppRenderer/createAppBridgeFactory")
>();
return {
...actual,
createAppBridgeFactory: (deps: AppBridgeFactoryDeps) => {
appBridgeFactoryDeps.push(deps);
return actual.createAppBridgeFactory(deps);
},
};
},
);
// --- publishAppDocument spy (#2056) -----------------------------------------
// The dedicated-origin publisher App hands both bridge factories. Mocked at the
// module so a test can assert the wiring is live — the factory's own tests
// inject the dep and cannot see App failing to supply it.
const publishAppDocumentMock =
vi.fn<
(
doc: { html: string; csp?: string },
opts: { baseUrl: string; authToken?: string },
) => Promise<string | null>
>();
vi.mock("./lib/publishAppDocument", () => ({
publishAppDocument: (
doc: { html: string; csp?: string },
opts: { baseUrl: string; authToken?: string },
) => publishAppDocumentMock(doc, opts),
}));
// --- 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: InspectorViewProps) => (
<div>
<span data-testid="tool-status">
{props.tools.toolCallState?.status ?? "none"}
</span>
<span data-testid="task-progress-keys">
{Object.keys(props.tasks.progressByTaskId ?? {}).join(",") || "none"}
</span>
<span data-testid="selected-tool">
{props.tools.toolsUi?.selectedToolKey ?? "none"}
</span>
<span data-testid="tool-search">
{props.tools.toolsUi?.search || "none"}
</span>
<span data-testid="selected-prompt">
{props.prompts.promptsUi?.selectedPromptName ?? "none"}
</span>
<span data-testid="log-filter">
{props.logs.logsUi?.filterText || "none"}
</span>
<span data-testid="prompt-status">
{props.prompts.getPromptState?.status ?? "none"}
</span>
<span data-testid="resource-status">
{props.resources.readResourceState?.status ?? "none"}
</span>
<span data-testid="log-level">{props.logs.currentLogLevel}</span>
<span data-testid="active-tab">{props.shell.activeTab ?? "none"}</span>
<span data-testid="init-result">
{props.connection.initializeResult
? `name:${props.connection.initializeResult.serverInfo.name || "(empty)"}`
: "none"}
</span>
<span data-testid="errored-server">
{props.connection.erroredServerId ?? "none"}
</span>
<span data-testid="active-server">
{props.connection.activeServer ?? "none"}
</span>
<button onClick={() => props.shell.onActiveTabChange("Servers")}>
switch-servers-tab
</button>
<button onClick={() => props.connection.onToggleConnection("A")}>
connect
</button>
{/* A second target, so a test can drive an A -> B -> A switch (#2095). */}
<button onClick={() => props.connection.onToggleConnection("B")}>
connect-b
</button>
{/* The header's explicit Disconnect, which routes to the standalone
`onDisconnect` rather than through the toggle. */}
<button onClick={() => props.connection.onDisconnect()}>
disconnect
</button>
<button onClick={() => props.servers.onConnectionInfo("A")}>
open-connection-info
</button>
<button
onClick={() =>
props.tools.onToolsUiChange({
...props.tools.toolsUi,
selectedToolKey: "0:get_acts",
})
}
>
select-tool
</button>
<button
onClick={() =>
props.tools.onToolsUiChange({
...props.tools.toolsUi,
selectedToolKey: "1:other_tool",
})
}
>
select-other-tool
</button>
<button
onClick={() =>
props.tools.onToolsUiChange({
...props.tools.toolsUi,
search: "act",
})
}
>
set-tool-search
</button>
<button
onClick={() =>
props.prompts.onPromptsUiChange({
...props.prompts.promptsUi,
selectedPromptName: "greet",
})
}
>
select-prompt
</button>
<button
onClick={() =>
props.logs.onLogsUiChange({
...props.logs.logsUi,
filterText: "err",
})
}
>
set-log-filter
</button>
<button onClick={() => props.tools.onCallTool("get_acts", {})}>
call
</button>
<button onClick={() => props.tools.onCallTool("get_acts", {}, true)}>
call-as-task
</button>
<button onClick={() => props.tasks.onCancelTask("task-1")}>
cancel-task
</button>
<button onClick={() => props.tools.onCancelToolCall?.()}>
cancel-tool-call
</button>
<button onClick={() => props.tasks.onClearCompletedTasks()}>
clear-completed
</button>
<button onClick={() => props.tasks.onRefreshTasks()}>
refresh-tasks
</button>
<button onClick={() => props.prompts.onGetPrompt("greet", {})}>
get-prompt
</button>
<button onClick={() => props.resources.onReadResource("res://x")}>
read-resource
</button>
<button onClick={() => props.logs.onSetLogLevel("debug")}>
set-level
</button>
<button onClick={() => props.servers.onServerSettings("A")}>
open-settings
</button>
{/* A second settings target, so a test can drive a clear against an
entry other than the active one (#2217). */}
<button onClick={() => props.servers.onServerSettings("B")}>
open-settings-b
</button>
{/* The real server grid (and its Add / Edit controls) lives inside this
mocked view, so the config modal is only reachable through these
callbacks — and the highlight batch only observable through this prop. */}
<button onClick={() => props.servers.onServerEdit("A")}>
edit-server
</button>
<button onClick={() => props.servers.onServerAdd()}>add-server</button>
{/* The real drag-and-drop reorder lives inside this mocked view, so the
callback is only reachable through a control like this one. */}
<button onClick={() => props.servers.onServerReorder(["B", "A"])}>
reorder-servers
</button>
<span data-testid="highlighted-servers">
{(props.servers.highlightedServerIds ?? []).join(",") || "none"}
</span>
<span data-testid="pinned-history">
{Array.from(props.protocol.pinnedProtocolIds ?? []).join(",")}
</span>
<button onClick={() => props.protocol.onTogglePinProtocol("hist-1")}>
toggle-pin
</button>
<button onClick={() => props.protocol.onReplayProtocol("hist-1")}>
replay-history
</button>
<button onClick={() => props.protocol.onClearProtocol()}>
clear-history
</button>
<span data-testid="tools-paginated">
{String(props.tools.toolsPagination.paginated)}
</span>
<span data-testid="tools-loaded-pages">
{props.tools.toolsPagination.loadedPages}
</span>
<button
onClick={() => props.tools.toolsPagination.onPaginatedChange(true)}
>
paginated-on
</button>
<button
onClick={() => props.tools.toolsPagination.onPaginatedChange(false)}
>
paginated-off
</button>
<button onClick={() => props.tools.toolsPagination.onLoadMore()}>
load-more-tools
</button>
<button onClick={() => props.tools.onRefreshTools()}>
refresh-tools
</button>
</div>
),
}));
import App from "./App";
import type { InspectorViewProps } from "./components/views/InspectorView/InspectorView";
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 {
ServerListReloadError,
useServers,
} from "@inspector/core/react/useServers.js";
import type {
InspectorClientOptions,
InspectorServerSettings,
MessageEntry,
ServerEntry,
} from "@inspector/core/mcp/types.js";
import type { AppBridgeFactoryDeps } from "./components/elements/AppRenderer/createAppBridgeFactory";
import { useManagedResources } from "@inspector/core/react/useManagedResources.js";
import type { UseManagedResourcesResult } from "@inspector/core/react/useManagedResources.js";
import type { Resource } from "@modelcontextprotocol/client";
// Default useInspectorClient return — capabilities empty (no task tool calls).
// Individual tests override via vi.mocked(...).mockReturnValue(...).
const DEFAULT_USE_INSPECTOR_CLIENT: ReturnType<typeof useInspectorClient> = {
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;
/**
* Mirror how the real client enqueues a server-initiated request: put it on the
* queue, then announce the change.
*
* Both halves matter now that `usePendingClientRequests` reads the queue back
* off the client (`useStoreSnapshot`, #1955) rather than taking the event's
* `detail`. The store is the source of truth and the event is only the signal
* that it moved, so dispatching alone would announce a change that isn't there
* — and the modal under test would never open.
*/
function enqueuePendingRequests(
client: EventTarget,
queue: "getPendingSamples" | "getPendingElicitations",
event: "pendingSamplesChange" | "pendingElicitationsChange",
entries: readonly unknown[],
): void {
const withQueue = client as EventTarget &
Record<typeof queue, ReturnType<typeof vi.fn>>;
withQueue[queue].mockReturnValue(entries);
client.dispatchEvent(new CustomEvent(event, { detail: entries }));
}
const enqueuePendingSamples = (
client: EventTarget,
samples: readonly unknown[],
): void =>
enqueuePendingRequests(
client,
"getPendingSamples",
"pendingSamplesChange",
samples,
);
const enqueuePendingElicitations = (
client: EventTarget,
elicitations: readonly unknown[],
): void =>
enqueuePendingRequests(
client,
"getPendingElicitations",
"pendingElicitationsChange",
elicitations,
);
// The mock factory adds four test-only arming hooks to the module namespace.
// Intersecting with `typeof McpIndex` keeps the real module's shape checked and
// narrows this to a single cast — `as unknown as` would discard the former and
// is what AGENTS.md rules out.
type ArmingHooks = typeof McpIndex & {
__rejectNextConnect: (err: unknown) => void;
__rejectNextResumeAfterOAuth: (err: unknown) => void;
__rejectNextAuthenticate: (err: unknown) => void;
__rejectNextChallengeCheck: (err: unknown) => void;
};
const { __rejectNextConnect: rejectNextConnect } = McpIndex as ArmingHooks;
const { __rejectNextResumeAfterOAuth: rejectNextResumeAfterOAuth } =
McpIndex as ArmingHooks;
const { __rejectNextAuthenticate: rejectNextAuthenticate } =
McpIndex as ArmingHooks;
const { __rejectNextChallengeCheck: rejectNextChallengeCheck } =
McpIndex as ArmingHooks;
const fetchLogInstances = (
FetchLogModule as unknown as { __fetchLogInstances: EventTarget[] }
).__fetchLogInstances;
// #2068 — the web connection seam for the refresh-token opt-out. The provider
// and manager tests start from an already-built OAuth config and the runner test
// covers only the CLI/TUI leg, so without this case deleting the
// `requestRefreshToken` spread from App's `oauthFromServer` would leave the
// checkbox persisted but inert with every other new test still green.
describe("App wires the refresh-token opt-out into the client (#2068)", () => {
// Fully-typed fixtures rather than a cast: `settings` is the whole
// `InspectorServerSettings`, so a field added to that interface later is a
// compile error here instead of a silently-absent value at runtime.
const HTTP_SERVER: ServerEntry = {
id: "A",
name: "PlotRocket",
config: { type: "streamable-http", url: "https://api.example.com/mcp" },
connection: { status: "disconnected" },
};
const BASE_SETTINGS: InspectorServerSettings = {
headers: [],
env: [],
metadata: {},
connectionTimeout: 0,
requestTimeout: 0,
taskTtl: 60000,
maxFetchRequests: 1000,
roots: [],
};
function mockServersWith(overrides?: Partial<InspectorServerSettings>) {
vi.mocked(useServers).mockReturnValue({
servers: [
{
...HTTP_SERVER,
...(overrides
? { settings: { ...BASE_SETTINGS, ...overrides } }
: {}),
},
],
loading: false,
error: undefined,
refresh: vi.fn().mockResolvedValue(undefined),
addServer: addServerSpy,
updateServer: updateServerSpy,
updateServerSettings: updateServerSettingsSpy,
removeServer: vi.fn(),
reorderServers: vi.fn(),
importSource: vi.fn().mockResolvedValue({ servers: {} }),
});
}
/**
* The `oauth` option the mocked InspectorClient constructor was given. Read
* through the constructor's own parameter type, so a rename of the option
* fails to compile here rather than silently reading `undefined`.
*/
function constructedOAuth(): InspectorClientOptions["oauth"] {
return vi.mocked(McpIndex.InspectorClient).mock.calls[0]?.[1]?.oauth;
}
let previousUseServers: typeof useServers | undefined;
beforeEach(() => {
vi.clearAllMocks();
clientInstances.length = 0;
previousUseServers = vi.mocked(useServers).getMockImplementation();
vi.mocked(useInspectorClient).mockReturnValue(DEFAULT_USE_INSPECTOR_CLIENT);
});
afterEach(() => {
if (previousUseServers) {
vi.mocked(useServers).mockImplementation(previousUseServers);
}
});
it("passes requestRefreshToken: false when the server opted out", async () => {
const user = userEvent.setup();
mockServersWith({ oauthRequestRefreshToken: false });
renderWithMantine(<App />);
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));
expect(constructedOAuth()?.requestRefreshToken).toBe(false);
});
// The default must not send the key at all — the provider's own default is
// what declares the grant, and a stray `true` here would mask its removal.
it("omits requestRefreshToken when the setting is on", async () => {
const user = userEvent.setup();
mockServersWith({ oauthScopes: "openid" });
renderWithMantine(<App />);
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));
const oauth = constructedOAuth();
expect(oauth).toBeDefined();
expect(oauth).not.toHaveProperty("requestRefreshToken");
});
// The opt-out is the only OAuth field set, so it must be enough on its own to
// materialize the `oauth` option — otherwise it is dropped for any server
// without credentials or scopes, which is the common case here.
it("builds the oauth option from the opt-out alone", async () => {
const user = userEvent.setup();
mockServersWith({ oauthRequestRefreshToken: false });
renderWithMantine(<App />);
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));
expect(constructedOAuth()).toEqual({ requestRefreshToken: false });
});
});
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(<App />);
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"),
);
});
// An OAuth leg that fails never reaches the `"error"` connection status — the
// handler tears the client down, leaving the session `"disconnected"` — so the
// flag is the only signal downstream that a connect attempt died. Without it
// the monitoring sidebar stays shut on exactly the failure the Network tab
// exists to explain (#2108).
it("flags the server when the OAuth authorization leg fails (#2108)", async () => {
const user = userEvent.setup();
renderWithMantine(<App />);
expect(screen.getByTestId("errored-server")).toHaveTextContent("none");
// 401 from the server -> App starts the authorization-code flow, whose
// discovery/DCR round-trip then fails (e.g. an invalid
// `/.well-known/oauth-authorization-server`).
rejectNextConnect(Object.assign(new Error("HTTP 401"), { status: 401 }));
rejectNextAuthenticate(new Error("discovery failed"));
await user.click(screen.getByText("connect"));
await waitFor(() =>
expect(screen.getByTestId("errored-server")).toHaveTextContent("A"),
);
expect(notificationsMock.show).toHaveBeenCalledWith(
expect.objectContaining({
title: expect.stringContaining("OAuth authorization failed"),
}),
);
});
// The auth-recovery arm re-connects from inside the outer `catch`, so before
// #2108 a rejection there escaped `onToggleConnection` entirely as an
// unhandled rejection: no toast, no red border, no sidebar.
it("flags the server when the satisfied-challenge retry fails (#2108)", async () => {
const user = userEvent.setup();
renderWithMantine(<App />);
// First connect asks for recovery; the fake reports the challenge already
// satisfied, so App retries the connect — and that retry fails.
rejectNextConnect(
new AuthRecoveryRequiredError(
new URL("https://as.example.com/authorize"),
{
reason: "unauthorized",
},
),
);
rejectNextConnect(new Error("handshake failed after re-authorization"));
await user.click(screen.getByText("connect"));
await waitFor(() =>
expect(screen.getByTestId("errored-server")).toHaveTextContent("A"),
);
expect(notificationsMock.show).toHaveBeenCalledWith(
expect.objectContaining({
title: expect.stringContaining("Failed to connect"),
}),
);
});
// The other half of the guarded arm. A *rejecting* challenge check is not the
// same as one that resolves `false`: the latter means "re-authorize", the
// former means the check itself broke, so it is surfaced rather than used as
// grounds to navigate the whole page to the auth server.
it("surfaces a rejecting challenge check instead of redirecting (#2108)", async () => {
const user = userEvent.setup();
renderWithMantine(<App />);
rejectNextConnect(
new AuthRecoveryRequiredError(
new URL("https://as.example.com/authorize"),
{ reason: "unauthorized" },
),
);
rejectNextChallengeCheck(new Error("challenge check failed"));
await user.click(screen.getByText("connect"));
await waitFor(() =>
expect(screen.getByTestId("errored-server")).toHaveTextContent("A"),
);
expect(notificationsMock.show).toHaveBeenCalledWith(
expect.objectContaining({
title: expect.stringContaining("Failed to connect"),
message: "challenge check failed",
}),
);
// No redirect was prepared: `prepareOAuthRedirect` persists a resume
// snapshot before navigating, and nothing wrote one.
expect(readOAuthResumeSnapshot()).toBeUndefined();
// And the attempt was ended. The outer connect rejected with an
// auth-recovery error, which holds the status at "connecting", so without
// an explicit teardown here the toggle would spin forever.
const client = clientInstances[0] as EventTarget & {
disconnect: ReturnType<typeof vi.fn>;
};
expect(client.disconnect).toHaveBeenCalled();
});
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(<App />);
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(<App />);
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(<App />);
// 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(<App />);
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(<App />);
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(<App />);
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(<App />);
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(<App />);
// 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(<App />);
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(<App />);
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(<App />);
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(<App />);
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(<App />);
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(<App />);
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(<App />);
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(<App />);
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(<App />);
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(<App />);
// 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(<App />);
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(() => {
enqueuePendingSamples(clientInstances[0], [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(() => {
enqueuePendingSamples(clientInstances[0], []);
});
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(<App />);
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<typeof vi.fn>;
callTool: ReturnType<typeof vi.fn>;
};
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(<App />);
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<typeof vi.fn>;
callTool: ReturnType<typeof vi.fn>;
};
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(<App />);
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));
(
clientInstances[0] as unknown as {
cancelRequestorTask: ReturnType<typeof vi.fn>;
}
).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(<App />);
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));
const client = clientInstances[0] as unknown as {
cancelRequestorTask: ReturnType<typeof vi.fn>;
};
// 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(<App />);
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));
const client = clientInstances[0] as unknown as {
cancelRequestorTask: ReturnType<typeof vi.fn>;
};
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(() => {
enqueuePendingElicitations(clientInstances[0], [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(<App />);
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));
const client = clientInstances[0] as unknown as {
cancelRequestorTask: ReturnType<typeof vi.fn>;
};
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(<App />);
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));
const client = clientInstances[0] as unknown as {
cancelRequestorTask: ReturnType<typeof vi.fn>;
cancelToolCall: ReturnType<typeof vi.fn>;
};
// 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(<App />);
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<typeof vi.fn>;
}
).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(<App />);
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));
(
clientInstances[0] as unknown as {
callTool: ReturnType<typeof vi.fn>;
}
).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(<App />);
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));
(
clientInstances[0] as unknown as {
callTool: ReturnType<typeof vi.fn>;
}
).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(<App />);
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(<App />);
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(<App />);
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(<App />);
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(<App />);
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(<App />);
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<typeof vi.fn>;
getRoots: ReturnType<typeof vi.fn>;
// Close also pushes the settings it decided to apply, which is what the
// failed-save case asserts on (#2089).
setServerSettings: ReturnType<typeof vi.fn>;
};
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<RootsFakeClient> {
vi.mocked(useSettingsDraft).mockReturnValue({
draft,
onChange: vi.fn(),
flush: vi.fn(),
});
const user = userEvent.setup();
renderWithMantine(<App />);
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<typeof userEvent.setup>) {
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();
});
it("applies the last persisted settings on close after a save failed (#2089)", async () => {
// `useSettingsDraft` keeps the draft when a save rejects, so closing the
// modal would push a value that never reached disk into the live client —
// undoing the rollback that reported the failure. Close has to apply what
// landed instead.
const user = userEvent.setup();
updateServerSettingsSpy.mockClear();
const failedDraft: InspectorServerSettings = {
...settingsWithRoots([{ uri: "file:///rejected" }]),
paginatedLists: true,
};
const client = await openSettingsForConnectedServer(failedDraft);
client.getRoots.mockReturnValue([]);
const draftOptions = vi.mocked(useSettingsDraft).mock.calls.at(-1)?.[0];
if (!draftOptions) throw new Error("useSettingsDraft was never called");
// One save lands, so the tracker has an account of disk…
const persistedSettings: InspectorServerSettings = {
...settingsWithRoots([]),
paginatedLists: false,
};
await act(async () => {
await draftOptions.onPersist("A", persistedSettings);
});
// …then the next one rejects, leaving the draft holding an unsaved edit.
updateServerSettingsSpy.mockRejectedValueOnce(new Error("disk full"));
await act(async () => {
await draftOptions.onPersist("A", failedDraft).catch(() => undefined);
});
client.setServerSettings.mockClear();
await closeModal(user);
await waitFor(() => expect(client.setServerSettings).toHaveBeenCalled());
expect(client.setServerSettings).toHaveBeenLastCalledWith(
persistedSettings,
);
// …and the rejected roots edit is not advertised either.
expect(client.setRoots).not.toHaveBeenCalled();
});
it("restores the roots and log size too when a flushed save rejects (#2089)", async () => {
// Close flushes a pending save and live-applies the draft immediately —
// roots and the Network log size, not just the settings object. If that
// save then rejects, reconciling only `setServerSettings` would leave the
// server advertising roots that were never persisted.
const user = userEvent.setup();
updateServerSettingsSpy.mockClear();
const rejectedDraft: InspectorServerSettings = {
...settingsWithRoots([{ uri: "file:///rejected" }]),
maxFetchRequests: 10,
};
const client = await openSettingsForConnectedServer(rejectedDraft);
client.getRoots.mockReturnValue([]);
const draftOptions = vi.mocked(useSettingsDraft).mock.calls.at(-1)?.[0];
if (!draftOptions) throw new Error("useSettingsDraft was never called");
const persistedSettings = settingsWithRoots([]);
await act(async () => {
await draftOptions.onPersist("A", persistedSettings);
});
// Close applies the draft live — the state the flush leaves behind.
const fetchLog = fetchLogInstances.at(-1) as EventTarget & {
setMaxFetchRequests: ReturnType<typeof vi.fn>;
};
await closeModal(user);
await waitFor(() =>
expect(client.setRoots).toHaveBeenCalledWith([
{ uri: "file:///rejected" },
]),
);
expect(fetchLog.setMaxFetchRequests).toHaveBeenLastCalledWith(10);
client.getRoots.mockReturnValue([{ uri: "file:///rejected" }]);
client.setRoots.mockClear();
// …and only now does the flushed save reject.
updateServerSettingsSpy.mockRejectedValueOnce(new Error("disk full"));
await act(async () => {
await draftOptions.onPersist("A", rejectedDraft).catch(() => undefined);
});
// The whole live surface goes back to what landed — settings, roots, and
// the log size, each of which close had moved to the rejected draft.
await waitFor(() => expect(client.setRoots).toHaveBeenCalledWith([]));
expect(client.setServerSettings).toHaveBeenLastCalledWith(
persistedSettings,
);
expect(fetchLog.setMaxFetchRequests).toHaveBeenLastCalledWith(
persistedSettings.maxFetchRequests,
);
});
});
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(<App />);
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(<App />);
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(<App />);
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<typeof vi.fn>;
};
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(<App />);
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<typeof vi.fn>;
};
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(<App />);
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<typeof vi.fn>;
};
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(<App />);
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(<App />);
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<typeof vi.fn>;
};
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(<App />);
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<typeof vi.fn>;
};
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(<App />);
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(<App />);
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(<App />);
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<string, string>();
const writeTestOAuthSnapshot = (
overrides?: Partial<OAuthResumeSnapshot>,
): 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(<App />);
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(<App />);
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(<App />);
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(<App />);
await waitFor(() => expect(clientInstances).toHaveLength(1));
const client = clientInstances[0] as unknown as {
resumeAfterOAuth: ReturnType<typeof vi.fn>;
};
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(<App />);
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<string, string>();
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<typeof useServers> => ({
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(<App />);
};
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();
});
// The callback leg is a connect attempt too, so a failure there must flag the
// server — that is what opens the monitoring sidebar onto the OAuth requests
// (discovery, DCR and the token exchange, restored from the pre-redirect
// session) rather than leaving the user with only a banner (#2108).
it("flags the server when the callback leg fails (#2108)", async () => {
renderCallbackWithFailure(new Error("token exchange rejected"));
await waitFor(() =>
expect(screen.getByTestId("errored-server")).toHaveTextContent("A"),
);
// The active server is deliberately retained. "Authorize again" on the
// re-auth banner only hands `clearServerOAuthState` the live client when
// the banner's server is the active one, so releasing it here would leave
// the stale tokens on the client that holds them. Asserted so a later
// "cleanup" of the apparent leak fails here rather than silently breaking
// the recovery.
expect(screen.getByTestId("active-server")).toHaveTextContent("A");
// The teardown still runs, for the case where the reconnect is what
// rejected and the status really is left at "connecting".
const client = clientInstances[0] as EventTarget & {
disconnect: ReturnType<typeof vi.fn>;
};
await waitFor(() => expect(client.disconnect).toHaveBeenCalled());
});
// The callback leg can also die before the token exchange, if the persisted
// OAuth state cannot be read back at all. Spied on the prototype rather than
// mocked at the module, so every other test keeps the real storage.
it("flags the server when the OAuth storage fails to load (#2108)", async () => {
const loadSpy = vi
.spyOn(RemoteOAuthStorage.prototype, "load")
.mockRejectedValueOnce(new Error("storage unavailable"));
try {
writeOAuthResumeSnapshot({
version: 1,
serverId: "A",
activeTab: "Tools",
authKind: "reauth",
tabUi: {},
});
window.history.replaceState({}, "", `${OAUTH_CALLBACK_PATH}?code=test`);
renderWithMantine(<App />);
await waitFor(() =>
expect(screen.getByTestId("errored-server")).toHaveTextContent("A"),
);
// Red border only, by design: this arm returns before a client is built,
// so nothing restores the Network log — and a failed local read of
// `oauth.json` would not be in it anyway.
expect(
await screen.findByText(/storage unavailable/),
).toBeInTheDocument();
} finally {
loadSpy.mockRestore();
}
});
// The other callback arm: the provider redirected back with an error instead
// of a code, so no token exchange is even attempted. Still a connect attempt
// that failed, so the server carries the same flag (#2108).
//
// The flag is deliberately all this asserts. That arm returns before a client
// is rebuilt, so no Network entries are restored and the content-gated column
// stays shut — which is right here: the provider's `error` param is the whole
// diagnostic and the re-auth banner is already showing it. Asserting a
// sidebar would be asserting behavior this arm should not have.
it("flags the server when the provider returns an error to the callback (#2108)", async () => {
writeOAuthResumeSnapshot({
version: 1,
serverId: "A",
activeTab: "Tools",
authKind: "reauth",
tabUi: {},
});
window.history.replaceState(
{},
"",
`${OAUTH_CALLBACK_PATH}?error=access_denied`,
);
renderWithMantine(<App />);
await waitFor(() =>
expect(screen.getByTestId("errored-server")).toHaveTextContent("A"),
);
});
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<typeof vi.fn>;
};
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(<App />);
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<typeof vi.fn>;
};
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(<App />);
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(<App />);
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<typeof vi.fn>;
};
const lastPush = client.setServerSettings.mock.calls.at(-1)?.[0] as {
paginatedLists?: boolean;
};
expect(lastPush?.paginatedLists).toBeFalsy();
});
it("rolls back to the last write that landed, not to a stale list entry (#2089)", async () => {
// Two toggles. The first PUT lands but the list reload behind it fails, so
// the `servers` entry keeps describing disk as it was *before* it — which
// this suite models exactly, because `useServers` is mocked to return the
// same entry forever. The second PUT fails outright and rolls back.
//
// Reverting to the `servers` entry there lands on `false`, the value from
// before the first toggle, contradicting what is on disk. The baseline has
// to come from the write that landed instead.
//
// One failed toggle cannot distinguish the two baselines — they only differ
// once a write has landed that the list never caught up with — so the test
// needs the full sequence.
const user = userEvent.setup();
renderWithMantine(<App />);
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));
await user.click(screen.getByText("paginated-on"));
await waitFor(() =>
expect(updateServerSettingsSpy).toHaveBeenCalledWith(
"A",
expect.objectContaining({ paginatedLists: true }),
),
);
updateServerSettingsSpy.mockRejectedValueOnce(new Error("disk full"));
await user.click(screen.getByText("paginated-off"));
// Back to the value the first toggle put on disk, not to the pre-toggle one.
await waitFor(() =>
expect(screen.getByTestId("tools-paginated")).toHaveTextContent("true"),
);
const client = clientInstances[0] as EventTarget & {
setServerSettings: ReturnType<typeof vi.fn>;
};
const lastPush = client.setServerSettings.mock.calls.at(-1)?.[0] as {
paginatedLists?: boolean;
};
expect(lastPush?.paginatedLists).toBe(true);
});
it("rolls back to a write that landed while this one was in flight (#2089)", async () => {
// Overlapping toggles. The baseline cannot be captured when a write is
// issued: toggle 1 is still in flight when toggle 2 reads it, so toggle 2
// would close over `false` — and by the time toggle 2 fails, toggle 1 has
// landed `true` on disk. Rolling back to the captured value contradicts
// disk exactly as the stale-list case does; the baseline has to be resolved
// at failure time. The two writes are driven by deferred promises so the
// order — issue 1, issue 2, land 1, fail 2 — is exact rather than timed.
const user = userEvent.setup();
let landFirst: (() => void) | undefined;
let failSecond: ((err: Error) => void) | undefined;
const first = new Promise<void>((resolve) => {
landFirst = resolve;
});
const second = new Promise<void>((_resolve, reject) => {
failSecond = (err) => {
reject(err);
};
});
updateServerSettingsSpy
.mockImplementationOnce(() => first)
.mockImplementationOnce(() => second);
renderWithMantine(<App />);
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));
await user.click(screen.getByText("paginated-on"));
await user.click(screen.getByText("paginated-off"));
await waitFor(() =>
expect(updateServerSettingsSpy).toHaveBeenCalledTimes(2),
);
await act(async () => {
landFirst?.();
await first;
});
await act(async () => {
failSecond?.(new Error("disk full"));
await second.catch(() => undefined);
});
// `true` is what the landed write put on disk — not the `false` the second
// toggle read before it got there.
await waitFor(() =>
expect(screen.getByTestId("tools-paginated")).toHaveTextContent("true"),
);
const client = clientInstances[0] as EventTarget & {
setServerSettings: ReturnType<typeof vi.fn>;
};
const lastPush = client.setServerSettings.mock.calls.at(-1)?.[0] as {
paginatedLists?: boolean;
};
expect(lastPush?.paginatedLists).toBe(true);
});
it("re-applies a write that lands after an overlapping one failed (#2089)", async () => {
// The mirror of the test above, in the other settlement order: toggle 1
// (`true`) is still in flight when toggle 2 (`false`) fails, so the rollback
// resolves to the stale entry's `false` — correct at that instant, since
// nothing had landed. Toggle 1 then lands `true`. If its list reload failed
// too, no render will ever correct the UI, so the write that settles has to
// re-apply itself.
const user = userEvent.setup();
let landFirst: (() => void) | undefined;
let failSecond: ((err: Error) => void) | undefined;
const first = new Promise<void>((resolve) => {
landFirst = resolve;
});
const second = new Promise<void>((_resolve, reject) => {
failSecond = (err) => {
reject(err);
};
});
updateServerSettingsSpy
.mockImplementationOnce(() => first)
.mockImplementationOnce(() => second);
renderWithMantine(<App />);
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));
await user.click(screen.getByText("paginated-on"));
await user.click(screen.getByText("paginated-off"));
await waitFor(() =>
expect(updateServerSettingsSpy).toHaveBeenCalledTimes(2),
);
// Fail the second write first, then land the first.
await act(async () => {
failSecond?.(new Error("disk full"));
await second.catch(() => undefined);
});
expect(screen.getByTestId("tools-paginated")).toHaveTextContent("false");
await act(async () => {
landFirst?.();
await first;
});
await waitFor(() =>
expect(screen.getByTestId("tools-paginated")).toHaveTextContent("true"),
);
const client = clientInstances[0] as EventTarget & {
setServerSettings: ReturnType<typeof vi.fn>;
};
const lastPush = client.setServerSettings.mock.calls.at(-1)?.[0] as {
paginatedLists?: boolean;
};
expect(lastPush?.paginatedLists).toBe(true);
});
it("re-applies a settled modal save after a toggle failed first (#2089)", async () => {
// The mixed-writer version of the order above: the modal's flush is in
// flight with `paginatedLists: true` when the sidebar toggle fails and
// rolls back to the stale entry's `false`. The modal save then lands, so it
// is the settled state — and it has to re-apply itself for the same reason
// the toggle does, or disk holds `true` while the UI and the live client
// sit on `false`.
const user = userEvent.setup();
let landModal: (() => void) | undefined;
const modalWrite = new Promise<void>((resolve) => {
landModal = resolve;
});
renderWithMantine(<App />);
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));
const draftOptions = vi.mocked(useSettingsDraft).mock.calls.at(-1)?.[0];
if (!draftOptions) throw new Error("useSettingsDraft was never called");
updateServerSettingsSpy.mockImplementationOnce(() => modalWrite);
const persisted = draftOptions.onPersist("A", {
...settingsWithRoots([]),
paginatedLists: true,
});
updateServerSettingsSpy.mockRejectedValueOnce(new Error("disk full"));
await user.click(screen.getByText("paginated-off"));
await waitFor(() =>
expect(screen.getByTestId("tools-paginated")).toHaveTextContent("false"),
);
await act(async () => {
landModal?.();
await persisted;
});
await waitFor(() =>
expect(screen.getByTestId("tools-paginated")).toHaveTextContent("true"),
);
const client = clientInstances[0] as EventTarget & {
setServerSettings: ReturnType<typeof vi.fn>;
};
const lastPush = client.setServerSettings.mock.calls.at(-1)?.[0] as {
paginatedLists?: boolean;
};
expect(lastPush?.paginatedLists).toBe(true);
});
it("applies a settled write to the current client, not the one it started on (#2089)", async () => {
// A write can outlive a disconnect/reconnect to the *same* server: the id
// guard still passes, but the client captured when the write was issued has
// been destroyed and replaced by one built from the stale list entry. The
// settled value has to reach the live instance.
const user = userEvent.setup();
let landWrite: (() => void) | undefined;
const write = new Promise<void>((resolve) => {
landWrite = resolve;
});
updateServerSettingsSpy.mockImplementationOnce(() => write);
renderWithMantine(<App />);
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));
await user.click(screen.getByText("paginated-on"));
// Reconnect to the same server while that write is in flight. The transport
// drops, then the user reconnects — and `onToggleConnection` always
// rebuilds the client so the latest saved settings are picked up.
act(() => {
clientInstances[0].dispatchEvent(new Event("disconnect"));
});
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(2));
const replacement = clientInstances[1] as EventTarget & {
setServerSettings: ReturnType<typeof vi.fn>;
};
replacement.setServerSettings.mockClear();
await act(async () => {
landWrite?.();
await write;
});
await waitFor(() =>
expect(replacement.setServerSettings).toHaveBeenCalledWith(
expect.objectContaining({ paginatedLists: true }),
),
);
});
it("reconciles after a modal save fails onto a write that landed first (#2089)", async () => {
// Reverse of the mixed-writer case above: the toggle lands while the modal
// save is still pending, so it is told it is not settled and applies
// nothing. The modal save then fails, which makes the toggle's value the
// account of disk — and nothing else is left to apply it.
const user = userEvent.setup();
let failModal: ((err: Error) => void) | undefined;
const modalWrite = new Promise<void>((_resolve, reject) => {
failModal = (err) => {
reject(err);
};
});
renderWithMantine(<App />);
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));
const draftOptions = vi.mocked(useSettingsDraft).mock.calls.at(-1)?.[0];
if (!draftOptions) throw new Error("useSettingsDraft was never called");
updateServerSettingsSpy.mockImplementationOnce(() => modalWrite);
const persisted = draftOptions
.onPersist("A", { ...settingsWithRoots([]), paginatedLists: false })
.catch(() => undefined);
// The toggle lands `true` while the modal save is still in flight.
await user.click(screen.getByText("paginated-on"));
await waitFor(() =>
expect(updateServerSettingsSpy).toHaveBeenCalledTimes(2),
);
const client = clientInstances[0] as EventTarget & {
setServerSettings: ReturnType<typeof vi.fn>;
};
client.setServerSettings.mockClear();
await act(async () => {
failModal?.(new Error("disk full"));
await persisted;
});
// The failure is what reconciles: a push carrying the landed value arrives
// only after it, since the landed write itself was not settled at the time.
await waitFor(() =>
expect(client.setServerSettings).toHaveBeenCalledWith(
expect.objectContaining({ paginatedLists: true }),
),
);
});
it("seeds the settings modal from the last write, not the stale entry (#2089)", async () => {
// The modal draft is the input to the *next* write, so seeding it from a
// `servers` entry that a failed list reload froze would send the superseded
// value back to disk the moment the user saves any unrelated field.
const user = userEvent.setup();
renderWithMantine(<App />);
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));
await user.click(screen.getByText("paginated-on"));
await waitFor(() =>
expect(updateServerSettingsSpy).toHaveBeenCalledWith(
"A",
expect.objectContaining({ paginatedLists: true }),
),
);
// `servers` is mocked to a fixed entry here, so it still reports the
// pre-toggle value — exactly what a failed reload leaves behind.
const draftOptions = vi.mocked(useSettingsDraft).mock.calls.at(-1)?.[0];
if (!draftOptions) throw new Error("useSettingsDraft was never called");
expect(draftOptions.resolveInitial("A")).toEqual(
expect.objectContaining({ paginatedLists: true }),
);
});
it("builds the next client from the last write, not the stale entry (#2089)", async () => {
// Connecting is where the whole connection is configured, and it read the
// `servers` entry directly — so a save that landed while list reads were
// failing (including one made from the modal for a server that was not
// connected at the time) was undone by the next connect, which rebuilt the
// client from the frozen entry.
const user = userEvent.setup();
renderWithMantine(<App />);
const draftOptions = vi.mocked(useSettingsDraft).mock.calls.at(-1)?.[0];
if (!draftOptions) throw new Error("useSettingsDraft was never called");
const saved: InspectorServerSettings = {
...settingsWithRoots([{ uri: "file:///saved" }]),
maxFetchRequests: 42,
};
await act(async () => {
await draftOptions.onPersist("A", saved);
});
// `servers` still reports the entry with no settings at all — what a failed
// reload leaves. The client must be built from the write that landed.
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));
const clientOptions = vi
.mocked(McpIndex.InspectorClient)
.mock.calls.at(-1)?.[1] as { roots?: { uri: string }[] } | undefined;
expect(clientOptions?.roots).toEqual([{ uri: "file:///saved" }]);
const fetchLogOptions = vi
.mocked(FetchLogModule.FetchRequestLogState)
.mock.calls.at(-1)?.[1] as { maxFetchRequests?: number } | undefined;
expect(fetchLogOptions?.maxFetchRequests).toBe(42);
});
it("carries a landed stdio env/cwd onto the config it connects with (#2096)", async () => {
// The sibling case above covers the client *options*, which are derived
// from the settings. `env` and `cwd` are the two fields the modal edits as
// settings while the transport reads them off the **config** — and the
// config came straight off the same frozen `servers` entry. So a save that
// landed while list reads were failing kept spawning the child process with
// the pre-save environment, while the modal (re-seeded from the tracker
// since #2089) showed the new one. Credentials live here, so the symptom is
// the inspected server failing to authorize rather than a lost edit.
const user = userEvent.setup();
renderWithMantine(<App />);
const draftOptions = vi.mocked(useSettingsDraft).mock.calls.at(-1)?.[0];
if (!draftOptions) throw new Error("useSettingsDraft was never called");
await act(async () => {
await draftOptions.onPersist("A", {
...settingsWithRoots([]),
env: [{ key: "FOO", value: "after" }],
cwd: "/tmp/after",
});
});
// `servers` still reports `{ type: "stdio", command: "node" }` with no
// settings node at all — what a failed reload leaves behind.
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));
expect(vi.mocked(McpIndex.InspectorClient).mock.calls.at(-1)?.[0]).toEqual({
type: "stdio",
command: "node",
env: { FOO: "after" },
cwd: "/tmp/after",
});
});
it("clears a stdio env/cwd the last write removed (#2096)", async () => {
// Clearing has to travel too, and it is the direction a naive merge gets
// wrong: an empty env list and a blank cwd mean "remove the field", which
// is how the modal deletes a value. Spreading the settings over the config
// would leave the stale one in place instead.
const user = userEvent.setup();
const previousUseServers = vi.mocked(useServers).getMockImplementation();
try {
vi.mocked(useServers).mockReturnValue({
servers: [
{
...SERVER_A,
config: {
type: "stdio",
command: "node",
env: { FOO: "before" },
cwd: "/tmp/before",
},
} as ServerEntry,
],
loading: false,
error: undefined,
refresh: vi.fn().mockResolvedValue(undefined),
addServer: vi.fn(),
updateServer: vi.fn(),
updateServerSettings: updateServerSettingsSpy,
removeServer: vi.fn(),
reorderServers: vi.fn(),
importSource: vi.fn().mockResolvedValue({ servers: {} }),
});
renderWithMantine(<App />);
const draftOptions = vi.mocked(useSettingsDraft).mock.calls.at(-1)?.[0];
if (!draftOptions) throw new Error("useSettingsDraft was never called");
await act(async () => {
await draftOptions.onPersist("A", {
...settingsWithRoots([]),
env: [],
cwd: "",
});
});
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));
expect(
vi.mocked(McpIndex.InspectorClient).mock.calls.at(-1)?.[0],
).toEqual({ type: "stdio", command: "node" });
} finally {
if (previousUseServers) {
vi.mocked(useServers).mockImplementation(previousUseServers);
}
}
});
it("lets a successful list read supersede a concrete rollback override (#2089)", async () => {
// A rollback sets the override to a value rather than clearing it, so the
// effect that drops it cannot key on the persisted boolean alone: if a
// later read reports the value the override replaced — an edit made outside
// the Inspector overtaking the write — neither that boolean nor the server
// id changes, and the UI would stay stuck on the override forever. A read
// rebuilds the list, so the entry object is the signal that supersedes it.
const user = userEvent.setup();
const previousUseServers = vi.mocked(useServers).getMockImplementation();
try {
const { rerender } = renderWithMantine(<App />);
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));
// A write lands `true` while list reads are stale, then a toggle fails —
// leaving the override on `true` with the entry still reporting `false`.
const draftOptions = vi.mocked(useSettingsDraft).mock.calls.at(-1)?.[0];
if (!draftOptions) throw new Error("useSettingsDraft was never called");
await act(async () => {
await draftOptions.onPersist("A", {
...settingsWithRoots([]),
paginatedLists: true,
});
});
updateServerSettingsSpy.mockRejectedValueOnce(new Error("disk full"));
await user.click(screen.getByText("paginated-off"));
await waitFor(() =>
expect(screen.getByTestId("tools-paginated")).toHaveTextContent("true"),
);
// Now a list read succeeds and still says `false` — same boolean, same
// server id, new entry object. It is authoritative and must win.
vi.mocked(useServers).mockReturnValue({
servers: [{ ...SERVER_A } as ServerEntry],
loading: false,
error: undefined,
refresh: vi.fn().mockResolvedValue(undefined),
addServer: vi.fn(),
updateServer: vi.fn(),
updateServerSettings: updateServerSettingsSpy,
removeServer: vi.fn(),
reorderServers: vi.fn(),
importSource: vi.fn().mockResolvedValue({ servers: {} }),
});
rerender(<App />);
await waitFor(() =>
expect(screen.getByTestId("tools-paginated")).toHaveTextContent(
"false",
),
);
} finally {
if (previousUseServers) {
vi.mocked(useServers).mockImplementation(previousUseServers);
}
}
});
it("rolls back to a settings-modal save that landed, not just a toggle (#2089)", async () => {
// The two settings writers feed one record, and this is the half the
// toggle-driven tests cannot reach: `useSettingsDraft` is mocked for this
// file, so its `onPersist` never runs on its own and a regression that
// dropped `begin`/`landed` from the modal path would go unnoticed. The
// callback the App handed the hook is invoked directly instead.
const user = userEvent.setup();
renderWithMantine(<App />);
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));
const draftOptions = vi.mocked(useSettingsDraft).mock.calls.at(-1)?.[0];
if (!draftOptions) throw new Error("useSettingsDraft was never called");
const saved: InspectorServerSettings = {
...settingsWithRoots([]),
paginatedLists: true,
};
// The save lands; the list read behind it is what stays stale, so `servers`
// (mocked to a fixed entry here) never reports it.
await act(async () => {
await draftOptions.onPersist("A", saved);
});
expect(updateServerSettingsSpy).toHaveBeenCalledWith("A", saved);
updateServerSettingsSpy.mockRejectedValueOnce(new Error("disk full"));
await user.click(screen.getByText("paginated-off"));
await waitFor(() =>
expect(screen.getByTestId("tools-paginated")).toHaveTextContent("true"),
);
const client = clientInstances[0] as EventTarget & {
setServerSettings: ReturnType<typeof vi.fn>;
};
const lastPush = client.setServerSettings.mock.calls.at(-1)?.[0] as {
paginatedLists?: boolean;
};
expect(lastPush?.paginatedLists).toBe(true);
});
it("treats a modal save whose list reload failed as landed (#1914 + #2089)", async () => {
// A `ServerListReloadError` rejects *after* the PUT landed, so the save is
// on disk. It has to be recorded as the last landed write and applied like
// any other settled one — the failure path would roll the UI, the live
// client and the modal's seed back to a value disk no longer holds.
const user = userEvent.setup();
renderWithMantine(<App />);
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));
const draftOptions = vi.mocked(useSettingsDraft).mock.calls.at(-1)?.[0];
if (!draftOptions) throw new Error("useSettingsDraft was never called");
updateServerSettingsSpy.mockRejectedValueOnce(
new ServerListReloadError(
"The server was saved, but the server list could not be reloaded: disk full",
),
);
const saved: InspectorServerSettings = {
...settingsWithRoots([]),
paginatedLists: true,
};
await act(async () => {
await draftOptions.onPersist("A", saved).catch(() => undefined);
});
// `servers` is mocked to a fixed entry here — exactly what a failed reload
// leaves — so both of these read the record rather than the entry.
expect(draftOptions.resolveInitial("A")).toEqual(
expect.objectContaining({ paginatedLists: true }),
);
await waitFor(() =>
expect(screen.getByTestId("tools-paginated")).toHaveTextContent("true"),
);
});
it("keeps the optimistic toggle when only the list reload failed (#1914)", async () => {
// The mirror of the test above. `refreshAfterWrite` rejects with a
// `ServerListReloadError` when the PUT landed and only reading the list
// back failed — the setting IS on disk, so the #1721 rollback would put
// the UI and the live client on the value the user just changed away
// from, and contradict what a reload would show.
const user = userEvent.setup();
updateServerSettingsSpy.mockRejectedValueOnce(
new ServerListReloadError(
"The server was saved, but the server list could not be reloaded: disk full",
),
);
renderWithMantine(<App />);
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"),
);
// Give the rejection a turn to land, then confirm nothing reverted it.
await act(async () => {
await Promise.resolve();
});
expect(screen.getByTestId("tools-paginated")).toHaveTextContent("true");
// Single intersection assertion rather than the `as unknown as` this file
// uses elsewhere: the fake client really is an EventTarget, and widening
// it keeps that relationship instead of erasing it (#1914 r2).
const client = clientInstances[0] as EventTarget & {
setServerSettings: ReturnType<typeof vi.fn>;
};
const lastPush = client.setServerSettings.mock.calls.at(-1)?.[0] as {
paginatedLists?: boolean;
};
expect(lastPush?.paginatedLists).toBe(true);
});
it("keeps the toggle on the last write after switching servers and back (#2095)", async () => {
// The override is what holds the display up while list reads are failing --
// the `servers` entry it would otherwise be read from is frozen at the last
// successful read, which this suite models exactly by mocking `useServers`
// to a fixed list.
//
// Held app-wide and cleared on every change of the *active* entry, it did
// not survive a plain server switch: A to B and back dropped it and the
// toggle fell back to that stale entry, reading `off` while disk, the
// tracker, and the client just built from it all said `on`. The lists were
// then rendered in all-pages mode showing an aggregate the client never
// fetched, with no Load-next-page control to fill them.
//
// Nothing here fails a list read explicitly: the fixed mock *is* that
// state, which is why one write is enough to reproduce it.
const user = userEvent.setup();
const previousUseServers = vi.mocked(useServers).getMockImplementation();
try {
vi.mocked(useServers).mockReturnValue({
servers: [
SERVER_A,
{ ...SERVER_A, id: "B", name: "Other" },
] as ServerEntry[],
loading: false,
error: undefined,
refresh: vi.fn().mockResolvedValue(undefined),
addServer: addServerSpy,
updateServer: updateServerSpy,
updateServerSettings: updateServerSettingsSpy,
removeServer: vi.fn(),
reorderServers: vi.fn(),
importSource: vi.fn().mockResolvedValue({ servers: {} }),
});
renderWithMantine(<App />);
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));
await user.click(screen.getByText("paginated-on"));
await waitFor(() =>
expect(updateServerSettingsSpy).toHaveBeenCalledWith(
"A",
expect.objectContaining({ paginatedLists: true }),
),
);
await waitFor(() =>
expect(screen.getByTestId("tools-paginated")).toHaveTextContent("true"),
);
// Away to B, whose own entry has never been written and so reads false.
await user.click(screen.getByText("connect-b"));
await waitFor(() =>
expect(screen.getByTestId("active-server")).toHaveTextContent("B"),
);
expect(screen.getByTestId("tools-paginated")).toHaveTextContent("false");
// ...and back to A, which is still paginated on disk.
await user.click(screen.getByText("connect"));
await waitFor(() =>
expect(screen.getByTestId("active-server")).toHaveTextContent("A"),
);
expect(screen.getByTestId("tools-paginated")).toHaveTextContent("true");
} finally {
if (previousUseServers) {
vi.mocked(useServers).mockImplementation(previousUseServers);
}
}
});
});
// A post-write reload failure rejects *after* the write landed, so every piece
// of state `onConfigSubmit` used to apply on the resolve path has to be applied
// on the reject path too — the rename target and the highlight batch both
// describe a row that really is on disk (#1914 review r2 / r3).
describe("App config submit with a failed list reload (#1914)", () => {
// Earlier describes install their own `useServers` return value, which
// outlives them — so this block installs its own and puts the previous
// implementation back, rather than trusting whatever leaked in.
let restoreUseServers: (() => void) | undefined;
const serversResult = (ids: string[]): ReturnType<typeof useServers> => ({
servers: ids.map((id) => ({ ...SERVER_A, id }) as ServerEntry),
loading: false,
error: undefined,
refresh: vi.fn().mockResolvedValue(undefined),
addServer: addServerSpy,
updateServer: updateServerSpy,
updateServerSettings: updateServerSettingsSpy,
removeServer: vi.fn().mockResolvedValue(undefined),
reorderServers: vi.fn().mockResolvedValue(undefined),
importSource: vi.fn().mockResolvedValue({ servers: {} }),
});
beforeEach(() => {
clientInstances.length = 0;
const previous = vi.mocked(useServers).getMockImplementation();
restoreUseServers = previous
? () => vi.mocked(useServers).mockImplementation(previous)
: undefined;
updateServerSpy.mockClear();
addServerSpy.mockClear();
updateServerSettingsSpy.mockClear();
vi.mocked(useServers).mockReturnValue(serversResult(["A"]));
});
afterEach(() => {
restoreUseServers?.();
});
it("follows the rename so the active id isn't orphaned (#1914)", async () => {
// `activeServerId` isn't rendered anywhere, so it's read back through the
// one handler that passes it straight to a spy: the pagination toggle
// calls `updateServerSettings(activeServerId, …)`.
const user = userEvent.setup();
updateServerSpy.mockRejectedValueOnce(
new ServerListReloadError(
"The server was saved, but the server list could not be reloaded: disk full",
),
);
const { rerender } = renderWithMantine(<App />);
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));
await user.click(screen.getByText("edit-server"));
const idField = await screen.findByLabelText(/Server ID/);
await user.clear(idField);
await user.type(idField, "A-renamed");
await user.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() =>
expect(updateServerSpy).toHaveBeenCalledWith(
"A",
"A-renamed",
expect.anything(),
),
);
// The rejection still reaches the modal, which stays open showing it.
expect(
await screen.findByText(/could not be reloaded: disk full/),
).toBeInTheDocument();
// Now let the row land, as the SSE refresh the write itself triggers
// would. The modal's target ("A") is gone, so it closes rather than
// blanking its own form — `initialId`/`initialConfig` would go undefined
// and ServerConfigModal's reset would wipe the open form and the error
// it is showing (#1914 r3).
vi.mocked(useServers).mockReturnValue(serversResult(["A-renamed"]));
rerender(<App />);
await waitFor(() =>
expect(screen.queryByLabelText(/Server ID/)).not.toBeInTheDocument(),
);
// With the modal gone, read the active id back off the toggle.
await user.click(screen.getByText("paginated-on"));
await waitFor(() =>
expect(updateServerSettingsSpy).toHaveBeenCalledWith(
"A-renamed",
expect.objectContaining({ paginatedLists: true }),
),
);
});
it("labels a settings save whose reload failed as saved, not failed (#1914)", () => {
// The settings draft debounces and flushes on close, so this toast is the
// user's only signal — a flush that rejects usually does so after the
// modal is gone. `useSettingsDraft` is mocked here, so the App's `onError`
// is invoked directly with the options it was handed.
renderWithMantine(<App />);
const onError = vi.mocked(useSettingsDraft).mock.calls.at(-1)?.[0].onError;
expect(onError).toBeDefined();
act(() => {
onError!(
"A",
new ServerListReloadError(
"The server was saved, but the server list could not be reloaded: disk full",
),
);
});
expect(notificationsMock.show).toHaveBeenCalledWith(
expect.objectContaining({
title: 'Saved settings for "A", but the server list did not reload',
message: expect.stringContaining("disk full"),
}),
);
// A genuinely failed write still reads as a failure.
act(() => {
onError!("A", new Error("disk full"));
});
expect(notificationsMock.show).toHaveBeenCalledWith(
expect.objectContaining({ title: 'Failed to save settings for "A"' }),
);
});
it("keeps a successful add in the highlight batch when the reload failed (#1914)", async () => {
// `addServerHighlighted` marked the new id only after `addServer`
// resolved. The row is on disk either way, so on a reload failure it has
// to be marked from the catch — the next successful refresh is what
// renders it, and it would otherwise arrive unhighlighted.
const user = userEvent.setup();
addServerSpy.mockRejectedValueOnce(
new ServerListReloadError(
"The server was added, but the server list could not be reloaded: disk full",
),
);
renderWithMantine(<App />);
expect(screen.getByTestId("highlighted-servers")).toHaveTextContent("none");
await user.click(screen.getByText("add-server"));
await user.type(await screen.findByLabelText(/Server ID/), "brand-new");
await user.type(screen.getByLabelText(/^Command/), "node");
await user.click(screen.getByRole("button", { name: "Add" }));
await waitFor(() =>
expect(addServerSpy).toHaveBeenCalledWith("brand-new", expect.anything()),
);
expect(
await screen.findByText(/could not be reloaded: disk full/),
).toBeInTheDocument();
await waitFor(() =>
expect(screen.getByTestId("highlighted-servers")).toHaveTextContent(
"brand-new",
),
);
});
});
// 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(<App />);
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));
return user;
};
// A complete `useServers` return with one injected mutator, so the mock stays
// type-checked against the hook's contract rather than spread from a call.
const serversWithReorder = (
reorderServers: ReturnType<typeof useServers>["reorderServers"],
): ReturnType<typeof useServers> => ({
servers: [
{
id: "A",
name: "PlotRocket",
config: { type: "streamable-http", url: "https://api.example.com/mcp" },
connection: { status: "disconnected" },
},
],
loading: false,
error: undefined,
refresh: vi.fn().mockResolvedValue(undefined),
addServer: vi.fn().mockResolvedValue(undefined),
updateServer: vi.fn().mockResolvedValue(undefined),
updateServerSettings: vi.fn().mockResolvedValue(undefined),
removeServer: vi.fn().mockResolvedValue(undefined),
reorderServers,
importSource: vi.fn().mockResolvedValue({ servers: {} }),
});
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<typeof vi.fn>;
};
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();
}
});
// `onToggleConnection` and `onDisconnect` both close a live session with
// `try { await disconnect() } finally { finalizeExplicitDisconnect() }` — a
// `finally`, not a `catch` — so a transport that fails to close rejects out
// of the handler. App discards neither: both are terminated with a `.catch`
// that toasts, because a bare `void` would turn an ordinary click into a
// global unhandled rejection with nothing shown to the user (#2130 review).
const CLOSE_FAILURE = new Error("close boom");
it("toasts a failed transport close from the connection toggle instead of leaking it", async () => {
const rejections = captureUnhandledRejections();
try {
const user = await connect();
const client = clientInstances[0] as EventTarget & {
disconnect: ReturnType<typeof vi.fn>;
};
client.disconnect.mockRejectedValueOnce(CLOSE_FAILURE);
// Second click on the same, now-connected server takes the disconnect
// branch of the toggle.
await user.click(screen.getByText("connect"));
await waitFor(() =>
expect(notificationsMock.show).toHaveBeenCalledWith(
expect.objectContaining({
title: "Failed to change the connection",
message: "close boom",
color: "red",
}),
),
);
await settleRejections();
expect(rejections.seen).toEqual([]);
} finally {
rejections.stop();
}
});
// The four command handlers await `handleCommandScopedAuthRecovery` from
// *inside* their catch blocks, and a rejection thrown from a catch is not
// caught by that same catch — so it escapes the handler entirely. That helper
// awaits `checkAuthChallengeSatisfied`, which reaches the backend and can
// reject. Before #2130's review this escaped a bare `void` as a global
// unhandled rejection with nothing shown to the user.
it("toasts an auth-recovery failure that escapes a tool call instead of leaking it", async () => {
const rejections = captureUnhandledRejections();
try {
const user = await connect();
const client = clientInstances[0] as EventTarget & {
callTool: ReturnType<typeof vi.fn>;
};
client.callTool.mockRejectedValueOnce(
new AuthRecoveryRequiredError(
new URL("https://as.example.com/authorize"),
{ reason: "unauthorized" },
),
);
// The recovery helper's first await is the one that breaks.
rejectNextChallengeCheck(new Error("challenge check failed"));
await user.click(screen.getByText("call"));
await waitFor(() =>
expect(notificationsMock.show).toHaveBeenCalledWith(
expect.objectContaining({
title: "Tool call failed",
message: "challenge check failed",
color: "red",
}),
),
);
await settleRejections();
expect(rejections.seen).toEqual([]);
} finally {
rejections.stop();
}
});
// The reorder handler was lifted out of the JSX into a named callback with a
// `.catch` that toasts (#2130). `reorderServers` reverts its own optimistic
// ordering and re-throws — on a 409 from a racing external edit, or a network
// error — so without the toast the drag just silently bounces back.
it("forwards a reorder to the server list", async () => {
const reorderServers = vi.fn().mockResolvedValue(undefined);
vi.mocked(useServers).mockReturnValue(serversWithReorder(reorderServers));
const user = userEvent.setup();
renderWithMantine(<App />);
await user.click(screen.getByText("reorder-servers"));
await waitFor(() =>
expect(reorderServers).toHaveBeenCalledWith(["B", "A"]),
);
expect(notificationsMock.show).not.toHaveBeenCalled();
});
it("toasts a failed reorder instead of leaking it", async () => {
const reorderServers = vi
.fn()
.mockRejectedValue(new Error("stale server list"));
vi.mocked(useServers).mockReturnValue(serversWithReorder(reorderServers));
const rejections = captureUnhandledRejections();
try {
const user = userEvent.setup();
renderWithMantine(<App />);
await user.click(screen.getByText("reorder-servers"));
await waitFor(() =>
expect(notificationsMock.show).toHaveBeenCalledWith(
expect.objectContaining({
title: "Failed to reorder servers",
message: "stale server list",
color: "red",
}),
),
);
await settleRejections();
expect(rejections.seen).toEqual([]);
} finally {
rejections.stop();
}
});
it("toasts a failed transport close from the explicit Disconnect instead of leaking it", async () => {
const rejections = captureUnhandledRejections();
try {
const user = await connect();
const client = clientInstances[0] as EventTarget & {
disconnect: ReturnType<typeof vi.fn>;
};
client.disconnect.mockRejectedValueOnce(CLOSE_FAILURE);
await user.click(screen.getByText("disconnect"));
await waitFor(() =>
expect(notificationsMock.show).toHaveBeenCalledWith(
expect.objectContaining({
title: "Failed to disconnect",
message: "close boom",
color: "red",
}),
),
);
await settleRejections();
expect(rejections.seen).toEqual([]);
} finally {
rejections.stop();
}
});
});
/** A complete `useManagedResources` return, so the mock stays type-checked against the hook's contract. */
function managedResourcesResult(
resources: Resource[],
): UseManagedResourcesResult {
return {
error: null,
resources,
listChanged: false,
refresh: vi.fn().mockResolvedValue(resources),
clearListChanged: vi.fn(),
};
}
describe("App MCP App listed-resource metadata wiring (#2055)", () => {
beforeEach(() => {
appBridgeFactoryDeps.length = 0;
});
afterEach(() => {
// The hook mock is module-level and shared, so put the empty-list default
// back rather than leaving a populated list for whatever runs next.
vi.mocked(useManagedResources).mockReturnValue(managedResourcesResult([]));
});
it("hands the bridge factory a getListedResourceMeta reading the resources/list entries", async () => {
// ext-apps treats a listing entry's `_meta.ui` as the static default for a
// UI resource, so the App has to surface the listing to the bridge — the
// factory's own tests inject the dep and cannot see this wiring break.
const listedMeta = {
ui: { csp: { connectDomains: ["https://api.example.com"] } },
};
vi.mocked(useManagedResources).mockReturnValue(
managedResourcesResult([
{ uri: "ui://weather/app.html", name: "app", _meta: listedMeta },
]),
);
renderWithMantine(<App />);
// App builds two factories, differing only in `advertiseElicitation`. Wait
// for BOTH by that flag rather than for a count: waiting on "at least one"
// would still pass with the wiring stripped from either call site, since
// the other's deps sit in the same array.
const appsFactory = () =>
appBridgeFactoryDeps.find((d) => !d.advertiseElicitation);
const elicitationFactory = () =>
appBridgeFactoryDeps.find((d) => d.advertiseElicitation === true);
await waitFor(() => {
expect(appsFactory()).toBeDefined();
expect(elicitationFactory()).toBeDefined();
});
for (const deps of [appsFactory(), elicitationFactory()]) {
expect(deps?.getListedResourceMeta?.("ui://weather/app.html")).toEqual(
listedMeta,
);
expect(
deps?.getListedResourceMeta?.("ui://other/app.html"),
).toBeUndefined();
}
});
it("hands BOTH bridge factories a working publishAppDocument (#2056)", async () => {
// Both call sites pass the same `publishDocument`, and the end-to-end smoke
// only covers the Apps-screen one — so without this the elicitation
// factory's `publishAppDocument` could be dropped and every test stays
// green while a domain-declaring elicitation app silently loses its real
// origin. Assert it is not merely present but actually reaches the lib.
publishAppDocumentMock.mockResolvedValue(
"http://127.0.0.1:6278/app-document/deadbeef",
);
renderWithMantine(<App />);
const appsFactory = () =>
appBridgeFactoryDeps.find((d) => !d.advertiseElicitation);
const elicitationFactory = () =>
appBridgeFactoryDeps.find((d) => d.advertiseElicitation === true);
await waitFor(() => {
expect(appsFactory()).toBeDefined();
expect(elicitationFactory()).toBeDefined();
});
for (const deps of [appsFactory(), elicitationFactory()]) {
publishAppDocumentMock.mockClear();
await expect(
deps?.publishAppDocument?.({
html: "<p>app</p>",
csp: "default-src 'none'",
}),
).resolves.toBe("http://127.0.0.1:6278/app-document/deadbeef");
expect(publishAppDocumentMock).toHaveBeenCalledWith(
{ html: "<p>app</p>", csp: "default-src 'none'" },
expect.objectContaining({ baseUrl: expect.any(String) }),
);
}
});
});
// #2217 — persisted OAuth state is keyed by server URL and `serverList`
// enforces no URL uniqueness, so two catalog entries can share one credential
// blob, one grant and one revocation. `runClear`'s in-flight guard used to
// dedupe by catalog id, which cannot see that: clearing both entries while the
// first revocation was still out ran concurrent store writes, concurrent RFC
// 7009 requests and two contradictory teardown toasts — the exact race the
// guard exists to prevent (Copilot, on this PR).
describe("App dedupes concurrent OAuth clears that share a storage key (#2217)", () => {
const SHARED_URL = "https://shared.example/mcp";
const sharedEntry = (id: string, name: string): ServerEntry => ({
id,
name,
config: { type: "streamable-http", url: SHARED_URL },
connection: { status: "disconnected" },
});
let previousUseServers: typeof useServers | undefined;
beforeEach(() => {
vi.clearAllMocks();
clientInstances.length = 0;
previousUseServers = vi.mocked(useServers).getMockImplementation();
vi.mocked(useInspectorClient).mockReturnValue(DEFAULT_USE_INSPECTOR_CLIENT);
vi.mocked(useServers).mockReturnValue({
servers: [sharedEntry("A", "PlotRocket"), sharedEntry("B", "Same URL")],
loading: false,
error: undefined,
refresh: vi.fn().mockResolvedValue(undefined),
addServer: addServerSpy,
updateServer: updateServerSpy,
updateServerSettings: updateServerSettingsSpy,
removeServer: vi.fn(),
} as unknown as ReturnType<typeof useServers>);
});
afterEach(() => {
if (previousUseServers) {
vi.mocked(useServers).mockImplementation(previousUseServers);
}
});
/** Open the settings modal for `which` and press its OAuth-section clear. */
async function clearFromSettings(
user: ReturnType<typeof userEvent.setup>,
which: "open-settings" | "open-settings-b",
): Promise<void> {
await user.click(screen.getByText(which));
// The control lives in an accordion section. The modal stays mounted
// across a target switch, so the section may already be open from a
// previous call — toggling it again would close it.
const section = await screen.findByRole("button", {
name: "OAuth Settings",
});
if (section.getAttribute("aria-expanded") !== "true") {
await user.click(section);
}
await user.click(
await screen.findByRole("button", { name: "Clear stored OAuth state" }),
);
}
// Three full modal interaction sequences against the whole App tree, so this
// one runs long enough to trip the 5s default when the suite is under load.
it(
"suppresses a second clear for another entry with the same URL, and allows one after it settles",
{ timeout: 20000 },
async () => {
// `delay: null` drops userEvent's inter-event waits, which dominate here.
const user = userEvent.setup({ delay: null });
renderWithMantine(<App />);
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));
const client = clientInstances[0] as unknown as {
clearOAuthTokens: ReturnType<typeof vi.fn>;
};
// Hold the first clear open, as a pending RFC 7009 request would.
let settle: (v: { status: string; reason: string }) => void = () => {};
client.clearOAuthTokens.mockImplementation(
() =>
new Promise((resolve) => {
settle = resolve as typeof settle;
}),
);
await clearFromSettings(user, "open-settings");
await waitFor(() =>
expect(client.clearOAuthTokens).toHaveBeenCalledTimes(1),
);
// Entry B: a different catalog id, the same OAuth storage key. Both clears
// route through the live client precisely because they share that key, so
// an id-keyed guard would let this second one straight through.
await clearFromSettings(user, "open-settings-b");
expect(client.clearOAuthTokens).toHaveBeenCalledTimes(1);
// Suppression is for the duration of the in-flight clear only — the key
// must be released when it settles, or the control is dead for the rest of
// the session.
await act(async () => {
settle({ status: "skipped", reason: "no_endpoint" });
await Promise.resolve();
});
client.clearOAuthTokens.mockResolvedValue({
status: "skipped",
reason: "no_endpoint",
});
await clearFromSettings(user, "open-settings-b");
await waitFor(() =>
expect(client.clearOAuthTokens).toHaveBeenCalledTimes(2),
);
},
);
});