diff --git a/clients/web/src/App.test.tsx b/clients/web/src/App.test.tsx index 9e626f9f..49beb743 100644 --- a/clients/web/src/App.test.tsx +++ b/clients/web/src/App.test.tsx @@ -450,122 +450,62 @@ 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 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. */} - - + + - {(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"; diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index 91bf2c29..f005b69f 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"; @@ -1469,6 +1483,275 @@ 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), + [], + ); + + // The seven wrappers below discard a promise the callee already owns — each + // of these handlers ends in its own `catch` that surfaces the failure as a + // toast or a panel error — and the view's props are synchronous. `void` is + // the explicit discard `@typescript-eslint/no-floating-promises` requires. + const dispatchToggleConnection = useCallback( + (id: string) => { + void onToggleConnection(id); + }, + [onToggleConnection], + ); + const dispatchDisconnect = useCallback(() => { + void onDisconnect(); + }, [onDisconnect]); + const dispatchCallTool = useCallback( + (name: string, args: Record, runAsTask?: boolean) => { + void onCallTool(name, args, runAsTask); + }, + [onCallTool], + ); + const dispatchGetPrompt = useCallback( + (name: string, args: Record) => { + void onGetPrompt(name, args); + }, + [onGetPrompt], + ); + const dispatchReadResource = useCallback( + (uri: string) => { + void onReadResource(uri); + }, + [onReadResource], + ); + const dispatchCancelTask = useCallback( + (taskId: string) => { + void onCancelTask(taskId); + }, + [onCancelTask], + ); + const dispatchOpenApp = useCallback( + (name: string, args: Record) => { + void onOpenApp(name, args); + }, + [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 +1767,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 fc34c4bb..e152fad8 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.test.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.test.tsx @@ -59,117 +59,179 @@ 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: { 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 +306,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 +338,14 @@ describe("InspectorView", () => { const user = userEvent.setup({ delay: null }); renderWithMantine( , ); await user.click(screen.getByRole("switch")); @@ -275,10 +356,16 @@ describe("InspectorView", () => { renderWithMantine( , ); @@ -295,9 +382,15 @@ describe("InspectorView", () => { renderWithMantine( , ); @@ -311,11 +404,15 @@ describe("InspectorView", () => { renderWithMantine( , ); @@ -336,12 +433,16 @@ describe("InspectorView", () => { renderWithMantine( , @@ -358,12 +459,16 @@ describe("InspectorView", () => { renderWithMantine( , @@ -381,12 +486,16 @@ describe("InspectorView", () => { renderWithMantine( , @@ -405,12 +514,16 @@ describe("InspectorView", () => { renderWithMantine( , @@ -427,10 +540,14 @@ describe("InspectorView", () => { renderWithMantine( , ); @@ -443,10 +560,14 @@ describe("InspectorView", () => { renderWithMantine( , ); @@ -462,10 +583,14 @@ describe("InspectorView", () => { renderWithMantine( , ); @@ -482,11 +607,15 @@ describe("InspectorView", () => { const { rerender } = renderWithMantine( , ); @@ -501,9 +630,13 @@ describe("InspectorView", () => { rerender( , ); @@ -530,10 +663,14 @@ describe("InspectorView", () => { renderWithMantine( , ); @@ -560,10 +697,14 @@ describe("InspectorView", () => { renderWithMantine( , ); @@ -576,13 +717,19 @@ describe("InspectorView", () => { renderWithMantine( , ); @@ -599,11 +746,17 @@ describe("InspectorView", () => { renderWithMantine( , ); @@ -615,10 +768,14 @@ describe("InspectorView", () => { renderWithMantine( , ); @@ -632,10 +789,14 @@ describe("InspectorView", () => { renderWithMantine( , ); @@ -648,11 +809,15 @@ describe("InspectorView", () => { renderWithMantine( , ); @@ -668,13 +833,19 @@ describe("InspectorView", () => { renderWithMantine( , ); @@ -701,12 +872,18 @@ describe("InspectorView", () => { renderWithMantine( , ); @@ -727,12 +904,18 @@ describe("InspectorView", () => { renderWithMantine( , ); @@ -746,11 +929,17 @@ describe("InspectorView", () => { renderWithMantine( , ); @@ -768,11 +957,17 @@ describe("InspectorView", () => { const { rerender } = renderWithMantine( , ); @@ -784,11 +979,17 @@ describe("InspectorView", () => { rerender( , ); @@ -803,21 +1004,31 @@ describe("InspectorView", () => { renderWithMantine( , @@ -843,21 +1054,29 @@ describe("InspectorView", () => { renderWithMantine( , @@ -900,22 +1119,30 @@ describe("InspectorView", () => { renderWithMantine( , @@ -935,20 +1162,28 @@ describe("InspectorView", () => { renderWithMantine( , @@ -965,11 +1200,17 @@ describe("InspectorView", () => { const { rerender } = renderWithMantine( , ); @@ -985,11 +1226,17 @@ describe("InspectorView", () => { rerender( , ); @@ -1003,14 +1250,20 @@ describe("InspectorView", () => { renderWithMantine(