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.
This commit is contained in:
Ethan
2026-08-24 13:46:15 +01:00
committed by GitHub
parent 042421694f
commit 9b5f47eeb7
12 changed files with 104 additions and 45 deletions
@@ -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();
},
};
@@ -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,
@@ -284,6 +284,7 @@ export const ForMember: Story = {
export const ForMemberWithModelAccess: Story = {
parameters: {
pixel: { matrix: pixelWithDesktop },
reactRouter: reactRouterParameters({
location: { path: "/" },
routing: [
@@ -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(
@@ -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,
@@ -64,6 +64,16 @@ const meta: Meta<typeof OrganizationModelsLayout> = {
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 }) => {
@@ -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) => (
<OrganizationModelsContext.Provider
value={{
organization: MockDefaultOrganization,
organizations: [MockDefaultOrganization],
permissions: MockOrganizationPermissions,
requestedOrganizationDenied: false,
}}
>
<Story />
</OrganizationModelsContext.Provider>
);
const meta: Meta<typeof ModelForm> = {
title: "pages/AISettingsPage/ModelsPage/ModelForm",
component: ModelForm,
decorators: [withToaster],
decorators: [withToaster, withOrganizationModels],
args: {
providerStates: [MockOpenAIProviderState, MockAnthropicProviderState],
selectedProviderState: MockOpenAIProviderState,
@@ -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}");
@@ -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 },
);
@@ -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();
});
},
};
@@ -595,9 +595,6 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
{personalModelOverridesQuery.error != null && (
<ErrorAlert error={personalModelOverridesQuery.error} />
)}
{mcpServersQuery.error != null && (
<ErrorAlert error={mcpServersQuery.error} />
)}
{organizationId !== "" &&
modelsQuery.data !== undefined &&
modelsQuery.error == null &&
@@ -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();