From cfc2c41dd3840b203befb828a18d175b220959a5 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Sun, 22 Mar 2026 15:19:08 +0000 Subject: [PATCH 1/3] Decouple immersive review story from app meta --- .../CodeReview/ImmersiveReviewView.stories.tsx | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/browser/features/RightSidebar/CodeReview/ImmersiveReviewView.stories.tsx b/src/browser/features/RightSidebar/CodeReview/ImmersiveReviewView.stories.tsx index fced2df652..b726e25d98 100644 --- a/src/browser/features/RightSidebar/CodeReview/ImmersiveReviewView.stories.tsx +++ b/src/browser/features/RightSidebar/CodeReview/ImmersiveReviewView.stories.tsx @@ -1,4 +1,5 @@ import { within, waitFor } from "@storybook/test"; +import type { Meta, StoryObj } from "@storybook/react-vite"; import { useRef, useState } from "react"; import type { ComponentProps, ComponentType, FC, ReactNode } from "react"; @@ -6,7 +7,6 @@ import { TooltipProvider } from "@/browser/components/Tooltip/Tooltip"; import { APIProvider, type APIClient } from "@/browser/contexts/API"; import { ExperimentsProvider } from "@/browser/contexts/ExperimentsContext"; import { ThemeProvider } from "@/browser/contexts/ThemeContext"; -import { appMeta, type AppStory } from "@/browser/stories/meta.js"; import { createMockORPCClient } from "@/browser/stories/mocks/orpc"; import { createReview } from "@/browser/stories/storyHelpers.js"; import type { DiffHunk, Review } from "@/common/types/review"; @@ -339,8 +339,7 @@ function ImmersiveReviewStory(props: ImmersiveReviewStoryProps) { ); } -const meta = { - ...appMeta, +const meta: Meta = { title: "Features/RightSidebar/CodeReview/ImmersiveReviewView", component: ImmersiveReviewView, decorators: [ @@ -351,9 +350,9 @@ const meta = { ), ], parameters: { - ...appMeta.parameters, + layout: "fullscreen", chromatic: { - ...(appMeta.parameters?.chromatic ?? {}), + delay: 500, modes: { dark: { theme: "dark", viewport: 1600 }, light: { theme: "light", viewport: 1600 }, @@ -364,7 +363,7 @@ const meta = { export default meta; -type Story = AppStory; +type Story = StoryObj; export const ReviewTabImmersiveLineHeightDebug: Story = { render: () => ( From a8e995c4d7b00bb6eda720055bcb1e0bfc7479ac Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Sun, 22 Mar 2026 15:33:57 +0000 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=A4=96=20tests:=20split=20MCP=20stori?= =?UTF-8?q?es=20into=20colocated=20component=20files?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../WorkspaceMCPModal.stories.tsx | 394 ++++++++ .../Sections/MCPSettingsSection.stories.tsx | 420 +++++++++ .../stories/App.projectSettings.stories.tsx | 866 ------------------ 3 files changed, 814 insertions(+), 866 deletions(-) create mode 100644 src/browser/components/WorkspaceMCPModal/WorkspaceMCPModal.stories.tsx create mode 100644 src/browser/features/Settings/Sections/MCPSettingsSection.stories.tsx delete mode 100644 src/browser/stories/App.projectSettings.stories.tsx diff --git a/src/browser/components/WorkspaceMCPModal/WorkspaceMCPModal.stories.tsx b/src/browser/components/WorkspaceMCPModal/WorkspaceMCPModal.stories.tsx new file mode 100644 index 0000000000..a57add7816 --- /dev/null +++ b/src/browser/components/WorkspaceMCPModal/WorkspaceMCPModal.stories.tsx @@ -0,0 +1,394 @@ +import { useRef } from "react"; +import type { FC, ReactNode } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, userEvent, waitFor, within } from "@storybook/test"; + +import { TooltipProvider } from "@/browser/components/Tooltip/Tooltip"; +import { APIProvider, type APIClient } from "@/browser/contexts/API"; +import { ExperimentsProvider } from "@/browser/contexts/ExperimentsContext"; +import { RouterProvider } from "@/browser/contexts/RouterContext"; +import { SettingsProvider } from "@/browser/contexts/SettingsContext"; +import { ThemeProvider } from "@/browser/contexts/ThemeContext"; +import { updatePersistedState } from "@/browser/hooks/usePersistedState"; +import { createWorkspace, groupWorkspacesByProject } from "@/browser/stories/mockFactory"; +import { createMockORPCClient } from "@/browser/stories/mocks/orpc"; +import { getMCPTestResultsKey } from "@/common/constants/storage"; +import type { MCPServerInfo } from "@/common/types/mcp"; + +import { WorkspaceMCPModal } from "./WorkspaceMCPModal"; + +const PROJECT_PATH = "/Users/test/my-app"; +const WORKSPACE_ID = "ws-mcp-test"; + +const MOCK_TOOLS = [ + "file_read", + "file_write", + "bash", + "web_search", + "web_fetch", + "todo_write", + "todo_read", + "status_set", +]; + +const POSTHOG_TOOLS = [ + "add-insight-to-dashboard", + "dashboard-create", + "dashboard-delete", + "dashboard-get", + "dashboards-get-all", + "dashboard-update", + "docs-search", + "error-details", + "list-errors", + "create-feature-flag", + "delete-feature-flag", + "feature-flag-get-all", + "experiment-get-all", + "experiment-create", +]; + +const PROJECT_MCP_CACHE_KEY = getMCPTestResultsKey(PROJECT_PATH); + +interface WorkspaceMCPStoryOptions { + servers?: Record; + workspaceOverrides?: { + disabledServers?: string[]; + enabledServers?: string[]; + toolAllowlist?: Record; + }; + testResults?: Record; + preCacheTools?: boolean; +} + +function setupWorkspaceMCPModalStory(options: WorkspaceMCPStoryOptions = {}): APIClient { + // User rationale: these stories should render the modal in isolation and keep + // MCP tool cache deterministic per scenario instead of reusing app-shell state. + updatePersistedState(PROJECT_MCP_CACHE_KEY, {}); + + if (options.preCacheTools && options.testResults) { + const cachedResults: Record< + string, + { + result: { success: true; tools: string[] }; + testedAt: number; + } + > = {}; + + for (const [serverName, tools] of Object.entries(options.testResults)) { + cachedResults[serverName] = { + result: { success: true, tools }, + testedAt: Date.now(), + }; + } + + updatePersistedState(PROJECT_MCP_CACHE_KEY, cachedResults); + } + + const workspaces = [ + createWorkspace({ + id: WORKSPACE_ID, + name: "main", + projectName: "my-app", + projectPath: PROJECT_PATH, + }), + ]; + + const mcpServers = new Map>(); + if (options.servers) { + mcpServers.set(PROJECT_PATH, options.servers); + } + + const mcpOverrides = new Map< + string, + { + disabledServers?: string[]; + enabledServers?: string[]; + toolAllowlist?: Record; + } + >(); + if (options.workspaceOverrides) { + mcpOverrides.set(WORKSPACE_ID, options.workspaceOverrides); + } + + const mcpTestResults = new Map(); + if (options.testResults) { + for (const [serverName, tools] of Object.entries(options.testResults)) { + mcpTestResults.set(serverName, { success: true, tools }); + } + } + + return createMockORPCClient({ + projects: groupWorkspacesByProject(workspaces), + workspaces, + mcpServers, + mcpOverrides, + mcpTestResults, + }); +} + +const WorkspaceMCPModalStoryShell: FC<{ setup: () => APIClient; children: ReactNode }> = ({ + setup, + children, +}) => { + const clientRef = useRef(null); + clientRef.current ??= setup(); + + return ( + + + + + + {children} + + + + + + ); +}; + +function renderWorkspaceMCPModal(options: WorkspaceMCPStoryOptions): JSX.Element { + return ( + setupWorkspaceMCPModalStory(options)}> + { + // Keep modal visible for interaction stories. + }} + /> + + ); +} + +function queryWorkspaceMCPDialog(ownerDocument: Document): HTMLElement | null { + const dialog = Array.from(ownerDocument.querySelectorAll('[role="dialog"]')).find((element) => + element.textContent?.includes("Workspace MCP Configuration") + ); + + return dialog instanceof HTMLElement ? dialog : null; +} + +async function findWorkspaceMCPDialog(canvasElement: HTMLElement): Promise { + return waitFor( + () => { + const dialog = queryWorkspaceMCPDialog(canvasElement.ownerDocument); + if (!dialog) { + throw new Error("Workspace MCP dialog not found"); + } + return dialog; + }, + { timeout: 10000 } + ); +} + +const meta: Meta = { + title: "Components/WorkspaceMCPModal", + component: WorkspaceMCPModal, + parameters: { + layout: "fullscreen", + chromatic: { + delay: 500, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const WorkspaceMCPNoOverrides: Story = { + render: () => + renderWorkspaceMCPModal({ + servers: { + mux: { transport: "stdio", command: "npx -y @anthropics/mux-server", disabled: false }, + posthog: { transport: "stdio", command: "npx -y posthog-mcp-server", disabled: false }, + }, + testResults: { + mux: MOCK_TOOLS, + posthog: POSTHOG_TOOLS, + }, + preCacheTools: true, + }), + play: async ({ canvasElement }) => { + const modal = within(await findWorkspaceMCPDialog(canvasElement)); + + await expect(modal.findByText("mux")).resolves.toBeInTheDocument(); + await expect(modal.findByText("posthog")).resolves.toBeInTheDocument(); + }, +}; + +export const WorkspaceMCPProjectDisabledServer: Story = { + render: () => + renderWorkspaceMCPModal({ + servers: { + mux: { transport: "stdio", command: "npx -y @anthropics/mux-server", disabled: false }, + posthog: { transport: "stdio", command: "npx -y posthog-mcp-server", disabled: true }, + }, + testResults: { + mux: MOCK_TOOLS, + posthog: POSTHOG_TOOLS, + }, + preCacheTools: true, + }), + play: async ({ canvasElement }) => { + const modal = within(await findWorkspaceMCPDialog(canvasElement)); + + await expect(modal.findByText("posthog")).resolves.toBeInTheDocument(); + await expect(modal.findByText(/disabled at project level/i)).resolves.toBeInTheDocument(); + }, +}; + +export const WorkspaceMCPEnabledOverride: Story = { + render: () => + renderWorkspaceMCPModal({ + servers: { + mux: { transport: "stdio", command: "npx -y @anthropics/mux-server", disabled: false }, + posthog: { transport: "stdio", command: "npx -y posthog-mcp-server", disabled: true }, + }, + workspaceOverrides: { + enabledServers: ["posthog"], + }, + testResults: { + mux: MOCK_TOOLS, + posthog: POSTHOG_TOOLS, + }, + preCacheTools: true, + }), + play: async ({ canvasElement }) => { + const modal = within(await findWorkspaceMCPDialog(canvasElement)); + + await expect(modal.findByText("posthog")).resolves.toBeInTheDocument(); + await expect(modal.findByText(/disabled at project level/i)).resolves.toBeInTheDocument(); + + const posthogSwitch = await modal.findByRole("switch", { + name: /toggle posthog mcp server/i, + }); + await expect(posthogSwitch).toHaveAttribute("aria-checked", "true"); + }, +}; + +export const WorkspaceMCPDisabledOverride: Story = { + render: () => + renderWorkspaceMCPModal({ + servers: { + mux: { transport: "stdio", command: "npx -y @anthropics/mux-server", disabled: false }, + posthog: { transport: "stdio", command: "npx -y posthog-mcp-server", disabled: false }, + }, + workspaceOverrides: { + disabledServers: ["posthog"], + }, + testResults: { + mux: MOCK_TOOLS, + posthog: POSTHOG_TOOLS, + }, + preCacheTools: true, + }), + play: async ({ canvasElement }) => { + const modal = within(await findWorkspaceMCPDialog(canvasElement)); + + await expect(modal.findByText("mux")).resolves.toBeInTheDocument(); + await expect(modal.findByText("posthog")).resolves.toBeInTheDocument(); + + const posthogSwitch = await modal.findByRole("switch", { + name: /toggle posthog mcp server/i, + }); + await expect(posthogSwitch).toHaveAttribute("aria-checked", "false"); + }, +}; + +export const WorkspaceMCPWithToolAllowlist: Story = { + render: () => + renderWorkspaceMCPModal({ + servers: { + posthog: { transport: "stdio", command: "npx -y posthog-mcp-server", disabled: false }, + }, + workspaceOverrides: { + toolAllowlist: { + posthog: ["docs-search", "error-details", "list-errors"], + }, + }, + testResults: { + posthog: POSTHOG_TOOLS, + }, + preCacheTools: true, + }), + play: async ({ canvasElement }) => { + const modal = within(await findWorkspaceMCPDialog(canvasElement)); + + await expect(modal.findByText("posthog")).resolves.toBeInTheDocument(); + await expect(modal.findByText(/3 of 14 tools enabled/i)).resolves.toBeInTheDocument(); + }, +}; + +export const ToolSelectorInteraction: Story = { + render: () => + renderWorkspaceMCPModal({ + servers: { + mux: { transport: "stdio", command: "npx -y @anthropics/mux-server", disabled: false }, + }, + testResults: { + mux: MOCK_TOOLS, + }, + preCacheTools: true, + }), + play: async ({ canvasElement }) => { + const modal = within(await findWorkspaceMCPDialog(canvasElement)); + + const allButton = await modal.findByRole("button", { name: /^All$/i }); + await expect(allButton).toBeDisabled(); + + const noneButton = await modal.findByRole("button", { name: /^None$/i }); + await userEvent.click(noneButton); + + await waitFor(() => { + return expect(noneButton).toBeDisabled(); + }); + + modal.getByText((_content: string, element: Element | null) => { + const normalizedText = (element?.textContent ?? "").replace(/\s+/g, " ").trim(); + return /^0 of \d+ tools enabled$/i.test(normalizedText); + }); + + const allButtonAfterNone = await modal.findByRole("button", { name: /^All$/i }); + await expect(allButtonAfterNone).toBeEnabled(); + + await userEvent.click(allButtonAfterNone); + + await waitFor(() => { + return expect(allButtonAfterNone).toBeDisabled(); + }); + }, +}; + +export const ToggleServerEnabled: Story = { + render: () => + renderWorkspaceMCPModal({ + servers: { + mux: { transport: "stdio", command: "npx -y @anthropics/mux-server", disabled: false }, + posthog: { transport: "stdio", command: "npx -y posthog-mcp-server", disabled: false }, + }, + testResults: { + mux: MOCK_TOOLS, + posthog: POSTHOG_TOOLS, + }, + preCacheTools: true, + }), + play: async ({ canvasElement }) => { + const modal = within(await findWorkspaceMCPDialog(canvasElement)); + + const posthogSwitch = await modal.findByRole("switch", { + name: /toggle posthog mcp server/i, + }); + + await expect(posthogSwitch).toHaveAttribute("aria-checked", "true"); + await userEvent.click(posthogSwitch); + + await waitFor(() => { + return expect(posthogSwitch).toHaveAttribute("aria-checked", "false"); + }); + }, +}; diff --git a/src/browser/features/Settings/Sections/MCPSettingsSection.stories.tsx b/src/browser/features/Settings/Sections/MCPSettingsSection.stories.tsx new file mode 100644 index 0000000000..5171d848d5 --- /dev/null +++ b/src/browser/features/Settings/Sections/MCPSettingsSection.stories.tsx @@ -0,0 +1,420 @@ +import { useEffect, useRef } from "react"; +import type { FC, ReactNode } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, userEvent, within } from "@storybook/test"; + +import { TooltipProvider } from "@/browser/components/Tooltip/Tooltip"; +import { APIProvider, type APIClient } from "@/browser/contexts/API"; +import { ExperimentsProvider } from "@/browser/contexts/ExperimentsContext"; +import { PolicyProvider } from "@/browser/contexts/PolicyContext"; +import { ThemeProvider } from "@/browser/contexts/ThemeContext"; +import { updatePersistedState } from "@/browser/hooks/usePersistedState"; +import { createMockORPCClient } from "@/browser/stories/mocks/orpc"; +import { getMCPTestResultsKey } from "@/common/constants/storage"; +import type { MCPServerInfo } from "@/common/types/mcp"; +import type { MCPOAuthAuthStatus } from "@/common/types/mcpOauth"; +import type { Secret } from "@/common/types/secrets"; + +import { MCPSettingsSection } from "./MCPSettingsSection"; + +const MOCK_TOOLS = [ + "file_read", + "file_write", + "bash", + "web_search", + "web_fetch", + "todo_write", + "todo_read", + "status_set", +]; + +const POSTHOG_TOOLS = [ + "add-insight-to-dashboard", + "dashboard-create", + "dashboard-delete", + "dashboard-get", + "dashboards-get-all", + "dashboard-update", + "docs-search", + "error-details", + "list-errors", + "create-feature-flag", + "delete-feature-flag", + "feature-flag-get-all", + "experiment-get-all", + "experiment-create", +]; + +const GLOBAL_MCP_CACHE_KEY = getMCPTestResultsKey("__global__"); + +interface MCPSectionStoryOptions { + servers?: Record; + mcpOauthAuthStatus?: Map; + testResults?: Record; + secrets?: Secret[]; + preCacheTools?: boolean; +} + +function setupMCPSettingsSectionStory(options: MCPSectionStoryOptions = {}): APIClient { + // User rationale: stories should render each scenario directly at the component level, + // without inheriting stale app-shell MCP cache from prior stories. + updatePersistedState(GLOBAL_MCP_CACHE_KEY, {}); + + if (options.preCacheTools && options.testResults) { + const cachedResults: Record< + string, + { + result: { success: true; tools: string[] }; + testedAt: number; + } + > = {}; + + for (const [serverName, tools] of Object.entries(options.testResults)) { + cachedResults[serverName] = { + result: { success: true, tools }, + testedAt: Date.now(), + }; + } + + updatePersistedState(GLOBAL_MCP_CACHE_KEY, cachedResults); + } + + const mcpTestResults = new Map(); + if (options.testResults) { + for (const [serverName, tools] of Object.entries(options.testResults)) { + mcpTestResults.set(serverName, { success: true, tools }); + } + } + + return createMockORPCClient({ + globalMcpServers: options.servers ?? {}, + globalSecrets: options.secrets ?? [], + mcpTestResults, + mcpOauthAuthStatus: options.mcpOauthAuthStatus, + }); +} + +const MCPSettingsSectionStoryShell: FC<{ setup: () => APIClient; children: ReactNode }> = ({ + setup, + children, +}) => { + const clientRef = useRef(null); + clientRef.current ??= setup(); + + return ( + + + + + {children} + + + + + ); +}; + +const withDesktopWindowApi = [ + (Story: FC) => { + const originalApiRef = useRef(window.api); + + window.api = { + platform: "darwin", + versions: { + node: "20.0.0", + chrome: "120.0.0", + electron: "28.0.0", + }, + isRosetta: false, + }; + + useEffect(() => { + const savedApi = originalApiRef.current; + return () => { + window.api = savedApi; + }; + }, []); + + return ; + }, +]; + +const meta: Meta = { + title: "Features/Settings/Sections/MCPSettingsSection", + component: MCPSettingsSection, + parameters: { + layout: "fullscreen", + chromatic: { + delay: 500, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const ProjectSettingsEmpty: Story = { + render: () => ( + setupMCPSettingsSectionStory()}> + + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await canvas.findByText("MCP Servers"); + await canvas.findByText("No MCP servers configured yet."); + }, +}; + +export const ProjectSettingsAddRemoteServerHeaders: Story = { + render: () => ( + + setupMCPSettingsSectionStory({ + secrets: [ + { key: "MCP_TOKEN", value: "abc123" }, + { key: "MCP_TOKEN_DEV", value: "def456" }, + ], + }) + } + > + + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const body = within(canvasElement.ownerDocument.body); + + const addServerSummary = await canvas.findByText(/^Add server$/i); + await userEvent.click(addServerSummary); + + const transportLabel = await canvas.findByText("Transport"); + const transportContainer = transportLabel.closest("div"); + await expect(transportContainer).not.toBeNull(); + + const transportSelect = await within(transportContainer as HTMLElement).findByRole("combobox"); + await userEvent.click(transportSelect); + + const httpOption = await body.findByRole("option", { name: /HTTP \(Streamable\)/i }); + await userEvent.click(httpOption); + + const headersLabel = await canvas.findByText(/HTTP headers \(optional\)/i); + headersLabel.scrollIntoView({ block: "center" }); + + const addHeaderButton = await canvas.findByRole("button", { name: /\+ Add header/i }); + await userEvent.click(addHeaderButton); + + const headerNameInputs = await canvas.findAllByPlaceholderText("Authorization"); + await userEvent.type(headerNameInputs[0], "Authorization"); + + const secretToggles = await canvas.findAllByRole("radio", { name: "Secret" }); + await userEvent.click(secretToggles[0]); + + await expect( + canvas.findByRole("button", { name: /Choose secret/i }) + ).resolves.toBeInTheDocument(); + + const secretValueInput = await canvas.findByPlaceholderText("MCP_TOKEN"); + await userEvent.type(secretValueInput, "MCP_TOKEN"); + + await userEvent.click(addHeaderButton); + + const headerNameInputsAfterSecond = canvas.getAllByPlaceholderText("Authorization"); + await userEvent.type(headerNameInputsAfterSecond[1], "X-Env"); + + const textValueInput = await canvas.findByPlaceholderText("value"); + await userEvent.type(textValueInput, "prod"); + + await expect(body.findByDisplayValue("Authorization")).resolves.toBeInTheDocument(); + await expect(body.findByDisplayValue("MCP_TOKEN")).resolves.toBeInTheDocument(); + await expect(body.findByDisplayValue("X-Env")).resolves.toBeInTheDocument(); + await expect(body.findByDisplayValue("prod")).resolves.toBeInTheDocument(); + }, +}; + +export const ProjectSettingsWithServers: Story = { + render: () => ( + + setupMCPSettingsSectionStory({ + servers: { + mux: { transport: "stdio", command: "npx -y @anthropics/mux-server", disabled: false }, + posthog: { transport: "stdio", command: "npx -y posthog-mcp-server", disabled: false }, + filesystem: { + transport: "stdio", + command: "npx -y @anthropics/filesystem-server /tmp", + disabled: false, + }, + }, + testResults: { + mux: MOCK_TOOLS, + posthog: POSTHOG_TOOLS, + filesystem: ["read_file", "write_file", "list_directory"], + }, + preCacheTools: true, + }) + } + > + + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await canvas.findByText("mux"); + await canvas.findByText("posthog"); + await canvas.findByText("filesystem"); + }, +}; + +export const ProjectSettingsMixedState: Story = { + render: () => ( + + setupMCPSettingsSectionStory({ + servers: { + mux: { transport: "stdio", command: "npx -y @anthropics/mux-server", disabled: false }, + posthog: { transport: "stdio", command: "npx -y posthog-mcp-server", disabled: true }, + filesystem: { + transport: "stdio", + command: "npx -y @anthropics/filesystem-server /tmp", + disabled: false, + }, + }, + testResults: { + mux: MOCK_TOOLS, + posthog: POSTHOG_TOOLS, + filesystem: ["read_file", "write_file", "list_directory"], + }, + preCacheTools: true, + }) + } + > + + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await canvas.findByText("posthog"); + await canvas.findByText("disabled"); + }, +}; + +export const ProjectSettingsWithToolAllowlist: Story = { + render: () => ( + + setupMCPSettingsSectionStory({ + servers: { + mux: { + transport: "stdio", + command: "npx -y @anthropics/mux-server", + disabled: false, + toolAllowlist: ["file_read", "file_write", "bash"], + }, + }, + testResults: { + mux: MOCK_TOOLS, + }, + preCacheTools: true, + }) + } + > + + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await canvas.findByText("mux"); + await canvas.findByText(/3\/8/); + }, +}; + +export const ProjectSettingsOAuthNotLoggedIn: Story = { + decorators: withDesktopWindowApi, + render: () => ( + + setupMCPSettingsSectionStory({ + servers: { + "remote-oauth": { + transport: "http", + url: "https://example.com/mcp", + disabled: false, + }, + }, + mcpOauthAuthStatus: new Map([ + [ + "https://example.com/mcp", + { + serverUrl: "https://example.com/mcp", + isLoggedIn: false, + hasRefreshToken: false, + }, + ], + ]), + }) + } + > + + + ), + play: async ({ canvasElement }) => { + const body = within(canvasElement.ownerDocument.body); + + await body.findByText("remote-oauth"); + await body.findByText("Not logged in"); + await body.findByRole("button", { name: /^Login$/i }); + }, +}; + +export const ProjectSettingsOAuthLoggedIn: Story = { + decorators: withDesktopWindowApi, + render: () => ( + + setupMCPSettingsSectionStory({ + servers: { + "remote-oauth": { + transport: "http", + url: "https://example.com/mcp", + disabled: false, + }, + }, + mcpOauthAuthStatus: new Map([ + [ + "https://example.com/mcp", + { + serverUrl: "https://example.com/mcp", + isLoggedIn: true, + hasRefreshToken: true, + updatedAtMs: Date.now() - 60_000, + }, + ], + ]), + }) + } + > + + + ), + play: async ({ canvasElement }) => { + const body = within(canvasElement.ownerDocument.body); + + await body.findByText("remote-oauth"); + await body.findByText(/Logged in/i); + + const [moreActionsButton] = await body.findAllByRole("button", { name: "⋮" }); + if (!moreActionsButton) { + throw new Error("OAuth actions menu button not found"); + } + + await userEvent.click(moreActionsButton); + await body.findByRole("button", { name: /Re-login/i }); + await body.findByRole("button", { name: /^Logout$/i }); + }, +}; diff --git a/src/browser/stories/App.projectSettings.stories.tsx b/src/browser/stories/App.projectSettings.stories.tsx deleted file mode 100644 index a60a1cf6aa..0000000000 --- a/src/browser/stories/App.projectSettings.stories.tsx +++ /dev/null @@ -1,866 +0,0 @@ -/** - * Project Settings stories - * - * Shows different states and interactions for project-level configuration: - * - MCP servers (enable/disable, tool allowlists) - * - Idle compaction settings - * - Workspace-level MCP overrides - * - * Uses play functions to navigate to settings and interact with the UI. - */ - -import React from "react"; -import type { MCPServerInfo } from "@/common/types/mcp"; -import type { MCPOAuthAuthStatus } from "@/common/types/mcpOauth"; -import type { Secret } from "@/common/types/secrets"; -import type { APIClient } from "@/browser/contexts/API"; -import { appMeta, AppWithMocks, type AppStory } from "./meta.js"; -import { createWorkspace, groupWorkspacesByProject } from "./mockFactory"; -import { selectWorkspace } from "./storyHelpers"; -import { createMockORPCClient } from "@/browser/stories/mocks/orpc"; -import { within, userEvent, waitFor, expect } from "@storybook/test"; -import { getMCPTestResultsKey } from "@/common/constants/storage"; - -export default { - ...appMeta, - title: "App/Settings/MCP", -}; - -// ═══════════════════════════════════════════════════════════════════════════════ -// TEST DATA -// ═══════════════════════════════════════════════════════════════════════════════ - -const MOCK_TOOLS = [ - "file_read", - "file_write", - "bash", - "web_search", - "web_fetch", - "todo_write", - "todo_read", - "status_set", -]; - -const POSTHOG_TOOLS = [ - "add-insight-to-dashboard", - "dashboard-create", - "dashboard-delete", - "dashboard-get", - "dashboards-get-all", - "dashboard-update", - "docs-search", - "error-details", - "list-errors", - "create-feature-flag", - "delete-feature-flag", - "feature-flag-get-all", - "experiment-get-all", - "experiment-create", -]; - -// ═══════════════════════════════════════════════════════════════════════════════ -// HELPERS -// ═══════════════════════════════════════════════════════════════════════════════ - -interface MCPStoryOptions { - /** Global MCP servers (Settings → MCP) */ - servers?: Record; - /** Optional mock OAuth auth status per MCP server URL (serverUrl -> status) */ - mcpOauthAuthStatus?: Map; - /** Workspace-level MCP overrides */ - workspaceOverrides?: { - disabledServers?: string[]; - enabledServers?: string[]; - toolAllowlist?: Record; - }; - /** Test results for each server (tools available) */ - testResults?: Record; - /** Global secrets (used for secret-backed MCP header dropdowns) */ - secrets?: Secret[]; - /** Pre-cache test results in localStorage */ - preCacheTools?: boolean; -} - -function setupMCPStory(options: MCPStoryOptions = {}): APIClient { - const projectPath = "/Users/test/my-app"; - const workspaceId = "ws-mcp-test"; - const workspaces = [ - createWorkspace({ - id: workspaceId, - name: "main", - projectName: "my-app", - projectPath, - }), - ]; - - selectWorkspace(workspaces[0]); - - // Pre-cache tool test results if requested - if (options.preCacheTools && options.testResults) { - const cacheKeys = [getMCPTestResultsKey("__global__"), getMCPTestResultsKey(projectPath)]; - const cacheData: Record< - string, - { result: { success: true; tools: string[] }; testedAt: number } - > = {}; - for (const [serverName, tools] of Object.entries(options.testResults)) { - cacheData[serverName] = { - result: { success: true, tools }, - testedAt: Date.now(), - }; - } - for (const cacheKey of cacheKeys) { - localStorage.setItem(cacheKey, JSON.stringify(cacheData)); - } - } - - // Build mock data - const mcpServers = new Map>(); - if (options.servers) { - mcpServers.set(projectPath, options.servers); - } - - const mcpOverrides = new Map< - string, - { - disabledServers?: string[]; - enabledServers?: string[]; - toolAllowlist?: Record; - } - >(); - if (options.workspaceOverrides) { - mcpOverrides.set(workspaceId, options.workspaceOverrides); - } - - const projectSecrets = new Map(); - if (options.secrets) { - projectSecrets.set(projectPath, options.secrets); - } - const mcpTestResults = new Map(); - if (options.testResults) { - for (const [serverName, tools] of Object.entries(options.testResults)) { - mcpTestResults.set(serverName, { success: true, tools }); - } - } - - return createMockORPCClient({ - projects: groupWorkspacesByProject(workspaces), - workspaces, - globalSecrets: options.secrets ?? [], - projectSecrets, - globalMcpServers: options.servers ?? {}, - mcpServers, - mcpOverrides, - mcpTestResults, - mcpOauthAuthStatus: options.mcpOauthAuthStatus, - }); -} - -/** Open settings page and navigate to MCP section. */ -async function openProjectSettings(canvasElement: HTMLElement): Promise { - const canvas = within(canvasElement); - - const settingsButton = await canvas.findByTestId("settings-button", {}, { timeout: 10000 }); - await userEvent.click(settingsButton); - - // Desktop + mobile settings nav are both present in the test DOM. - const mcpButtons = await canvas.findAllByRole("button", { name: /^MCP$/i }); - const mcpButton = mcpButtons[0]; - if (!mcpButton) { - throw new Error("MCP settings button not found"); - } - await userEvent.click(mcpButton); - - const mcpHeading = await canvas.findByText("MCP Servers"); - mcpHeading.scrollIntoView({ block: "start" }); -} - -/** - * Modal roots are ephemeral under Storybook remounts; use query/find split - * to preserve retry semantics when the dialog briefly disappears. - */ -function queryWorkspaceMCPDialog(canvasElement: HTMLElement): HTMLElement | null { - const dialog = Array.from( - canvasElement.ownerDocument.body.querySelectorAll('[role="dialog"]') - ).find((el) => el.textContent?.includes("Workspace MCP Configuration")); - return dialog instanceof HTMLElement ? dialog : null; -} - -async function findWorkspaceMCPDialog( - canvasElement: HTMLElement, - timeout = 10000 -): Promise { - return waitFor( - () => { - const dialog = queryWorkspaceMCPDialog(canvasElement); - if (!dialog) { - throw new Error("Workspace MCP dialog not found"); - } - return dialog; - }, - { timeout } - ); -} - -function createWorkspaceMCPModalScope(canvasElement: HTMLElement) { - /** Re-open the dialog if a Storybook remount reset component state. */ - const ensureOpen = async () => { - if (!queryWorkspaceMCPDialog(canvasElement)) { - await openWorkspaceMCPModal(canvasElement); - } - }; - - return { - /** Self-healing find: re-opens the dialog if it disappeared. */ - find: async (timeout = 10000) => { - await ensureOpen(); - return within(await findWorkspaceMCPDialog(canvasElement, timeout)); - }, - /** - * Self-healing assertion: ensures the dialog is open, then retries the - * assertion callback until it passes or timeout expires. Survives - * Storybook remounts that reset component state mid-test. - */ - assert: async ( - assertion: (scope: ReturnType) => void | Promise, - timeout = 10000 - ) => { - await waitFor( - async () => { - await ensureOpen(); - const dialog = queryWorkspaceMCPDialog(canvasElement); - if (!dialog) throw new Error("Workspace MCP dialog not found"); - await assertion(within(dialog)); - }, - { timeout } - ); - }, - }; -} - -/** Open the workspace MCP modal via the "More actions" menu. */ -async function openWorkspaceMCPModal(canvasElement: HTMLElement): Promise { - const canvas = within(canvasElement); - const body = within(canvasElement.ownerDocument.body); - const stepTimeoutMs = 5000; - - // Wait for header controls before interacting with workspace menu bar actions. - await canvas.findByTestId("workspace-menu-bar", {}, { timeout: stepTimeoutMs }); - - const openMoreActionsMenu = async (): Promise => { - const moreActionsButton = await canvas.findByTestId( - "workspace-more-actions", - {}, - { timeout: stepTimeoutMs } - ); - await userEvent.click(moreActionsButton); - }; - - const clickMcpButton = async (): Promise => { - // Prefer stable test id lookup first; popover content renders in document.body. - const mcpButtonByTestId = body.queryByTestId("workspace-mcp-button"); - if (mcpButtonByTestId) { - await userEvent.click(mcpButtonByTestId); - return; - } - - // Fallback to accessible name if test id lookup races with popover mount. - const mcpButtonsByRole = await body.findAllByRole( - "button", - { name: /configure mcp servers/i }, - { timeout: stepTimeoutMs } - ); - const mcpButtonByRole = mcpButtonsByRole[0]; - if (!mcpButtonByRole) { - throw new Error("Workspace MCP menu button not found"); - } - await userEvent.click(mcpButtonByRole); - }; - - const waitForModalHeading = async (): Promise => { - await body.findByRole( - "heading", - { name: /workspace mcp configuration/i }, - { timeout: stepTimeoutMs } - ); - }; - - try { - await openMoreActionsMenu(); - await clickMcpButton(); - await waitForModalHeading(); - } catch { - // Storybook can remount between menu open and click; retry the full flow once. - await openMoreActionsMenu(); - await clickMcpButton(); - await waitForModalHeading(); - } -} - -const withDesktopWindowApi = [ - (Story: React.FC) => { - // Save and restore window.api to prevent leaking to other stories - const originalApiRef = React.useRef(window.api); - window.api = { - platform: "darwin", - versions: { - node: "20.0.0", - chrome: "120.0.0", - electron: "28.0.0", - }, - isRosetta: false, - }; - - // Cleanup on unmount - React.useEffect(() => { - const savedApi = originalApiRef.current; - return () => { - window.api = savedApi; - }; - }, []); - - return ; - }, -]; -// ═══════════════════════════════════════════════════════════════════════════════ -// PROJECT SETTINGS STORIES -// ═══════════════════════════════════════════════════════════════════════════════ - -/** Project settings with no MCP servers configured */ -export const ProjectSettingsEmpty: AppStory = { - render: () => setupMCPStory({})} />, - play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { - await openProjectSettings(canvasElement); - }, -}; - -/** Project settings - adding a remote server shows the headers table editor */ -export const ProjectSettingsAddRemoteServerHeaders: AppStory = { - render: () => ( - - setupMCPStory({ - secrets: [ - { key: "MCP_TOKEN", value: "abc123" }, - { key: "MCP_TOKEN_DEV", value: "def456" }, - ], - }) - } - /> - ), - play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { - await openProjectSettings(canvasElement); - - const body = within(canvasElement.ownerDocument.body); - - const addServerSummary = await body.findByText(/^Add server$/i); - await userEvent.click(addServerSummary); - - // Switch the transport to HTTP to reveal the headers editor. - const transportLabel = await body.findByText("Transport"); - const transportContainer = transportLabel.closest("div"); - await expect(transportContainer).not.toBeNull(); - - // Use findByRole (retry-capable) to handle transient DOM gaps between awaits. - const transportSelect = await within(transportContainer as HTMLElement).findByRole("combobox"); - await userEvent.click(transportSelect); - - const httpOption = await body.findByRole("option", { name: /HTTP \(Streamable\)/i }); - await userEvent.click(httpOption); - - const headersLabel = await body.findByText(/HTTP headers \(optional\)/i); - headersLabel.scrollIntoView({ block: "center" }); - - // Configure a secret-backed Authorization header. - const addHeaderButton = await body.findByRole("button", { name: /\+ Add header/i }); - await userEvent.click(addHeaderButton); - - // Use findAllByRole / waitFor to handle transient DOM gaps between awaits. - const headerNameInputs = await body.findAllByPlaceholderText("Authorization"); - await userEvent.type(headerNameInputs[0], "Authorization"); - - const secretToggles = await body.findAllByRole("radio", { name: "Secret" }); - await userEvent.click(secretToggles[0]); - - await expect( - body.findByRole("button", { name: /Choose secret/i }) - ).resolves.toBeInTheDocument(); - - const secretValueInput = await body.findByPlaceholderText("MCP_TOKEN"); - await userEvent.type(secretValueInput, "MCP_TOKEN"); - - // Add a second plain-text header. - await userEvent.click(addHeaderButton); - - const headerNameInputsAfterSecond = body.getAllByPlaceholderText("Authorization"); - await userEvent.type(headerNameInputsAfterSecond[1], "X-Env"); - - const textValueInput = await body.findByPlaceholderText("value"); - await userEvent.type(textValueInput, "prod"); - - await expect(body.findByDisplayValue("Authorization")).resolves.toBeInTheDocument(); - await expect(body.findByDisplayValue("MCP_TOKEN")).resolves.toBeInTheDocument(); - await expect(body.findByDisplayValue("X-Env")).resolves.toBeInTheDocument(); - await expect(body.findByDisplayValue("prod")).resolves.toBeInTheDocument(); - }, -}; - -/** Project settings with MCP servers configured (all enabled) */ -export const ProjectSettingsWithServers: AppStory = { - render: () => ( - - setupMCPStory({ - servers: { - mux: { transport: "stdio", command: "npx -y @anthropics/mux-server", disabled: false }, - posthog: { transport: "stdio", command: "npx -y posthog-mcp-server", disabled: false }, - filesystem: { - transport: "stdio", - command: "npx -y @anthropics/filesystem-server /tmp", - disabled: false, - }, - }, - testResults: { - mux: MOCK_TOOLS, - posthog: POSTHOG_TOOLS, - filesystem: ["read_file", "write_file", "list_directory"], - }, - preCacheTools: true, - }) - } - /> - ), - play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { - await openProjectSettings(canvasElement); - - // Verify servers are shown - const body = within(canvasElement.ownerDocument.body); - await body.findByText("mux"); - await body.findByText("posthog"); - await body.findByText("filesystem"); - }, -}; - -/** Project settings with a mix of enabled and disabled servers */ -export const ProjectSettingsMixedState: AppStory = { - render: () => ( - - setupMCPStory({ - servers: { - mux: { transport: "stdio", command: "npx -y @anthropics/mux-server", disabled: false }, - posthog: { transport: "stdio", command: "npx -y posthog-mcp-server", disabled: true }, - filesystem: { - transport: "stdio", - command: "npx -y @anthropics/filesystem-server /tmp", - disabled: false, - }, - }, - testResults: { - mux: MOCK_TOOLS, - posthog: POSTHOG_TOOLS, - filesystem: ["read_file", "write_file", "list_directory"], - }, - preCacheTools: true, - }) - } - /> - ), - play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { - await openProjectSettings(canvasElement); - - const body = within(canvasElement.ownerDocument.body); - - // posthog should show as disabled - await body.findByText("posthog"); - // The switch should be off for posthog - }, -}; - -/** Project settings showing tool allowlist (tools filtered) */ -export const ProjectSettingsWithToolAllowlist: AppStory = { - render: () => ( - - setupMCPStory({ - servers: { - mux: { - transport: "stdio", - command: "npx -y @anthropics/mux-server", - disabled: false, - toolAllowlist: ["file_read", "file_write", "bash"], - }, - }, - testResults: { - mux: MOCK_TOOLS, - }, - preCacheTools: true, - }) - } - /> - ), - play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { - await openProjectSettings(canvasElement); - - const body = within(canvasElement.ownerDocument.body); - await body.findByText("mux"); - - // Should show "3/8" tools indicator (3 allowed out of 8 total) - await body.findByText(/3\/8/); - }, -}; - -/** Project settings - remote MCP server row with OAuth available (not logged in) */ -export const ProjectSettingsOAuthNotLoggedIn: AppStory = { - decorators: withDesktopWindowApi, - render: () => ( - - setupMCPStory({ - servers: { - "remote-oauth": { - transport: "http", - url: "https://example.com/mcp", - disabled: false, - }, - }, - mcpOauthAuthStatus: new Map([ - [ - "https://example.com/mcp", - { - serverUrl: "https://example.com/mcp", - isLoggedIn: false, - hasRefreshToken: false, - }, - ], - ]), - }) - } - /> - ), - play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { - await openProjectSettings(canvasElement); - - const body = within(canvasElement.ownerDocument.body); - await body.findByText("remote-oauth"); - - // Wait for post-load OAuth status. - await body.findByText("Not logged in"); - await body.findByRole("button", { name: /^Login$/i }); - }, -}; - -/** Project settings - remote MCP server row with OAuth available (logged in) */ -export const ProjectSettingsOAuthLoggedIn: AppStory = { - decorators: withDesktopWindowApi, - render: () => ( - - setupMCPStory({ - servers: { - "remote-oauth": { - transport: "http", - url: "https://example.com/mcp", - disabled: false, - }, - }, - mcpOauthAuthStatus: new Map([ - [ - "https://example.com/mcp", - { - serverUrl: "https://example.com/mcp", - isLoggedIn: true, - hasRefreshToken: true, - updatedAtMs: Date.now() - 60_000, - }, - ], - ]), - }) - } - /> - ), - play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { - await openProjectSettings(canvasElement); - - const body = within(canvasElement.ownerDocument.body); - await body.findByText("remote-oauth"); - - // Wait for post-load OAuth status. - await body.findByText(/Logged in \(1 minute ago\)/i); - - // Actions are grouped under a compact kebab menu. - const moreActionsButton = await body.findByRole("button", { name: "⋮" }); - await userEvent.click(moreActionsButton); - await body.findByRole("button", { name: /Re-login/i }); - await body.findByRole("button", { name: /^Logout$/i }); - }, -}; - -// ═══════════════════════════════════════════════════════════════════════════════ -// WORKSPACE MCP MODAL STORIES -// ═══════════════════════════════════════════════════════════════════════════════ - -/** Workspace MCP modal with servers from project (no overrides) */ -export const WorkspaceMCPNoOverrides: AppStory = { - render: () => ( - - setupMCPStory({ - servers: { - mux: { transport: "stdio", command: "npx -y @anthropics/mux-server", disabled: false }, - posthog: { transport: "stdio", command: "npx -y posthog-mcp-server", disabled: false }, - }, - testResults: { - mux: MOCK_TOOLS, - posthog: POSTHOG_TOOLS, - }, - preCacheTools: true, - }) - } - /> - ), - play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { - await openWorkspaceMCPModal(canvasElement); - const modal = createWorkspaceMCPModalScope(canvasElement); - - // Both servers should be shown and enabled. - await expect((await modal.find()).findByText("mux")).resolves.toBeInTheDocument(); - await expect((await modal.find()).findByText("posthog")).resolves.toBeInTheDocument(); - }, -}; - -/** Workspace MCP modal - server disabled at project level, can be enabled */ -export const WorkspaceMCPProjectDisabledServer: AppStory = { - render: () => ( - - setupMCPStory({ - servers: { - mux: { transport: "stdio", command: "npx -y @anthropics/mux-server", disabled: false }, - posthog: { transport: "stdio", command: "npx -y posthog-mcp-server", disabled: true }, - }, - testResults: { - mux: MOCK_TOOLS, - posthog: POSTHOG_TOOLS, - }, - preCacheTools: true, - }) - } - /> - ), - play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { - await openWorkspaceMCPModal(canvasElement); - const modal = createWorkspaceMCPModalScope(canvasElement); - - // posthog should show "(disabled at project level)" but switch should still be toggleable. - await expect((await modal.find()).findByText("posthog")).resolves.toBeInTheDocument(); - await expect( - (await modal.find()).findByText(/disabled at project level/i) - ).resolves.toBeInTheDocument(); - }, -}; - -/** Workspace MCP modal - server disabled at project level, enabled at workspace level */ -export const WorkspaceMCPEnabledOverride: AppStory = { - render: () => ( - - setupMCPStory({ - servers: { - mux: { transport: "stdio", command: "npx -y @anthropics/mux-server", disabled: false }, - posthog: { transport: "stdio", command: "npx -y posthog-mcp-server", disabled: true }, - }, - workspaceOverrides: { - enabledServers: ["posthog"], - }, - testResults: { - mux: MOCK_TOOLS, - posthog: POSTHOG_TOOLS, - }, - preCacheTools: true, - }) - } - /> - ), - play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { - await openWorkspaceMCPModal(canvasElement); - const modal = createWorkspaceMCPModalScope(canvasElement); - - // posthog should be enabled despite project-level disable. - await expect((await modal.find()).findByText("posthog")).resolves.toBeInTheDocument(); - await expect( - (await modal.find()).findByText(/disabled at project level/i) - ).resolves.toBeInTheDocument(); - - // The switch should be ON (enabled at workspace level). - }, -}; - -/** Workspace MCP modal - server enabled at project level, disabled at workspace level */ -export const WorkspaceMCPDisabledOverride: AppStory = { - render: () => ( - - setupMCPStory({ - servers: { - mux: { transport: "stdio", command: "npx -y @anthropics/mux-server", disabled: false }, - posthog: { transport: "stdio", command: "npx -y posthog-mcp-server", disabled: false }, - }, - workspaceOverrides: { - disabledServers: ["posthog"], - }, - testResults: { - mux: MOCK_TOOLS, - posthog: POSTHOG_TOOLS, - }, - preCacheTools: true, - }) - } - /> - ), - play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { - await openWorkspaceMCPModal(canvasElement); - const modal = createWorkspaceMCPModalScope(canvasElement); - - // mux should be enabled, posthog should be disabled. - await expect((await modal.find()).findByText("mux")).resolves.toBeInTheDocument(); - await expect((await modal.find()).findByText("posthog")).resolves.toBeInTheDocument(); - }, -}; - -/** Workspace MCP modal with tool allowlist filtering */ -export const WorkspaceMCPWithToolAllowlist: AppStory = { - render: () => ( - - setupMCPStory({ - servers: { - posthog: { transport: "stdio", command: "npx -y posthog-mcp-server", disabled: false }, - }, - workspaceOverrides: { - toolAllowlist: { - posthog: ["docs-search", "error-details", "list-errors"], - }, - }, - testResults: { - posthog: POSTHOG_TOOLS, - }, - preCacheTools: true, - }) - } - /> - ), - play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { - await openWorkspaceMCPModal(canvasElement); - const modal = createWorkspaceMCPModalScope(canvasElement); - - await expect((await modal.find()).findByText("posthog")).resolves.toBeInTheDocument(); - - // Should show filtered tool count. - await expect( - (await modal.find()).findByText(/3 of 14 tools enabled/i) - ).resolves.toBeInTheDocument(); - }, -}; - -// ═══════════════════════════════════════════════════════════════════════════════ -// INTERACTION STORIES -// ═══════════════════════════════════════════════════════════════════════════════ - -/** Interact with tool selector - click All/None buttons */ -export const ToolSelectorInteraction: AppStory = { - render: () => ( - - setupMCPStory({ - servers: { - mux: { transport: "stdio", command: "npx -y @anthropics/mux-server", disabled: false }, - }, - testResults: { - mux: MOCK_TOOLS, - }, - preCacheTools: true, - }) - } - /> - ), - play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { - await openWorkspaceMCPModal(canvasElement); - const modal = createWorkspaceMCPModalScope(canvasElement); - - // Normalize to the "all selected" baseline before testing None→All. - // Queries + clicks inside waitFor so we always target the live DOM node - // (prevents flakes from React re-renders replacing the button mid-step). - await modal.assert(async (scope) => { - const allBtn = scope.getByRole("button", { name: /^All$/i }) as HTMLElement; - if (!allBtn.hasAttribute("disabled")) { - await userEvent.click(allBtn); - } - await expect(allBtn).toBeDisabled(); - }); - - // Full None→All cycle in one atomic assertion. Keeping the entire - // flow inside a single modal.assert() removes the cross-assert - // state dependency window that caused flakes: if a Storybook remount - // resets the modal between separate assert() calls, the unsaved local - // overrides are lost and "All" stays disabled. A single callback lets - // waitFor retry the whole sequence from scratch on remount, while the - // idempotent guards prevent double-clicks on retry iterations. - await modal.assert(async (scope) => { - // Click "None" to deselect all tools. - const noneBtn = scope.getByRole("button", { name: /^None$/i }) as HTMLElement; - if (!noneBtn.hasAttribute("disabled")) { - await userEvent.click(noneBtn); - } - await expect(noneBtn).toBeDisabled(); - - scope.getByText((_content: string, element: Element | null) => { - const t = (element?.textContent ?? "").replace(/\s+/g, " ").trim(); - return /^0 of \d+ tools enabled$/i.test(t); - }); - - // "All" must be enabled — guards against a remount silently resetting - // state, which would make the click a no-op. - const allBtn = scope.getByRole("button", { name: /^All$/i }) as HTMLElement; - await expect(allBtn).toBeEnabled(); - - // Click "All" to re-select all tools. - await userEvent.click(allBtn); - await expect(allBtn).toBeDisabled(); - }); - }, -}; - -/** Toggle server enabled state in workspace modal */ -export const ToggleServerEnabled: AppStory = { - render: () => ( - - setupMCPStory({ - servers: { - mux: { transport: "stdio", command: "npx -y @anthropics/mux-server", disabled: false }, - posthog: { transport: "stdio", command: "npx -y posthog-mcp-server", disabled: false }, - }, - testResults: { - mux: MOCK_TOOLS, - posthog: POSTHOG_TOOLS, - }, - preCacheTools: true, - }) - } - /> - ), - play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { - await openWorkspaceMCPModal(canvasElement); - const modal = createWorkspaceMCPModalScope(canvasElement); - - // Find the posthog server row. - await expect((await modal.find()).findByText("posthog")).resolves.toBeInTheDocument(); - - // Find all switches and click the second one (posthog). - const switches = await (await modal.find()).findAllByRole("switch"); - // posthog should be the second switch - if (switches.length >= 2) { - await userEvent.click(switches[1]); - } - }, -}; From bf470c8f888ada4e0faea666dda8f9df9d900fbb Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Mon, 23 Mar 2026 14:54:34 +0000 Subject: [PATCH 3/3] fix(storybook): refresh ORPC client when setup changes --- .../WorkspaceMCPModal/WorkspaceMCPModal.stories.tsx | 6 +++++- .../Settings/Sections/MCPSettingsSection.stories.tsx | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/browser/components/WorkspaceMCPModal/WorkspaceMCPModal.stories.tsx b/src/browser/components/WorkspaceMCPModal/WorkspaceMCPModal.stories.tsx index a57add7816..076a4b1d0f 100644 --- a/src/browser/components/WorkspaceMCPModal/WorkspaceMCPModal.stories.tsx +++ b/src/browser/components/WorkspaceMCPModal/WorkspaceMCPModal.stories.tsx @@ -131,8 +131,12 @@ const WorkspaceMCPModalStoryShell: FC<{ setup: () => APIClient; children: ReactN setup, children, }) => { + const setupRef = useRef(setup); const clientRef = useRef(null); - clientRef.current ??= setup(); + if (clientRef.current === null || setupRef.current !== setup) { + setupRef.current = setup; + clientRef.current = setup(); + } return ( diff --git a/src/browser/features/Settings/Sections/MCPSettingsSection.stories.tsx b/src/browser/features/Settings/Sections/MCPSettingsSection.stories.tsx index 5171d848d5..7a4a113be3 100644 --- a/src/browser/features/Settings/Sections/MCPSettingsSection.stories.tsx +++ b/src/browser/features/Settings/Sections/MCPSettingsSection.stories.tsx @@ -98,8 +98,12 @@ const MCPSettingsSectionStoryShell: FC<{ setup: () => APIClient; children: React setup, children, }) => { + const setupRef = useRef(setup); const clientRef = useRef(null); - clientRef.current ??= setup(); + if (clientRef.current === null || setupRef.current !== setup) { + setupRef.current = setup; + clientRef.current = setup(); + } return (