diff --git a/clients/web/src/App.test.tsx b/clients/web/src/App.test.tsx index 9e626f9f9..2b7f0a9bf 100644 --- a/clients/web/src/App.test.tsx +++ b/clients/web/src/App.test.tsx @@ -450,122 +450,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; - search: string; - }; - promptsUi?: { - selectedPromptName?: string; - argumentValues: Record; - submittedFor?: string; - search: string; - }; - logsUi?: { filterText: string; visibleLevels: Record }; - getPromptState?: { status?: string }; - readResourceState?: { status?: string }; - currentLogLevel?: string; - activeTab?: string; - 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; - search: string; - }) => void; - onPromptsUiChange: (next: { - selectedPromptName?: string; - argumentValues: Record; - submittedFor?: string; - search: string; - }) => void; - onLogsUiChange: (next: { - filterText: string; - visibleLevels: Record; - }) => void; - progressByTaskId?: Record; - onCallTool: ( - name: string, - args: Record, - runAsTask?: boolean, - ) => void; - onGetPrompt: (name: string, args: Record) => void; - onReadResource: (uri: string) => void; - onSetLogLevel: (level: string) => void; - onCancelTask: (taskId: string) => void; - onCancelToolCall: () => void; - onClearCompletedTasks: () => void; - onRefreshTasks: () => void; - onServerSettings: (id: string) => void; - onServerEdit: (id: string) => void; - onServerAdd: () => void; - highlightedServerIds?: string[]; - onClearProtocol: () => void; - onReplayProtocol: (id: string) => void; - onTogglePinProtocol: (id: string) => void; - pinnedProtocolIds?: Set; - onRefreshTools: () => void; - toolsPagination: { - paginated: boolean; - canLoadMore: boolean; - loadedPages: number; - onPaginatedChange: (v: boolean) => void; - onLoadMore: () => void; - }; - }) => ( + InspectorView: (props: InspectorViewProps) => (
- {props.toolCallState?.status ?? "none"} + {props.tools.toolCallState?.status ?? "none"} - {Object.keys(props.progressByTaskId ?? {}).join(",") || "none"} + {Object.keys(props.tasks.progressByTaskId ?? {}).join(",") || "none"} - {props.toolsUi?.selectedToolKey ?? "none"} + {props.tools.toolsUi?.selectedToolKey ?? "none"} + + + {props.tools.toolsUi?.search || "none"} - {props.toolsUi?.search || "none"} - {props.promptsUi?.selectedPromptName ?? "none"} + {props.prompts.promptsUi?.selectedPromptName ?? "none"} + + + {props.logs.logsUi?.filterText || "none"} - {props.logsUi?.filterText || "none"} - {props.getPromptState?.status ?? "none"} + {props.prompts.getPromptState?.status ?? "none"} - {props.readResourceState?.status ?? "none"} + {props.resources.readResourceState?.status ?? "none"} - {props.currentLogLevel} - {props.activeTab ?? "none"} + {props.logs.currentLogLevel} + {props.shell.activeTab ?? "none"} - {props.initializeResult - ? `name:${props.initializeResult.serverInfo.name || "(empty)"}` + {props.connection.initializeResult + ? `name:${props.connection.initializeResult.serverInfo.name || "(empty)"}` : "none"} - {props.erroredServerId ?? "none"} + {props.connection.erroredServerId ?? "none"} - {props.activeServer ?? "none"} - - + {/* A second target, so a test can drive an A -> B -> A switch (#2095). */} - - + {/* The header's explicit Disconnect, which routes to the standalone + `onDisconnect` rather than through the toggle. */} + + - - + - - - + + - - - + + - - + + {/* 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. */} - - + + + {/* The real drag-and-drop reorder lives inside this mocked view, so the + callback is only reachable through a control like this one. */} + - {(props.highlightedServerIds ?? []).join(",") || "none"} + {(props.servers.highlightedServerIds ?? []).join(",") || "none"} - {Array.from(props.pinnedProtocolIds ?? []).join(",")} + {Array.from(props.protocol.pinnedProtocolIds ?? []).join(",")} - - - + - {String(props.toolsPagination.paginated)} + {String(props.tools.toolsPagination.paginated)} - {props.toolsPagination.loadedPages} + {props.tools.toolsPagination.loadedPages} - - - - +
), })); 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"; @@ -3842,6 +3811,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["reorderServers"], + ): ReturnType => ({ + 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: [], @@ -3919,6 +3912,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; + }; + 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; + }; + 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(); + + 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(); + + 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; + }; + 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. */ diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index 91bf2c29f..b1bc9f286 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -64,6 +64,20 @@ import { useInitialConfig } from "@inspector/core/react/useInitialConfig.js"; import { refreshingPersist } from "./lib/refreshingPersist"; import { usePendingClientRequests } from "@inspector/core/react/usePendingClientRequests.js"; import { InspectorView } from "./components/views/InspectorView/InspectorView"; +import type { + AppsPanelProps, + ConnectionProps, + ConsolePanelProps, + LogsPanelProps, + NetworkPanelProps, + PromptsPanelProps, + ProtocolPanelProps, + ResourcesPanelProps, + ServerListProps, + ShellProps, + TasksPanelProps, + ToolsPanelProps, +} from "./components/views/InspectorView/types"; import type { ReadResourceState } from "./components/screens/ResourcesScreen/ResourcesScreen"; import { AppElicitationHost } from "./components/elements/AppElicitation/AppElicitationHost"; import type { LogEntryData } from "./components/elements/LogEntry/LogEntry"; @@ -108,6 +122,21 @@ import { FetchBodyDroppedToastMessage } from "./components/elements/Toasts/Fetch import { OutputValidationToastMessage } from "./components/elements/Toasts/OutputValidationToastMessage"; import { ReAuthBannerBar } from "./components/groups/ReAuthBanner/ReAuthBannerBar"; +/** + * Terminates a dispatched handler's promise by reporting the failure, for the + * `InspectorView` action wrappers below. Module scope because it closes over + * nothing — the title is the only thing that varies per action. + */ +function reportDispatchFailure(title: string) { + return (err: unknown) => { + notifications.show({ + title, + message: err instanceof Error ? err.message : String(err), + color: "red", + }); + }; +} + function App() { const { onToggleTheme } = useThemeToggle(); @@ -1469,6 +1498,304 @@ function App() { [pendingElicitations, inspectorClient], ); + // --- InspectorView prop bundles (#2130) ----------------------------------- + // + // The view takes one prop per domain, so the JSX below reads as a component + // tree rather than a wall of ~130 props. Everything the bundles need is + // assembled here: the handlers that used to be multi-line closures declared + // inline in the JSX are named callbacks, and the `void`-discarding wrappers + // carry their justification once rather than at a call site. + + const onServerAdd = useCallback(() => { + setHighlightedServerIds([]); + setConfigModal({ mode: "add" }); + }, []); + + const onServerImportConfig = useCallback(() => { + setHighlightedServerIds([]); + setImportConfigOpen(true); + }, []); + + const onServerImportJson = useCallback(() => { + setHighlightedServerIds([]); + setImportJsonOpen(true); + }, []); + + const onServerEdit = useCallback((id: string) => { + setConfigModal({ mode: "edit", targetId: id }); + }, []); + + const onServerClone = useCallback((id: string) => { + setHighlightedServerIds([]); + setConfigModal({ mode: "clone", targetId: id }); + }, []); + + const onServerRemove = useCallback( + (id: string) => { + const target = servers.find((s) => s.id === id); + if (target) setRemoveTarget(target); + }, + [servers], + ); + + const onServerReorderFromList = useCallback( + (orderedIds: string[]) => { + // reorderServers reverts the optimistic order via an internal refresh() + // and re-throws on failure (409 from a racing external edit, or a network + // error). Surface that to the user so the drag doesn't silently bounce + // back — matching the toast pattern every other mutation here uses. + reorderServers(orderedIds).catch((err: unknown) => { + notifications.show({ + title: "Failed to reorder servers", + message: err instanceof Error ? err.message : String(err), + color: "red", + }); + }); + }, + [reorderServers], + ); + + const openClientSettings = useCallback(() => setClientSettingsOpen(true), []); + const openConnectionInfo = useCallback( + () => setConnectionInfoModalOpen(true), + [], + ); + const openServerSettings = useCallback( + (id: string) => setSettingsModalTargetId(id), + [], + ); + + // Every `InspectorView` action prop is synchronous while the handler behind + // it is async, so each wrapper below has to terminate its promise somehow. + // They all do it the same way: a reporting `.catch`, never a bare `void`. + // + // The uniformity is the point. Deciding per handler whether it "owns its + // failures" is a judgement this PR got wrong twice (#2130 review) — the + // handlers really do catch their ordinary errors, but a rejection can still + // escape by a route the reading misses: + // + // - `onToggleConnection` awaits `initialConfigSettledRef` and constructs + // the client via `setupClientForServer` outside its own try/catch, and + // its already-connected branch is a `try/finally` that lets a failed + // transport close propagate. `onDisconnect` is that `try/finally` alone. + // - `onCallTool` / `onGetPrompt` / `onReadResource` / `onOpenApp` await + // `handleCommandScopedAuthRecovery` from *inside* their catch blocks, and + // a rejection thrown from a catch is not caught by that same catch. That + // helper awaits `checkAuthChallengeSatisfied` and `pushRemoteAuthState`, + // both of which reach the backend and can reject. + // + // A handler that does surface its own failure resolves, so it never reaches + // this `.catch` and there is no double-toast; what lands here is only the + // escape, which would otherwise be a global unhandled rejection with nothing + // shown to the user. + const dispatchToggleConnection = useCallback( + (id: string) => { + onToggleConnection(id).catch( + reportDispatchFailure("Failed to change the connection"), + ); + }, + [onToggleConnection], + ); + const dispatchDisconnect = useCallback(() => { + onDisconnect().catch(reportDispatchFailure("Failed to disconnect")); + }, [onDisconnect]); + const dispatchCallTool = useCallback( + (name: string, args: Record, runAsTask?: boolean) => { + onCallTool(name, args, runAsTask).catch( + reportDispatchFailure("Tool call failed"), + ); + }, + [onCallTool], + ); + const dispatchGetPrompt = useCallback( + (name: string, args: Record) => { + onGetPrompt(name, args).catch( + reportDispatchFailure("Failed to get prompt"), + ); + }, + [onGetPrompt], + ); + const dispatchReadResource = useCallback( + (uri: string) => { + onReadResource(uri).catch( + reportDispatchFailure("Failed to read resource"), + ); + }, + [onReadResource], + ); + const dispatchCancelTask = useCallback( + (taskId: string) => { + onCancelTask(taskId).catch( + reportDispatchFailure("Failed to cancel task"), + ); + }, + [onCancelTask], + ); + const dispatchOpenApp = useCallback( + (name: string, args: Record) => { + onOpenApp(name, args).catch(reportDispatchFailure("Failed to open app")); + }, + [onOpenApp], + ); + + const shellProps: ShellProps = { + deepLink, + deepLinkStatus, + version: inspectorVersion, + malformedListItems: shownMalformedListItems, + activeTab, + onActiveTabChange: setActiveTab, + onToggleTheme, + onOpenClientSettings: openClientSettings, + }; + + const connectionProps: ConnectionProps = { + activeServer: activeServerId, + erroredServerId: failedServerId, + connectedServerId, + connectionStatus, + connectErrorMessage, + initializeResult, + latencyMs, + protocolEra, + onCompleteArgument, + completionsSupported: capabilities?.completions !== undefined, + onToggleConnection: dispatchToggleConnection, + onDisconnect: dispatchDisconnect, + }; + + const serverListProps: ServerListProps = { + servers, + serverListWritable, + highlightedServerIds, + onClearHighlight: clearHighlight, + onServerAdd, + onServerImportConfig, + onServerImportJson, + onServerExport, + onConnectionInfo: openConnectionInfo, + onServerSettings: openServerSettings, + onServerEdit, + onServerClone, + onServerRemove, + onServerReorder: onServerReorderFromList, + }; + + const toolsPanelProps: ToolsPanelProps = { + tools, + excludedTools, + toolsListChanged, + toolsLoadError: toolsPagination.error, + toolsUi: ui.toolsUi, + toolCallState, + toolsPagination: toolsPaginationControls, + serverSupportsTaskToolCalls: + !!capabilities?.tasks?.requests?.tools?.call || + (inspectorClient?.isTasksExtensionNegotiated() ?? false), + onToolsUiChange, + onCallTool: dispatchCallTool, + onCancelToolCall, + onClearToolResult, + onRefreshTools, + onReadResourceContents, + }; + + const promptsPanelProps: PromptsPanelProps = { + prompts, + promptsListChanged, + promptsLoadError: promptsPagination.error, + promptsUi: ui.promptsUi, + getPromptState, + promptsPagination: promptsPaginationControls, + onPromptsUiChange: setUi.setPromptsUi, + onGetPrompt: dispatchGetPrompt, + onRefreshPrompts, + }; + + const resourcesPanelProps: ResourcesPanelProps = { + resources, + resourceTemplates, + subscriptions, + subscriptionStreamState, + subscriptionsSupported: capabilities?.resources?.subscribe === true, + resourcesListChanged, + resourcesLoadError: resourcesPagination.error ?? resourceTemplatesLoadError, + resourcesUi: ui.resourcesUi, + readResourceState: effectiveReadResourceState, + resourcesPagination: resourcesPaginationControls, + onResourcesUiChange: setUi.setResourcesUi, + onReadResource: dispatchReadResource, + onSubscribeResource, + onUnsubscribeResource, + onRefreshResources, + }; + + const appsPanelProps: AppsPanelProps = { + appsUi: ui.appsUi, + sandboxPath: sandboxUrl, + bridgeFactory: sandboxBridgeFactory, + appRendererRef, + onAppsUiChange: setUi.setAppsUi, + onSelectApp, + onOpenApp: dispatchOpenApp, + onCloseApp, + onAppError, + // The Apps tab is a filtered view of the tools list, so refreshing it is + // refreshing tools. + onRefreshApps: onRefreshTools, + }; + + const tasksPanelProps: TasksPanelProps = { + tasks, + progressByTaskId, + tasksUi: ui.tasksUi, + onTasksUiChange: setUi.setTasksUi, + onCancelTask: dispatchCancelTask, + onClearCompletedTasks, + onRefreshTasks, + }; + + const logsPanelProps: LogsPanelProps = { + logs, + logsUi: ui.logsUi, + currentLogLevel, + modernLogLevel, + onSetLogLevel, + onSetModernLogLevel, + onLogsUiChange: setUi.setLogsUi, + onClearLogs, + onExportLogs, + }; + + const protocolPanelProps: ProtocolPanelProps = { + protocol: protocolEntries, + protocolUi: ui.protocolUi, + pinnedProtocolIds, + onProtocolUiChange: setUi.setProtocolUi, + onClearProtocol, + onExportProtocol, + onClearProtocolSection, + onExportProtocolSection, + onReplayProtocol, + onTogglePinProtocol: togglePinProtocol, + }; + + const networkPanelProps: NetworkPanelProps = { + network: fetchRequests, + networkUi: ui.networkUi, + onNetworkUiChange: setUi.setNetworkUi, + onClearNetwork, + onExportNetwork, + }; + + const consolePanelProps: ConsolePanelProps = { + stderrLogs, + consoleUi: ui.consoleUi, + onConsoleUiChange: setUi.setConsoleUi, + onClearConsole, + onExportConsole, + }; + return ( <> @@ -1484,171 +1811,18 @@ function App() { ) : null} setClientSettingsOpen(true)} - onToggleConnection={(id) => { - void onToggleConnection(id); - }} - onDisconnect={() => { - void onDisconnect(); - }} - onServerAdd={() => { - setHighlightedServerIds([]); - setConfigModal({ mode: "add" }); - }} - onServerImportConfig={() => { - setHighlightedServerIds([]); - setImportConfigOpen(true); - }} - onServerImportJson={() => { - setHighlightedServerIds([]); - setImportJsonOpen(true); - }} - onServerExport={onServerExport} - onConnectionInfo={() => setConnectionInfoModalOpen(true)} - onServerSettings={(id) => setSettingsModalTargetId(id)} - onServerEdit={(id) => setConfigModal({ mode: "edit", targetId: id })} - onServerClone={(id) => { - setHighlightedServerIds([]); - setConfigModal({ mode: "clone", targetId: id }); - }} - onServerRemove={(id) => { - const target = servers.find((s) => s.id === id); - if (target) setRemoveTarget(target); - }} - onServerReorder={(orderedIds) => { - // reorderServers reverts the optimistic order via an internal - // refresh() and re-throws on failure (409 from a racing external - // edit, or a network error). Surface that to the user so the drag - // doesn't silently bounce back — matching the toast pattern every - // other mutation here uses. - reorderServers(orderedIds).catch((err: unknown) => { - notifications.show({ - title: "Failed to reorder servers", - message: err instanceof Error ? err.message : String(err), - color: "red", - }); - }); - }} - highlightedServerIds={highlightedServerIds} - onClearHighlight={clearHighlight} - serverSupportsTaskToolCalls={ - !!capabilities?.tasks?.requests?.tools?.call || - (inspectorClient?.isTasksExtensionNegotiated() ?? false) - } - onToolsUiChange={onToolsUiChange} - onCallTool={(name, args, runAsTask) => { - void onCallTool(name, args, runAsTask); - }} - onCancelToolCall={onCancelToolCall} - onClearToolResult={onClearToolResult} - onReadResourceContents={onReadResourceContents} - onRefreshTools={onRefreshTools} - toolsPagination={toolsPaginationControls} - promptsPagination={promptsPaginationControls} - resourcesPagination={resourcesPaginationControls} - onPromptsUiChange={setUi.setPromptsUi} - onGetPrompt={(name, args) => { - void onGetPrompt(name, args); - }} - onRefreshPrompts={onRefreshPrompts} - onResourcesUiChange={setUi.setResourcesUi} - onReadResource={(uri) => { - void onReadResource(uri); - }} - onSubscribeResource={onSubscribeResource} - onUnsubscribeResource={onUnsubscribeResource} - onRefreshResources={onRefreshResources} - onCompleteArgument={onCompleteArgument} - completionsSupported={capabilities?.completions !== undefined} - subscriptionsSupported={capabilities?.resources?.subscribe === true} - onTasksUiChange={setUi.setTasksUi} - onCancelTask={(taskId) => { - void onCancelTask(taskId); - }} - onClearCompletedTasks={onClearCompletedTasks} - onRefreshTasks={onRefreshTasks} - onSetLogLevel={onSetLogLevel} - modernLogLevel={modernLogLevel} - onSetModernLogLevel={onSetModernLogLevel} - onLogsUiChange={setUi.setLogsUi} - onClearLogs={onClearLogs} - onExportLogs={onExportLogs} - onProtocolUiChange={setUi.setProtocolUi} - onClearProtocol={onClearProtocol} - onExportProtocol={onExportProtocol} - onClearProtocolSection={onClearProtocolSection} - onExportProtocolSection={onExportProtocolSection} - onReplayProtocol={onReplayProtocol} - onTogglePinProtocol={togglePinProtocol} - pinnedProtocolIds={pinnedProtocolIds} - onNetworkUiChange={setUi.setNetworkUi} - onClearNetwork={onClearNetwork} - onExportNetwork={onExportNetwork} - onConsoleUiChange={setUi.setConsoleUi} - onClearConsole={onClearConsole} - onExportConsole={onExportConsole} - onAppsUiChange={setUi.setAppsUi} - onSelectApp={onSelectApp} - onOpenApp={(name, args) => { - void onOpenApp(name, args); - }} - onCloseApp={onCloseApp} - onAppError={onAppError} - onRefreshApps={onRefreshTools} + shell={shellProps} + connection={connectionProps} + servers={serverListProps} + tools={toolsPanelProps} + prompts={promptsPanelProps} + resources={resourcesPanelProps} + apps={appsPanelProps} + tasks={tasksPanelProps} + logs={logsPanelProps} + protocol={protocolPanelProps} + network={networkPanelProps} + console={consolePanelProps} /> = { title: "Views/InspectorView", component: InspectorView, parameters: { layout: "fullscreen" }, args: { - // Data - servers: demoServers, - tools: demoTools, - prompts: demoPrompts, - resources: demoResources, - resourceTemplates: demoResourceTemplates, - subscriptions: demoSubscriptions, - logs: demoLogs, - tasks: demoTasks, - progressByTaskId: demoProgressByTaskId, - protocol: demoHistory, - network: demoNetwork, - stderrLogs: demoStderr, - - // Connection state — stories default to "disconnected"; per-story - // overrides drive the connected / error narratives. - activeServer: undefined, - connectionStatus: "disconnected", - initializeResult: undefined, - latencyMs: undefined, - - // Misc state - currentLogLevel: "info", - sandboxPath: "about:blank", - bridgeFactory: noopBridgeFactory, - appRendererRef: { current: null }, - - // Per-screen UI state (search / filter / selection), one object per screen. - toolsUi: EMPTY_TOOLS_UI, - promptsUi: EMPTY_PROMPTS_UI, - resourcesUi: EMPTY_RESOURCES_UI, - appsUi: EMPTY_APPS_UI, - tasksUi: EMPTY_TASKS_UI, - logsUi: EMPTY_LOGS_UI, - protocolUi: EMPTY_PROTOCOL_UI, - networkUi: EMPTY_NETWORK_UI, - consoleUi: EMPTY_CONSOLE_UI, - - // Callbacks — all wired to storybook spies so play functions can assert - // on dispatch. Real wiring routes these to InspectorClient methods (the - // app shell at clients/web/src/App.tsx). - onToggleTheme: fn(), - onOpenClientSettings: fn(), - onToggleConnection: fn(), - onDisconnect: fn(), - onServerAdd: fn(), - onServerImportConfig: fn(), - onServerImportJson: fn(), - onServerExport: fn(), - onConnectionInfo: fn(), - onServerSettings: fn(), - onServerEdit: fn(), - onServerClone: fn(), - onServerRemove: fn(), - onServerReorder: fn(), - serverSupportsTaskToolCalls: false, - onToolsUiChange: fn(), - onCallTool: fn(), - onRefreshTools: fn(), - toolsPagination: noopPagination, - promptsPagination: noopPagination, - resourcesPagination: noopPagination, - onPromptsUiChange: fn(), - onGetPrompt: fn(), - onRefreshPrompts: fn(), - onResourcesUiChange: fn(), - onReadResource: fn(), - onSubscribeResource: fn(), - onUnsubscribeResource: fn(), - onRefreshResources: fn(), - onTasksUiChange: fn(), - onCancelTask: fn(), - onClearCompletedTasks: fn(), - onRefreshTasks: fn(), - onSetLogLevel: fn(), - onLogsUiChange: fn(), - onClearLogs: fn(), - onExportLogs: fn(), - onProtocolUiChange: fn(), - onClearProtocol: fn(), - onExportProtocol: fn(), - onReplayProtocol: fn(), - onTogglePinProtocol: fn(), - onNetworkUiChange: fn(), - onClearNetwork: fn(), - onExportNetwork: fn(), - onConsoleUiChange: fn(), - onClearConsole: fn(), - onExportConsole: fn(), - onAppsUiChange: fn(), - onSelectApp: fn(), - onOpenApp: fn(), - onCloseApp: fn(), - onAppError: fn(), - onRefreshApps: fn(), - activeTab: "Servers", - onActiveTabChange: fn(), + shell: shellArgs, + connection: connectionArgs, + servers: serversArgs, + tools: toolsArgs, + prompts: promptsArgs, + resources: resourcesArgs, + apps: appsArgs, + tasks: tasksArgs, + logs: logsArgs, + protocol: protocolArgs, + network: networkArgs, + console: consoleArgs, }, render: (args) => { - const [activeTab, setActiveTab] = useState(args.activeTab ?? "Servers"); + const [activeTab, setActiveTab] = useState(args.shell.activeTab); return ( ); }, @@ -463,7 +525,7 @@ export const Default: Story = { export const NoServers: Story = { args: { - servers: [], + servers: { ...serversArgs, servers: [] }, }, }; @@ -480,7 +542,7 @@ const manyServers: ServerEntry[] = Array.from({ length: 24 }, (_, i) => ({ export const ManyServers: Story = { args: { - servers: manyServers, + servers: { ...serversArgs, servers: manyServers }, }, play: async ({ canvasElement }) => { // Even under enough content to overflow, the shell stays viewport-clamped @@ -509,10 +571,13 @@ export const ManyServers: Story = { // regression / storybook play function coverage. export const Connected: Story = { args: { - activeServer: demoServers[0]!.id, - connectionStatus: "connected", - initializeResult: demoInitializeResult, - latencyMs: 142, + connection: { + ...connectionArgs, + activeServer: demoServers[0]!.id, + connectionStatus: "connected", + initializeResult: demoInitializeResult, + latencyMs: 142, + }, }, }; @@ -524,10 +589,13 @@ export const ConnectionError: Story = { // connect-attempt-failure signal that gates the failure column; `network: []` // (and empty Protocol) keep it a pure stdio failure so only Console is offered. args: { - activeServer: demoServers[0]!.id, - erroredServerId: demoServers[0]!.id, - connectionStatus: "error", - network: [], - stderrLogs: demoStderr, + connection: { + ...connectionArgs, + activeServer: demoServers[0]!.id, + erroredServerId: demoServers[0]!.id, + connectionStatus: "error", + }, + network: { ...networkArgs, network: [] }, + console: { ...consoleArgs, stderrLogs: demoStderr }, }, }; diff --git a/clients/web/src/components/views/InspectorView/InspectorView.test.tsx b/clients/web/src/components/views/InspectorView/InspectorView.test.tsx index fc34c4bba..20fd24db3 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.test.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.test.tsx @@ -59,117 +59,175 @@ const noopBridgeFactory: BridgeFactory = () => close: async () => {}, }) as unknown as AppBridge; +/** + * Per-bundle overrides. Each key takes a `Partial` of that bundle, so a test + * names only the field it cares about — `makeProps({ tools: { tools: [t] } })` + * — and inherits the rest of the bundle's defaults. + */ +type PropOverrides = { + [K in keyof InspectorViewProps]?: Partial; +}; + +// Merges one bundle across every supplied override layer, later layers +// winning. Kept generic (rather than a spread over `Object.assign`) so each +// bundle stays typed to its own shape. +function mergeBundle( + key: K, + layers: PropOverrides[], +): Partial { + let merged: Partial = {}; + for (const layer of layers) { + const part = layer[key]; + if (part) merged = { ...merged, ...part }; + } + return merged; +} + // Returns a fresh fixture each call so per-test spies can be asserted on // in isolation. The view is purely prop-driven; every callback is // dispatched up to the parent — these spies stand in for App.tsx's // hook-routed handlers in the real wiring. -function makeProps( - overrides: Partial = {}, -): InspectorViewProps { +// +// Takes any number of override layers so a scenario helper can supply its own +// base (see `connectedHttp` below) and still let the caller override on top. +function makeProps(...overrides: PropOverrides[]): InspectorViewProps { return { - servers: [], - activeServer: undefined, - connectionStatus: "disconnected", - initializeResult: undefined, - latencyMs: undefined, - tools: [], - prompts: [], - resources: [], - resourceTemplates: [], - toolsListChanged: false, - promptsListChanged: false, - resourcesListChanged: false, - subscriptions: [], - logs: [], - tasks: [], - protocol: [], - network: [], - stderrLogs: [], - currentLogLevel: "info", - sandboxPath: "about:blank", - bridgeFactory: noopBridgeFactory, - appRendererRef: { current: null }, - toolsUi: EMPTY_TOOLS_UI, - promptsUi: EMPTY_PROMPTS_UI, - resourcesUi: EMPTY_RESOURCES_UI, - appsUi: EMPTY_APPS_UI, - tasksUi: EMPTY_TASKS_UI, - logsUi: EMPTY_LOGS_UI, - protocolUi: EMPTY_PROTOCOL_UI, - networkUi: EMPTY_NETWORK_UI, - consoleUi: EMPTY_CONSOLE_UI, - onToggleTheme: vi.fn(), - onOpenClientSettings: vi.fn(), - onToggleConnection: vi.fn(), - onDisconnect: vi.fn(), - onServerAdd: vi.fn(), - onServerImportConfig: vi.fn(), - onServerImportJson: vi.fn(), - onServerExport: vi.fn(), - onConnectionInfo: vi.fn(), - onServerSettings: vi.fn(), - onServerEdit: vi.fn(), - onServerClone: vi.fn(), - onServerRemove: vi.fn(), - onServerReorder: vi.fn(), - serverSupportsTaskToolCalls: false, - onToolsUiChange: vi.fn(), - onCallTool: vi.fn(), - onRefreshTools: vi.fn(), - toolsPagination: noopPagination, - promptsPagination: noopPagination, - resourcesPagination: noopPagination, - onPromptsUiChange: vi.fn(), - onGetPrompt: vi.fn(), - onRefreshPrompts: vi.fn(), - onResourcesUiChange: vi.fn(), - onReadResource: vi.fn(), - onSubscribeResource: vi.fn(), - onUnsubscribeResource: vi.fn(), - onRefreshResources: vi.fn(), - onTasksUiChange: vi.fn(), - onCancelTask: vi.fn(), - onClearCompletedTasks: vi.fn(), - onRefreshTasks: vi.fn(), - onSetLogLevel: vi.fn(), - onLogsUiChange: vi.fn(), - onClearLogs: vi.fn(), - onExportLogs: vi.fn(), - onProtocolUiChange: vi.fn(), - onClearProtocol: vi.fn(), - onExportProtocol: vi.fn(), - onClearProtocolSection: vi.fn(), - onExportProtocolSection: vi.fn(), - onReplayProtocol: vi.fn(), - onTogglePinProtocol: vi.fn(), - onNetworkUiChange: vi.fn(), - onClearNetwork: vi.fn(), - onExportNetwork: vi.fn(), - onConsoleUiChange: vi.fn(), - onClearConsole: vi.fn(), - onExportConsole: vi.fn(), - onAppsUiChange: vi.fn(), - onSelectApp: vi.fn(), - onOpenApp: vi.fn(), - onCloseApp: vi.fn(), - onAppError: vi.fn(), - onRefreshApps: vi.fn(), - activeTab: "Servers", - onActiveTabChange: vi.fn(), - ...overrides, + shell: { + activeTab: "Servers", + onActiveTabChange: vi.fn(), + onToggleTheme: vi.fn(), + onOpenClientSettings: vi.fn(), + ...mergeBundle("shell", overrides), + }, + connection: { + activeServer: undefined, + connectionStatus: "disconnected", + initializeResult: undefined, + latencyMs: undefined, + onToggleConnection: vi.fn(), + onDisconnect: vi.fn(), + ...mergeBundle("connection", overrides), + }, + servers: { + servers: [], + onServerAdd: vi.fn(), + onServerImportConfig: vi.fn(), + onServerImportJson: vi.fn(), + onServerExport: vi.fn(), + onConnectionInfo: vi.fn(), + onServerSettings: vi.fn(), + onServerEdit: vi.fn(), + onServerClone: vi.fn(), + onServerRemove: vi.fn(), + onServerReorder: vi.fn(), + ...mergeBundle("servers", overrides), + }, + tools: { + tools: [], + toolsListChanged: false, + toolsUi: EMPTY_TOOLS_UI, + toolsPagination: noopPagination, + serverSupportsTaskToolCalls: false, + onToolsUiChange: vi.fn(), + onCallTool: vi.fn(), + onRefreshTools: vi.fn(), + ...mergeBundle("tools", overrides), + }, + prompts: { + prompts: [], + promptsListChanged: false, + promptsUi: EMPTY_PROMPTS_UI, + promptsPagination: noopPagination, + onPromptsUiChange: vi.fn(), + onGetPrompt: vi.fn(), + onRefreshPrompts: vi.fn(), + ...mergeBundle("prompts", overrides), + }, + resources: { + resources: [], + resourceTemplates: [], + subscriptions: [], + resourcesListChanged: false, + resourcesUi: EMPTY_RESOURCES_UI, + resourcesPagination: noopPagination, + onResourcesUiChange: vi.fn(), + onReadResource: vi.fn(), + onSubscribeResource: vi.fn(), + onUnsubscribeResource: vi.fn(), + onRefreshResources: vi.fn(), + ...mergeBundle("resources", overrides), + }, + apps: { + appsUi: EMPTY_APPS_UI, + sandboxPath: "about:blank", + bridgeFactory: noopBridgeFactory, + appRendererRef: { current: null }, + onAppsUiChange: vi.fn(), + onSelectApp: vi.fn(), + onOpenApp: vi.fn(), + onCloseApp: vi.fn(), + onAppError: vi.fn(), + onRefreshApps: vi.fn(), + ...mergeBundle("apps", overrides), + }, + tasks: { + tasks: [], + tasksUi: EMPTY_TASKS_UI, + onTasksUiChange: vi.fn(), + onCancelTask: vi.fn(), + onClearCompletedTasks: vi.fn(), + onRefreshTasks: vi.fn(), + ...mergeBundle("tasks", overrides), + }, + logs: { + logs: [], + logsUi: EMPTY_LOGS_UI, + currentLogLevel: "info", + onSetLogLevel: vi.fn(), + onLogsUiChange: vi.fn(), + onClearLogs: vi.fn(), + onExportLogs: vi.fn(), + ...mergeBundle("logs", overrides), + }, + protocol: { + protocol: [], + protocolUi: EMPTY_PROTOCOL_UI, + onProtocolUiChange: vi.fn(), + onClearProtocol: vi.fn(), + onExportProtocol: vi.fn(), + onClearProtocolSection: vi.fn(), + onExportProtocolSection: vi.fn(), + onReplayProtocol: vi.fn(), + onTogglePinProtocol: vi.fn(), + ...mergeBundle("protocol", overrides), + }, + network: { + network: [], + networkUi: EMPTY_NETWORK_UI, + onNetworkUiChange: vi.fn(), + onClearNetwork: vi.fn(), + onExportNetwork: vi.fn(), + ...mergeBundle("network", overrides), + }, + console: { + stderrLogs: [], + consoleUi: EMPTY_CONSOLE_UI, + onConsoleUiChange: vi.fn(), + onClearConsole: vi.fn(), + onExportConsole: vi.fn(), + ...mergeBundle("console", overrides), + }, }; } function StatefulInspectorViewHost(props: InspectorViewProps) { - const [activeTab, setActiveTab] = useState(props.activeTab ?? "Servers"); - const [appsUi, setAppsUi] = useState(props.appsUi ?? EMPTY_APPS_UI); + const [activeTab, setActiveTab] = useState(props.shell.activeTab); + const [appsUi, setAppsUi] = useState(props.apps.appsUi); return ( ); } @@ -244,14 +302,26 @@ describe("InspectorView", () => { it("renders the server card from the input list", () => { renderWithMantine( - , + , ); expect(screen.getByText("Alpha")).toBeInTheDocument(); }); it("renders the footer row with the version and copyright (#1682)", () => { renderWithMantine( - , + , ); expect(screen.getByText("v9.9.9")).toBeInTheDocument(); expect( @@ -264,7 +334,14 @@ describe("InspectorView", () => { const user = userEvent.setup({ delay: null }); renderWithMantine( , ); await user.click(screen.getByRole("switch")); @@ -275,10 +352,16 @@ describe("InspectorView", () => { renderWithMantine( , ); @@ -295,9 +378,15 @@ describe("InspectorView", () => { renderWithMantine( , ); @@ -311,11 +400,15 @@ describe("InspectorView", () => { renderWithMantine( , ); @@ -336,12 +429,16 @@ describe("InspectorView", () => { renderWithMantine( , @@ -358,12 +455,16 @@ describe("InspectorView", () => { renderWithMantine( , @@ -381,12 +482,16 @@ describe("InspectorView", () => { renderWithMantine( , @@ -405,12 +510,16 @@ describe("InspectorView", () => { renderWithMantine( , @@ -427,10 +536,14 @@ describe("InspectorView", () => { renderWithMantine( , ); @@ -443,10 +556,14 @@ describe("InspectorView", () => { renderWithMantine( , ); @@ -462,10 +579,14 @@ describe("InspectorView", () => { renderWithMantine( , ); @@ -482,11 +603,15 @@ describe("InspectorView", () => { const { rerender } = renderWithMantine( , ); @@ -501,9 +626,13 @@ describe("InspectorView", () => { rerender( , ); @@ -530,10 +659,14 @@ describe("InspectorView", () => { renderWithMantine( , ); @@ -560,10 +693,14 @@ describe("InspectorView", () => { renderWithMantine( , ); @@ -576,13 +713,19 @@ describe("InspectorView", () => { renderWithMantine( , ); @@ -599,11 +742,17 @@ describe("InspectorView", () => { renderWithMantine( , ); @@ -615,10 +764,14 @@ describe("InspectorView", () => { renderWithMantine( , ); @@ -632,10 +785,14 @@ describe("InspectorView", () => { renderWithMantine( , ); @@ -648,11 +805,15 @@ describe("InspectorView", () => { renderWithMantine( , ); @@ -668,13 +829,19 @@ describe("InspectorView", () => { renderWithMantine( , ); @@ -701,12 +868,18 @@ describe("InspectorView", () => { renderWithMantine( , ); @@ -727,12 +900,18 @@ describe("InspectorView", () => { renderWithMantine( , ); @@ -746,11 +925,17 @@ describe("InspectorView", () => { renderWithMantine( , ); @@ -768,11 +953,17 @@ describe("InspectorView", () => { const { rerender } = renderWithMantine( , ); @@ -784,11 +975,17 @@ describe("InspectorView", () => { rerender( , ); @@ -803,21 +1000,31 @@ describe("InspectorView", () => { renderWithMantine( , @@ -843,21 +1050,29 @@ describe("InspectorView", () => { renderWithMantine( , @@ -900,22 +1115,30 @@ describe("InspectorView", () => { renderWithMantine( , @@ -935,20 +1158,28 @@ describe("InspectorView", () => { renderWithMantine( , @@ -965,11 +1196,17 @@ describe("InspectorView", () => { const { rerender } = renderWithMantine( , ); @@ -985,11 +1222,17 @@ describe("InspectorView", () => { rerender( , ); @@ -1003,14 +1246,20 @@ describe("InspectorView", () => { renderWithMantine(