From 9b5f47eeb7b0eec57e9507fcf575c2854c7385f4 Mon Sep 17 00:00:00 2001 From: Ethan <39577870+ethanndickson@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:46:15 +0100 Subject: [PATCH] fix(site/src): repair failing Storybook stories (#28462) ## Summary Repair the Storybook interaction and Pixel failures currently present on `main`. The failures came from organization-scoped model changes leaving stories with incomplete providers and query fixtures, product copy and route changes leaving stale assertions, and several interaction tests depending on implementation details or teardown timing. ## Broken stories and fixes ### Organization-scoped chat models PR #27960 introduced organization-scoped chat models and the following regressions: - `OrganizationModelsLayout / Switch Organization Preserves Auxiliary Parameters`, `Invalid Requested Organization Falls Back To Default`, `Invalid Requested Organization Denies Add`, `Duplicate Display Names Are Disambiguated`, and `No Readable Organization Is Not Found`: the stories only populated permission query keys for individual organizations, while the accessible-organization lookup requests authorization for all visible organization IDs together. The unmatched `/api/v2/authcheck` request returned a Storybook proxy 502, so the layout rendered an error instead of the intended picker, fallback, denied, disambiguation, or not-found state. Add fixtures for the combined organization-permission keys, preserve the intentionally denied permission map, and return an explicit empty authorization result for the no-readable-organization case. - `ModelFormProviderConfig / Provider Config Open AI`, `Provider Config Anthropic`, and `Provider Config Open AI Web Search`: `ModelForm` began consuming `OrganizationModelsContext`, but these stories were not wrapped in its provider and rendered the router error boundary. Add the same organization-model context decorator used by the sibling model form stories. - `AgentChatPage / Queued For Capacity After Polling`: the story retained a manually assembled chat-and-messages fixture after the page gained organization-model, provider, workspace, prompt, diff, chat-list, and authorization dependencies. Those missing queries prevented the polling request from being reached. Replace the partial fixture list with the shared `buildQueries()` setup. - `DashboardLayout / Custom Organization Role Can Open Models`, `DashboardLayout / ACL Readable Member Can Open Models`, and `NavbarView / For Member With Model Access`: these stories also landed in #27960 and inherited Pixel's tablet-and-desktop matrix, but their play functions exercise the desktop `Models` link. Pixel's 744px tablet viewport renders that link inside the closed mobile menu, so the desktop query always failed there. Restrict these authorization-to-navigation stories to the desktop matrix; mobile Models navigation remains covered by the dedicated `MobileMenu` story. ### Premium copy and navigation - `DeploymentSidebarView / Premium Tab Visible` and `Premium Tab Hidden`: PR #28226 renamed the production navigation item from `Premium` to `Trial Upgrade`, but added stories that still queried the old name. Update both the positive and negative assertions so the hidden-state story cannot pass while the real CTA is present. - `PremiumPageView / No License`: PR #28226 changed the production heading to `Start an unlimited 30-day Coder trial` while the story asserted the previous Premium wording. Update the accessible heading assertion to the rendered copy. - `AgentChatPageView / Queued For Capacity Community Admin`: PR #28437 intentionally moved the trial CTA from `https://coder.com/trial` to the internal `/deployment/premium` route, leaving the story's href assertion stale. Update the expected route while retaining the link-name and callout checks. ### MCP server refetch behavior - `AgentCreateForm / MCP Servers Error Shows Alert And Disables Send` and `MCP Servers Refetch Error Keeps Send Enabled`: the MCP coverage was introduced in #27942. PR #28442 later added a second unconditional MCP `ErrorAlert`, so a background refetch error appeared even when cached MCP data remained usable. The refetch story also called `refetchQueries()` without a key, which began refetching unrelated active model queries as the form's query surface expanded and produced unmatched API failures. Remove the duplicate unconditional alert, refetch only the organization's MCP query, and use semantic alert and heading assertions. Initial-load failures still disable Send, while background failures with cached data keep Send enabled without replacing the form with an error. ### Interaction and teardown stability - `IconField / Open Picker`: PR #27674 changed this story to wait for the `em-emoji-picker` custom element. That implementation-specific query races the lazy-loaded picker chunk and violates the component's observable contract. Keep the button state assertion and wait for the visible dialog instead. - `AgentChatPage / Slash Compact Command Submits` and `Slash Compact Yields To Personal Skill`: the command story added in #27081 waited on cmdk's `Commands` group heading, which is accessibility-hidden, while the skill variant queried raw implementation text. Menu placement and visibility are asynchronous, especially after the positioning changes in #28411. Wait for the visible selectable options by role before pressing Enter. - `AgentChatPageView / Terminal Focus On Tab Switch`: the focus coverage added in #24677 exposed an xterm teardown race rather than a product navigation regression. xterm queues its initial viewport synchronization, but Storybook could synchronously dispose the terminal first, leaving the queued callback to read a cleared renderer and report an unhandled error. Clear React state immediately, defer xterm disposal by one timer turn, query the labeled terminal textbox semantically, and remove the unnecessary empty WebSocket message fixture. --- .../IconField/IconField.stories.tsx | 11 ++++--- .../dashboard/DashboardLayout.stories.tsx | 4 ++- .../dashboard/Navbar/NavbarView.stories.tsx | 1 + .../DeploymentSidebarView.stories.tsx | 9 +++--- .../modules/terminal/WorkspaceTerminal.tsx | 3 +- .../OrganizationModelsLayout.stories.tsx | 30 +++++++++++++++++++ .../ModelFormProviderConfig.stories.tsx | 20 ++++++++++++- .../AgentsPage/AgentChatPage.stories.tsx | 24 +++++++-------- .../AgentsPage/AgentChatPageView.stories.tsx | 20 ++++++------- .../components/AgentCreateForm.stories.tsx | 22 +++++++++----- .../AgentsPage/components/AgentCreateForm.tsx | 3 -- .../PremiumPage/PremiumPageView.stories.tsx | 2 +- 12 files changed, 104 insertions(+), 45 deletions(-) diff --git a/site/src/components/IconField/IconField.stories.tsx b/site/src/components/IconField/IconField.stories.tsx index 7def38e2fe3..0773c78ae83 100644 --- a/site/src/components/IconField/IconField.stories.tsx +++ b/site/src/components/IconField/IconField.stories.tsx @@ -50,9 +50,12 @@ export const OpenPicker: Story = { }); await userEvent.click(button); await expect(button).toHaveAttribute("aria-expanded", "true"); - const popover = await screen.findByRole("dialog"); - await waitFor(() => - expect(popover.querySelector("em-emoji-picker")).toBeInTheDocument(), - ); + const dialog = await screen.findByRole("dialog"); + await waitFor(() => { + expect( + within(dialog).queryByRole("status", { name: "Loading" }), + ).not.toBeInTheDocument(); + }); + await expect(dialog).toBeVisible(); }, }; diff --git a/site/src/modules/dashboard/DashboardLayout.stories.tsx b/site/src/modules/dashboard/DashboardLayout.stories.tsx index 94f6017251c..c23c84aab63 100644 --- a/site/src/modules/dashboard/DashboardLayout.stories.tsx +++ b/site/src/modules/dashboard/DashboardLayout.stories.tsx @@ -25,7 +25,7 @@ import { MockUserMember, MockUserOwner, } from "#/testHelpers/entities"; -import { pixelWithTablet } from "#/testHelpers/pixel"; +import { pixelWithDesktop, pixelWithTablet } from "#/testHelpers/pixel"; import { withAuthProvider, withDashboardProvider, @@ -118,6 +118,7 @@ export const ForMember: Story = { export const CustomOrganizationRoleCanOpenModels: Story = { parameters: { + pixel: { matrix: pixelWithDesktop }, user: MockUserMember, permissions: MockNoPermissions, reactRouter: modelSettingsRouter, @@ -141,6 +142,7 @@ export const CustomOrganizationRoleCanOpenModels: Story = { export const ACLReadableMemberCanOpenModels: Story = { parameters: { + pixel: { matrix: pixelWithDesktop }, user: MockUserMember, permissions: MockNoPermissions, reactRouter: modelSettingsRouter, diff --git a/site/src/modules/dashboard/Navbar/NavbarView.stories.tsx b/site/src/modules/dashboard/Navbar/NavbarView.stories.tsx index a958675c709..ba5a6821d35 100644 --- a/site/src/modules/dashboard/Navbar/NavbarView.stories.tsx +++ b/site/src/modules/dashboard/Navbar/NavbarView.stories.tsx @@ -284,6 +284,7 @@ export const ForMember: Story = { export const ForMemberWithModelAccess: Story = { parameters: { + pixel: { matrix: pixelWithDesktop }, reactRouter: reactRouterParameters({ location: { path: "/" }, routing: [ diff --git a/site/src/modules/management/DeploymentSidebarView.stories.tsx b/site/src/modules/management/DeploymentSidebarView.stories.tsx index 02320b84450..69e44ef29b8 100644 --- a/site/src/modules/management/DeploymentSidebarView.stories.tsx +++ b/site/src/modules/management/DeploymentSidebarView.stories.tsx @@ -71,10 +71,9 @@ export const PremiumTabVisible: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); - await expect(canvas.getByRole("link", { name: "Premium" })).toHaveAttribute( - "href", - "/deployment/premium", - ); + await expect( + canvas.getByRole("link", { name: "Trial Upgrade" }), + ).toHaveAttribute("href", "/deployment/premium"); }, }; @@ -87,7 +86,7 @@ export const PremiumTabHidden: Story = { const canvas = within(canvasElement); await expect( - canvas.queryByRole("link", { name: "Premium" }), + canvas.queryByRole("link", { name: "Trial Upgrade" }), ).not.toBeInTheDocument(); // A neighbouring item must survive the change. await expect( diff --git a/site/src/modules/terminal/WorkspaceTerminal.tsx b/site/src/modules/terminal/WorkspaceTerminal.tsx index a1f96888b09..a5b140cfa6e 100644 --- a/site/src/modules/terminal/WorkspaceTerminal.tsx +++ b/site/src/modules/terminal/WorkspaceTerminal.tsx @@ -331,8 +331,9 @@ export const WorkspaceTerminal = ({ window.removeEventListener("resize", refit); resizeObserver.disconnect(); fitAddonRef.current = undefined; - nextTerminal.dispose(); setTerminal(undefined); + // xterm queues its initial viewport synchronization in a timer. + window.setTimeout(() => nextTerminal.dispose(), 0); }; }, [ isVisible, diff --git a/site/src/pages/AISettingsPage/ModelsPage/OrganizationModelsLayout.stories.tsx b/site/src/pages/AISettingsPage/ModelsPage/OrganizationModelsLayout.stories.tsx index c67e0e764de..99967a83896 100644 --- a/site/src/pages/AISettingsPage/ModelsPage/OrganizationModelsLayout.stories.tsx +++ b/site/src/pages/AISettingsPage/ModelsPage/OrganizationModelsLayout.stories.tsx @@ -64,6 +64,16 @@ const meta: Meta = { key: organizationsPermissions([MockOrganization2.id]).queryKey, data: { [MockOrganization2.id]: MockOrganizationPermissions }, }, + { + key: organizationsPermissions([ + MockDefaultOrganization.id, + MockOrganization2.id, + ]).queryKey, + data: { + [MockDefaultOrganization.id]: MockOrganizationPermissions, + [MockOrganization2.id]: MockOrganizationPermissions, + }, + }, ], }, }; @@ -204,6 +214,15 @@ export const InvalidRequestedOrganizationDeniesAdd: Story = { [MockDefaultOrganization.id]: MockOrganizationPermissions, }, }, + { + key: organizationsPermissions([ + MockDefaultOrganization.id, + MockOrganization2.id, + ]).queryKey, + data: { + [MockDefaultOrganization.id]: MockOrganizationPermissions, + }, + }, ], }, play: async ({ canvasElement }) => { @@ -269,6 +288,16 @@ export const DuplicateDisplayNamesAreDisambiguated: Story = { [MockDefaultOrganization.id]: MockOrganizationPermissions, }, }, + { + key: organizationsPermissions([ + MockDefaultOrganization.id, + duplicateNameOrganization.id, + ]).queryKey, + data: { + [MockDefaultOrganization.id]: MockOrganizationPermissions, + [duplicateNameOrganization.id]: MockOrganizationPermissions, + }, + }, ], }, play: async ({ canvasElement }) => { @@ -358,6 +387,7 @@ export const NoReadableOrganizationIsNotFound: Story = { isAxiosError: true, response: { status: 403 }, }); + spyOn(API, "checkAuthorization").mockResolvedValue({}); }, parameters: { queries: [] }, play: async ({ canvasElement }) => { diff --git a/site/src/pages/AISettingsPage/ModelsPage/components/ModelFormProviderConfig.stories.tsx b/site/src/pages/AISettingsPage/ModelsPage/components/ModelFormProviderConfig.stories.tsx index 13572da3df6..eca85164935 100644 --- a/site/src/pages/AISettingsPage/ModelsPage/components/ModelFormProviderConfig.stories.tsx +++ b/site/src/pages/AISettingsPage/ModelsPage/components/ModelFormProviderConfig.stories.tsx @@ -1,17 +1,35 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, fn, userEvent, within } from "storybook/test"; import { reactRouterParameters } from "storybook-addon-remix-react-router"; +import { + MockDefaultOrganization, + MockOrganizationPermissions, +} from "#/testHelpers/entities"; import { withToaster } from "#/testHelpers/storybook"; +import { OrganizationModelsContext } from "../organizationModels"; import { MockAnthropicProviderState, MockOpenAIProviderState, } from "../testFixtures"; import { ModelForm } from "./ModelForm"; +const withOrganizationModels = (Story: React.FC) => ( + + + +); + const meta: Meta = { title: "pages/AISettingsPage/ModelsPage/ModelForm", component: ModelForm, - decorators: [withToaster], + decorators: [withToaster, withOrganizationModels], args: { providerStates: [MockOpenAIProviderState, MockAnthropicProviderState], selectedProviderState: MockOpenAIProviderState, diff --git a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx index dcdc77ced22..8dc1fe418f8 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx @@ -1607,16 +1607,11 @@ const capacityPollingChat: TypesGen.Chat = { export const QueuedForCapacityAfterPolling: Story = { parameters: { - queries: [ - { key: chatEntityKey(CHAT_ID), data: capacityPollingChat }, - { - key: chatMessagesKey(CHAT_ID), - data: { - pages: [{ messages: [], queued_messages: [], has_more: false }], - pageParams: [undefined], - }, - }, - ], + queries: buildQueries( + capacityPollingChat, + { messages: [], queued_messages: [], has_more: false }, + { diffUrl: undefined }, + ), }, beforeEach: () => { spyOn(API.experimental, "getChat").mockResolvedValue({ @@ -3315,7 +3310,10 @@ export const SlashCompactCommandSubmits: Story = { await userEvent.keyboard("/compact"); // First Enter accepts the highlighted menu entry; second Enter // submits the composer. - expect(await within(document.body).findByText("Commands")).toBeVisible(); + const body = within(document.body); + await waitFor(() => { + expect(body.getByRole("option", { name: /\/compact/i })).toBeVisible(); + }); await userEvent.keyboard("{Enter}"); await userEvent.keyboard("{Enter}"); @@ -3381,7 +3379,9 @@ export const SlashCompactYieldsToPersonalSkill: Story = { // visibility rather than asserting it once. await waitFor(() => { expect( - within(document.body).getByText("Personal compact skill"), + within(document.body).getByRole("option", { + name: /personal compact skill/i, + }), ).toBeVisible(); }); await userEvent.keyboard("{Enter}"); diff --git a/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx b/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx index fb1c428123e..4f7e0db540d 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx @@ -365,7 +365,7 @@ export const QueuedForCapacityCommunityAdmin: Story = { const trialLink = canvas.getByRole("link", { name: /start an unlimited trial/i, }); - expect(trialLink).toHaveAttribute("href", "https://coder.com/trial"); + expect(trialLink).toHaveAttribute("href", "/deployment/premium"); const learnMoreLink = canvas.getByRole("link", { name: /learn more/i }); expect(learnMoreLink).toHaveAttribute( "href", @@ -1588,7 +1588,7 @@ export const FailedHistoryPageOffersKeyboardRetry: Story = { export const TerminalFocusOnTabSwitch: Story = { parameters: { pixel: { exclude: true }, - webSocket: { "/api/v2/workspaceagents/": [{ event: "message", data: "" }] }, + webSocket: [], }, decorators: [withWebSocket], render: () => ( @@ -1614,12 +1614,12 @@ export const TerminalFocusOnTabSwitch: Story = { return el; }); - // The xterm focus target is a textarea inside the terminal container. + const terminal = within(terminalContainer); await waitFor( () => { - const textarea = terminalContainer.querySelector("textarea"); - expect(textarea).not.toBeNull(); - expect(document.activeElement).toBe(textarea); + expect( + terminal.getByRole("textbox", { name: "Terminal input" }), + ).toHaveFocus(); }, { timeout: 3000 }, ); @@ -1629,12 +1629,12 @@ export const TerminalFocusOnTabSwitch: Story = { await userEvent.click(gitTab); await userEvent.click(terminalTab); - // Focus should return to the terminal textarea. + // Focus should return to the terminal input. await waitFor( () => { - const textarea = terminalContainer.querySelector("textarea"); - expect(textarea).not.toBeNull(); - expect(document.activeElement).toBe(textarea); + expect( + terminal.getByRole("textbox", { name: "Terminal input" }), + ).toHaveFocus(); }, { timeout: 3000 }, ); diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx index a5b8c48ce34..7223aaee243 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx @@ -14,6 +14,7 @@ import { import { API } from "#/api/api"; import { aiProvidersListKey } from "#/api/queries/aiProviders"; import { + mcpServerConfigsKey, organizationChatModelsKey, userChatPersonalModelOverrides, userChatProviderConfigsKey, @@ -2343,8 +2344,12 @@ export const MCPServersErrorShowsAlertAndDisablesSend: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - const matches = await canvas.findAllByText(/failed to load mcp servers/i); - expect(matches.length).toBeGreaterThan(0); + const alert = await canvas.findByRole("alert"); + expect( + within(alert).getByRole("heading", { + name: /failed to load mcp servers/i, + }), + ).toBeVisible(); expect(canvas.getByRole("button", { name: "Send" })).toBeDisabled(); }, }; @@ -2371,10 +2376,13 @@ export const MCPServersRefetchErrorKeepsSendEnabled: Story = { if (!capturedQueryClient) { throw new Error("query client was not captured by the story decorator"); } - await capturedQueryClient.refetchQueries(); - expect( - canvas.queryByText(/failed to refresh mcp servers/i), - ).not.toBeInTheDocument(); - expect(send).toBeEnabled(); + await capturedQueryClient.refetchQueries({ + queryKey: mcpServerConfigsKey(MockDefaultOrganization.id), + exact: true, + }); + await waitFor(() => { + expect(canvas.queryByRole("alert")).not.toBeInTheDocument(); + expect(send).toBeEnabled(); + }); }, }; diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx index 6246051183b..643b92a99c1 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx @@ -595,9 +595,6 @@ export const AgentCreateForm: FC = ({ {personalModelOverridesQuery.error != null && ( )} - {mcpServersQuery.error != null && ( - - )} {organizationId !== "" && modelsQuery.data !== undefined && modelsQuery.error == null && diff --git a/site/src/pages/DeploymentSettingsPage/PremiumPage/PremiumPageView.stories.tsx b/site/src/pages/DeploymentSettingsPage/PremiumPage/PremiumPageView.stories.tsx index 231df078326..830fb18bead 100644 --- a/site/src/pages/DeploymentSettingsPage/PremiumPage/PremiumPageView.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/PremiumPage/PremiumPageView.stories.tsx @@ -26,7 +26,7 @@ export const NoLicense: Story = { // The hero tracks the panel, so the trial pitch only appears here. await expect( canvas.getByRole("heading", { - name: "Start a 30-day trial of Coder Premium", + name: "Start an unlimited 30-day Coder trial", level: 2, }), ).toBeInTheDocument();