Merge branch 'v2/main' into v2/feat/2144-oauth-revocation
This commit is contained in:
commit
5ba2fadd3b
1 changed files with 3326 additions and 1516 deletions
|
|
@ -453,122 +453,67 @@ vi.mock("./lib/publishAppDocument", () => ({
|
|||
// that invoke the App's connect / call-tool / get-prompt / read-resource /
|
||||
// set-log-level handlers.
|
||||
vi.mock("./components/views/InspectorView/InspectorView", () => ({
|
||||
InspectorView: (props: {
|
||||
toolCallState?: { status?: string };
|
||||
toolsUi?: {
|
||||
selectedToolKey?: string;
|
||||
formValues: Record<string, unknown>;
|
||||
search: string;
|
||||
};
|
||||
promptsUi?: {
|
||||
selectedPromptName?: string;
|
||||
argumentValues: Record<string, string>;
|
||||
submittedFor?: string;
|
||||
search: string;
|
||||
};
|
||||
logsUi?: { filterText: string; visibleLevels: Record<string, boolean> };
|
||||
getPromptState?: { status?: string };
|
||||
readResourceState?: { status?: string };
|
||||
currentLogLevel?: string;
|
||||
activeTab?: string;
|
||||
activeServer?: string;
|
||||
erroredServerId?: string;
|
||||
initializeResult?: { serverInfo: { name: string; version: string } };
|
||||
onActiveTabChange: (tab: string) => void;
|
||||
onConnectionInfo: () => void;
|
||||
onToggleConnection: (id: string) => void;
|
||||
onToolsUiChange: (next: {
|
||||
selectedToolKey?: string;
|
||||
formValues: Record<string, unknown>;
|
||||
search: string;
|
||||
}) => void;
|
||||
onPromptsUiChange: (next: {
|
||||
selectedPromptName?: string;
|
||||
argumentValues: Record<string, string>;
|
||||
submittedFor?: string;
|
||||
search: string;
|
||||
}) => void;
|
||||
onLogsUiChange: (next: {
|
||||
filterText: string;
|
||||
visibleLevels: Record<string, boolean>;
|
||||
}) => void;
|
||||
progressByTaskId?: Record<string, unknown>;
|
||||
onCallTool: (
|
||||
name: string,
|
||||
args: Record<string, unknown>,
|
||||
runAsTask?: boolean,
|
||||
) => void;
|
||||
onGetPrompt: (name: string, args: Record<string, string>) => void;
|
||||
onReadResource: (uri: string) => void;
|
||||
onSetLogLevel: (level: string) => void;
|
||||
onCancelTask: (taskId: string) => void;
|
||||
onCancelToolCall: () => void;
|
||||
onClearCompletedTasks: () => void;
|
||||
onRefreshTasks: () => void;
|
||||
onServerSettings: (id: string) => void;
|
||||
onServerEdit: (id: string) => void;
|
||||
onServerAdd: () => void;
|
||||
highlightedServerIds?: string[];
|
||||
onClearProtocol: () => void;
|
||||
onReplayProtocol: (id: string) => void;
|
||||
onTogglePinProtocol: (id: string) => void;
|
||||
pinnedProtocolIds?: Set<string>;
|
||||
onRefreshTools: () => void;
|
||||
toolsPagination: {
|
||||
paginated: boolean;
|
||||
canLoadMore: boolean;
|
||||
loadedPages: number;
|
||||
onPaginatedChange: (v: boolean) => void;
|
||||
onLoadMore: () => void;
|
||||
};
|
||||
}) => (
|
||||
InspectorView: (props: InspectorViewProps) => (
|
||||
<div>
|
||||
<span data-testid="tool-status">
|
||||
{props.toolCallState?.status ?? "none"}
|
||||
{props.tools.toolCallState?.status ?? "none"}
|
||||
</span>
|
||||
<span data-testid="task-progress-keys">
|
||||
{Object.keys(props.progressByTaskId ?? {}).join(",") || "none"}
|
||||
{Object.keys(props.tasks.progressByTaskId ?? {}).join(",") || "none"}
|
||||
</span>
|
||||
<span data-testid="selected-tool">
|
||||
{props.toolsUi?.selectedToolKey ?? "none"}
|
||||
{props.tools.toolsUi?.selectedToolKey ?? "none"}
|
||||
</span>
|
||||
<span data-testid="tool-search">
|
||||
{props.tools.toolsUi?.search || "none"}
|
||||
</span>
|
||||
<span data-testid="tool-search">{props.toolsUi?.search || "none"}</span>
|
||||
<span data-testid="selected-prompt">
|
||||
{props.promptsUi?.selectedPromptName ?? "none"}
|
||||
{props.prompts.promptsUi?.selectedPromptName ?? "none"}
|
||||
</span>
|
||||
<span data-testid="log-filter">
|
||||
{props.logs.logsUi?.filterText || "none"}
|
||||
</span>
|
||||
<span data-testid="log-filter">{props.logsUi?.filterText || "none"}</span>
|
||||
<span data-testid="prompt-status">
|
||||
{props.getPromptState?.status ?? "none"}
|
||||
{props.prompts.getPromptState?.status ?? "none"}
|
||||
</span>
|
||||
<span data-testid="resource-status">
|
||||
{props.readResourceState?.status ?? "none"}
|
||||
{props.resources.readResourceState?.status ?? "none"}
|
||||
</span>
|
||||
<span data-testid="log-level">{props.currentLogLevel}</span>
|
||||
<span data-testid="active-tab">{props.activeTab ?? "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.initializeResult
|
||||
? `name:${props.initializeResult.serverInfo.name || "(empty)"}`
|
||||
{props.connection.initializeResult
|
||||
? `name:${props.connection.initializeResult.serverInfo.name || "(empty)"}`
|
||||
: "none"}
|
||||
</span>
|
||||
<span data-testid="errored-server">
|
||||
{props.erroredServerId ?? "none"}
|
||||
{props.connection.erroredServerId ?? "none"}
|
||||
</span>
|
||||
<span data-testid="active-server">{props.activeServer ?? "none"}</span>
|
||||
<button onClick={() => props.onActiveTabChange("Servers")}>
|
||||
<span data-testid="active-server">
|
||||
{props.connection.activeServer ?? "none"}
|
||||
</span>
|
||||
<button onClick={() => props.shell.onActiveTabChange("Servers")}>
|
||||
switch-servers-tab
|
||||
</button>
|
||||
<button onClick={() => props.onToggleConnection("A")}>connect</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.onToggleConnection("B")}>connect-b</button>
|
||||
<button onClick={() => props.onConnectionInfo()}>
|
||||
<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.onToolsUiChange({
|
||||
formValues: {},
|
||||
search: "",
|
||||
...props.toolsUi,
|
||||
props.tools.onToolsUiChange({
|
||||
...props.tools.toolsUi,
|
||||
selectedToolKey: "0:get_acts",
|
||||
})
|
||||
}
|
||||
|
|
@ -577,10 +522,8 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
|
|||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
props.onToolsUiChange({
|
||||
formValues: {},
|
||||
search: "",
|
||||
...props.toolsUi,
|
||||
props.tools.onToolsUiChange({
|
||||
...props.tools.toolsUi,
|
||||
selectedToolKey: "1:other_tool",
|
||||
})
|
||||
}
|
||||
|
|
@ -589,9 +532,8 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
|
|||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
props.onToolsUiChange({
|
||||
formValues: {},
|
||||
...props.toolsUi,
|
||||
props.tools.onToolsUiChange({
|
||||
...props.tools.toolsUi,
|
||||
search: "act",
|
||||
})
|
||||
}
|
||||
|
|
@ -600,10 +542,8 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
|
|||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
props.onPromptsUiChange({
|
||||
argumentValues: {},
|
||||
search: "",
|
||||
...props.promptsUi,
|
||||
props.prompts.onPromptsUiChange({
|
||||
...props.prompts.promptsUi,
|
||||
selectedPromptName: "greet",
|
||||
})
|
||||
}
|
||||
|
|
@ -612,70 +552,99 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
|
|||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
props.onLogsUiChange({
|
||||
visibleLevels: {},
|
||||
...props.logsUi,
|
||||
props.logs.onLogsUiChange({
|
||||
...props.logs.logsUi,
|
||||
filterText: "err",
|
||||
})
|
||||
}
|
||||
>
|
||||
set-log-filter
|
||||
</button>
|
||||
<button onClick={() => props.onCallTool("get_acts", {})}>call</button>
|
||||
<button onClick={() => props.onCallTool("get_acts", {}, true)}>
|
||||
<button onClick={() => props.tools.onCallTool("get_acts", {})}>
|
||||
call
|
||||
</button>
|
||||
<button onClick={() => props.tools.onCallTool("get_acts", {}, true)}>
|
||||
call-as-task
|
||||
</button>
|
||||
<button onClick={() => props.onCancelTask("task-1")}>cancel-task</button>
|
||||
<button onClick={() => props.onCancelToolCall()}>cancel-tool-call</button>
|
||||
<button onClick={() => props.onClearCompletedTasks()}>
|
||||
<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.onRefreshTasks()}>refresh-tasks</button>
|
||||
<button onClick={() => props.onGetPrompt("greet", {})}>get-prompt</button>
|
||||
<button onClick={() => props.onReadResource("res://x")}>
|
||||
<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.onSetLogLevel("debug")}>set-level</button>
|
||||
<button onClick={() => props.onServerSettings("A")}>open-settings</button>
|
||||
<button onClick={() => props.logs.onSetLogLevel("debug")}>
|
||||
set-level
|
||||
</button>
|
||||
<button onClick={() => props.servers.onServerSettings("A")}>
|
||||
open-settings
|
||||
</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.onServerEdit("A")}>edit-server</button>
|
||||
<button onClick={() => props.onServerAdd()}>add-server</button>
|
||||
<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.highlightedServerIds ?? []).join(",") || "none"}
|
||||
{(props.servers.highlightedServerIds ?? []).join(",") || "none"}
|
||||
</span>
|
||||
<span data-testid="pinned-history">
|
||||
{Array.from(props.pinnedProtocolIds ?? []).join(",")}
|
||||
{Array.from(props.protocol.pinnedProtocolIds ?? []).join(",")}
|
||||
</span>
|
||||
<button onClick={() => props.onTogglePinProtocol("hist-1")}>
|
||||
<button onClick={() => props.protocol.onTogglePinProtocol("hist-1")}>
|
||||
toggle-pin
|
||||
</button>
|
||||
<button onClick={() => props.onReplayProtocol("hist-1")}>
|
||||
<button onClick={() => props.protocol.onReplayProtocol("hist-1")}>
|
||||
replay-history
|
||||
</button>
|
||||
<button onClick={() => props.onClearProtocol()}>clear-history</button>
|
||||
<button onClick={() => props.protocol.onClearProtocol()}>
|
||||
clear-history
|
||||
</button>
|
||||
<span data-testid="tools-paginated">
|
||||
{String(props.toolsPagination.paginated)}
|
||||
{String(props.tools.toolsPagination.paginated)}
|
||||
</span>
|
||||
<span data-testid="tools-loaded-pages">
|
||||
{props.toolsPagination.loadedPages}
|
||||
{props.tools.toolsPagination.loadedPages}
|
||||
</span>
|
||||
<button onClick={() => props.toolsPagination.onPaginatedChange(true)}>
|
||||
<button
|
||||
onClick={() => props.tools.toolsPagination.onPaginatedChange(true)}
|
||||
>
|
||||
paginated-on
|
||||
</button>
|
||||
<button onClick={() => props.toolsPagination.onPaginatedChange(false)}>
|
||||
<button
|
||||
onClick={() => props.tools.toolsPagination.onPaginatedChange(false)}
|
||||
>
|
||||
paginated-off
|
||||
</button>
|
||||
<button onClick={() => props.toolsPagination.onLoadMore()}>
|
||||
<button onClick={() => props.tools.toolsPagination.onLoadMore()}>
|
||||
load-more-tools
|
||||
</button>
|
||||
<button onClick={() => props.onRefreshTools()}>refresh-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";
|
||||
|
|
@ -3845,6 +3814,30 @@ describe("App background command rejections (#2049)", () => {
|
|||
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: [],
|
||||
|
|
@ -3922,6 +3915,156 @@ describe("App background command rejections (#2049)", () => {
|
|||
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. */
|
||||
|
|
|
|||
Loading…
Reference in a new issue