From 0857f6ae211e3d982b853eab489b6c0e04ec4d01 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Sun, 22 Mar 2026 15:24:57 +0000 Subject: [PATCH 1/3] Add CoderControls co-located stories --- .../Runtime/CoderControls.stories.tsx | 344 ++++++++++++++++++ 1 file changed, 344 insertions(+) create mode 100644 src/browser/features/Runtime/CoderControls.stories.tsx diff --git a/src/browser/features/Runtime/CoderControls.stories.tsx b/src/browser/features/Runtime/CoderControls.stories.tsx new file mode 100644 index 0000000000..66b0a7ca93 --- /dev/null +++ b/src/browser/features/Runtime/CoderControls.stories.tsx @@ -0,0 +1,344 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { waitFor, within } from "@storybook/test"; +import { useState } from "react"; + +import { lightweightMeta } from "@/browser/stories/meta.js"; +import type { + CoderInfo, + CoderPreset, + CoderTemplate, + CoderWorkspace, +} from "@/common/orpc/schemas/coder"; +import type { CoderWorkspaceConfig } from "@/common/types/runtime"; + +import { + CoderAvailabilityMessage, + CoderWorkspaceForm, + type CoderWorkspaceFormProps, +} from "./CoderControls"; + +type CoderWorkspaceFormStoryProps = Omit; + +interface CoderAvailabilityMessageStoryProps { + coderInfo: CoderInfo | null; +} + +const mockTemplates: CoderTemplate[] = [ + { name: "coder-on-coder", displayName: "Coder on Coder", organizationName: "default" }, + { name: "kubernetes-dev", displayName: "Kubernetes Development", organizationName: "default" }, + { name: "aws-windows", displayName: "AWS Windows Instance", organizationName: "default" }, +]; + +const mockPresetsCoderOnCoder: CoderPreset[] = [ + { + id: "preset-sydney", + name: "Sydney", + description: "Australia region", + isDefault: false, + }, + { + id: "preset-helsinki", + name: "Helsinki", + description: "Europe region", + isDefault: false, + }, + { + id: "preset-pittsburgh", + name: "Pittsburgh", + description: "US East region", + isDefault: true, + }, +]; + +const mockWorkspaces: CoderWorkspace[] = [ + { + name: "mux-dev", + templateName: "coder-on-coder", + templateDisplayName: "Coder on Coder", + status: "running", + }, + { + name: "api-testing", + templateName: "kubernetes-dev", + templateDisplayName: "Kubernetes Dev", + status: "running", + }, + { + name: "frontend-v2", + templateName: "coder-on-coder", + templateDisplayName: "Coder on Coder", + status: "running", + }, +]; + +const mockParseError = "Unexpected token u in JSON at position 0"; +const notLoggedInMessage = "Run `coder login ` first."; + +const NEW_WORKSPACE_CONFIG: CoderWorkspaceConfig = { + existingWorkspace: false, + template: "coder-on-coder", + templateOrg: "default", +}; + +const EXISTING_WORKSPACE_CONFIG: CoderWorkspaceConfig = { + existingWorkspace: true, + workspaceName: undefined, +}; + +const baseCoderWorkspaceFormProps: CoderWorkspaceFormStoryProps = { + coderConfig: NEW_WORKSPACE_CONFIG, + templates: mockTemplates, + templatesError: null, + presets: mockPresetsCoderOnCoder, + presetsError: null, + existingWorkspaces: mockWorkspaces, + workspacesError: null, + loadingTemplates: false, + loadingPresets: false, + loadingWorkspaces: false, + disabled: false, + hasError: false, +}; + +function getCoderWorkspaceFormProps( + overrides: Partial = {} +): CoderWorkspaceFormStoryProps { + return { + ...baseCoderWorkspaceFormProps, + ...overrides, + }; +} + +function CoderWorkspaceFormStory(props: CoderWorkspaceFormStoryProps) { + const [coderConfig, setCoderConfig] = useState( + () => props.coderConfig + ); + + return ( +
+ +
+ ); +} + +function CoderAvailabilityMessageStory(props: CoderAvailabilityMessageStoryProps) { + return ( +
+ +
+ ); +} + +const meta = { + ...lightweightMeta, + title: "Features/Runtime/CoderControls", +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const NewWorkspace: Story = { + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await canvas.findByTestId("coder-controls-inner"); + await canvas.findByTestId("coder-template-select"); + await canvas.findByTestId("coder-preset-select"); + }, +}; + +export const ExistingWorkspace: Story = { + render: () => ( + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await canvas.findByTestId("coder-workspace-select"); + }, +}; + +export const TemplatesParseError: Story = { + render: () => ( + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await canvas.findByText(mockParseError); + await waitFor(() => { + const templateSelect = canvas.queryByTestId("coder-template-select"); + if (!templateSelect?.hasAttribute("data-disabled")) { + throw new Error("Expected template select to be disabled when templates fail to parse"); + } + }); + }, +}; + +export const PresetsParseError: Story = { + render: () => ( + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await canvas.findByText(mockParseError); + }, +}; + +export const ExistingWorkspaceParseError: Story = { + render: () => ( + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await canvas.findByText(mockParseError); + }, +}; + +export const NoPresets: Story = { + render: () => ( + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await waitFor(() => { + const presetSelect = canvas.queryByTestId("coder-preset-select"); + if (!presetSelect?.hasAttribute("data-disabled")) { + throw new Error("Expected preset select to be disabled when no presets are available"); + } + }); + }, +}; + +export const NoRunningWorkspaces: Story = { + render: () => ( + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await waitFor(() => { + const workspaceSelect = canvas.queryByTestId("coder-workspace-select"); + if (!workspaceSelect?.textContent?.includes("No workspaces found")) { + throw new Error('Expected workspace select to show "No workspaces found"'); + } + }); + }, +}; + +export const WithLoginInfo: Story = { + render: () => ( + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await canvas.findByText(/Logged in as/i); + await canvas.findByText("coder-user"); + await canvas.findByText("https://coder.example.com"); + }, +}; + +export const AvailabilityLoading: Story = { + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await canvas.findByText("Checking…"); + await waitFor(() => { + const spinner = canvasElement.querySelector(".animate-spin"); + if (!spinner) { + throw new Error("Expected loading spinner while Coder availability is being checked"); + } + }); + }, +}; + +export const AvailabilityOutdated: Story = { + render: () => ( + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const warning = await canvas.findByText("Coder CLI 2.20.0 is below minimum v2.25.0."); + await waitFor(() => { + if (!warning.className.includes("text-yellow-500")) { + throw new Error("Expected outdated availability message to use warning styling"); + } + }); + }, +}; + +export const AvailabilityUnavailableNotLoggedIn: Story = { + render: () => ( + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const warning = await canvas.findByText(notLoggedInMessage); + await waitFor(() => { + if (!warning.className.includes("text-yellow-500")) { + throw new Error("Expected unavailable-not-logged-in message to use warning styling"); + } + }); + }, +}; From 5e6522af774efaa4cef785dd06b000fc7f80a4d8 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Sun, 22 Mar 2026 15:48:43 +0000 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=A4=96=20tests:=20add=20runtime-selec?= =?UTF-8?q?tor=20Coder=20stories=20for=20CreationControls?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ChatInput/CreationControls.stories.tsx | 111 ++++++++++++++++++ .../features/ChatInput/CreationControls.tsx | 4 +- 2 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 src/browser/features/ChatInput/CreationControls.stories.tsx diff --git a/src/browser/features/ChatInput/CreationControls.stories.tsx b/src/browser/features/ChatInput/CreationControls.stories.tsx new file mode 100644 index 0000000000..4ecd0a0d6c --- /dev/null +++ b/src/browser/features/ChatInput/CreationControls.stories.tsx @@ -0,0 +1,111 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, fn, userEvent, within } from "@storybook/test"; +import { lightweightMeta } from "@/browser/stories/meta.js"; +import { RUNTIME_MODE } from "@/common/types/runtime"; +import { RuntimeButtonGroup, type RuntimeButtonGroupProps } from "./CreationControls"; + +const BASE_ARGS = { + value: RUNTIME_MODE.WORKTREE, + defaultMode: RUNTIME_MODE.WORKTREE, + onChange: fn(), + onSetDefault: fn(), + runtimeAvailabilityState: { + status: "loaded", + data: { + local: { available: true }, + worktree: { available: true }, + ssh: { available: true }, + docker: { available: true }, + devcontainer: { available: true }, + }, + }, +} satisfies RuntimeButtonGroupProps; + +const meta = { + ...lightweightMeta, + title: "App/Chat/Creation Controls", + component: RuntimeButtonGroup, + render: (args) => ( +
+
+ +
+
+ ), +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +async function openWorkspaceTypeMenu(storyRoot: HTMLElement): Promise { + const canvas = within(storyRoot); + const trigger = await canvas.findByLabelText("Workspace type", {}, { timeout: 10000 }); + await userEvent.click(trigger); +} + +/** Coder option is visible and selectable when Coder CLI is available. */ +export const CoderAvailable: Story = { + args: { + ...BASE_ARGS, + coderInfo: { + state: "available", + version: "2.28.0", + username: "coder-user", + url: "https://coder.example.com", + }, + }, + play: async ({ canvasElement }) => { + const storyRoot = document.getElementById("storybook-root") ?? canvasElement; + + await openWorkspaceTypeMenu(storyRoot); + await within(document.body).findByRole("option", { name: /^Coder/i }, { timeout: 10000 }); + await userEvent.keyboard("{Escape}"); + }, +}; + +/** Coder option is hidden when Coder CLI is missing. */ +export const CoderNotAvailable: Story = { + args: { + ...BASE_ARGS, + coderInfo: { + state: "unavailable", + reason: "missing", + }, + }, + play: async ({ canvasElement }) => { + const storyRoot = document.getElementById("storybook-root") ?? canvasElement; + + await openWorkspaceTypeMenu(storyRoot); + await within(document.body).findByRole("option", { name: /^SSH/i }, { timeout: 10000 }); + await expect(within(document.body).queryByRole("option", { name: /^Coder/i })).toBeNull(); + await userEvent.keyboard("{Escape}"); + }, +}; + +/** Coder option remains visible but disabled when CLI version is outdated. */ +export const CoderOutdated: Story = { + args: { + ...BASE_ARGS, + coderInfo: { + state: "outdated", + version: "2.20.0", + minVersion: "2.25.0", + }, + }, + play: async ({ canvasElement }) => { + const storyRoot = document.getElementById("storybook-root") ?? canvasElement; + + await openWorkspaceTypeMenu(storyRoot); + const coderOption = await within(document.body).findByRole( + "option", + { name: /^Coder/i }, + { timeout: 10000 } + ); + + await expect(coderOption).toHaveAttribute("aria-disabled", "true"); + await expect(coderOption).toHaveTextContent("2.20.0"); + await expect(coderOption).toHaveTextContent("2.25.0"); + await userEvent.keyboard("{Escape}"); + }, +}; diff --git a/src/browser/features/ChatInput/CreationControls.tsx b/src/browser/features/ChatInput/CreationControls.tsx index bc8062fb27..db4751f3d8 100644 --- a/src/browser/features/ChatInput/CreationControls.tsx +++ b/src/browser/features/ChatInput/CreationControls.tsx @@ -152,7 +152,7 @@ interface CreationControlsProps { } /** Runtime type button group with icons and colors */ -interface RuntimeButtonGroupProps { +export interface RuntimeButtonGroupProps { value: RuntimeChoice; onChange: (mode: RuntimeChoice) => void; defaultMode: RuntimeChoice; @@ -428,7 +428,7 @@ function SectionPicker(props: SectionPickerProps) { ); } -function RuntimeButtonGroup(props: RuntimeButtonGroupProps) { +export function RuntimeButtonGroup(props: RuntimeButtonGroupProps) { const state = props.runtimeAvailabilityState; const availabilityMap = state?.status === "loaded" ? state.data : null; const coderInfo = props.coderInfo ?? null; From ca441fe6871b2532f5dcd9a0d00e4eb241cdca26 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Sun, 22 Mar 2026 15:54:51 +0000 Subject: [PATCH 3/3] refactor: share coder story fixtures and delete legacy App coder story --- .../ChatInput/CreationControls.stories.tsx | 23 +- .../Runtime/CoderControls.stories.tsx | 93 +-- src/browser/stories/App.coder.stories.tsx | 574 ------------------ src/browser/stories/mocks/coder.ts | 74 +++ 4 files changed, 101 insertions(+), 663 deletions(-) delete mode 100644 src/browser/stories/App.coder.stories.tsx create mode 100644 src/browser/stories/mocks/coder.ts diff --git a/src/browser/features/ChatInput/CreationControls.stories.tsx b/src/browser/features/ChatInput/CreationControls.stories.tsx index 4ecd0a0d6c..35c44951d0 100644 --- a/src/browser/features/ChatInput/CreationControls.stories.tsx +++ b/src/browser/features/ChatInput/CreationControls.stories.tsx @@ -1,6 +1,11 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, fn, userEvent, within } from "@storybook/test"; import { lightweightMeta } from "@/browser/stories/meta.js"; +import { + mockCoderInfoAvailable, + mockCoderInfoMissing, + mockCoderInfoOutdated, +} from "@/browser/stories/mocks/coder"; import { RUNTIME_MODE } from "@/common/types/runtime"; import { RuntimeButtonGroup, type RuntimeButtonGroupProps } from "./CreationControls"; @@ -48,12 +53,7 @@ async function openWorkspaceTypeMenu(storyRoot: HTMLElement): Promise { export const CoderAvailable: Story = { args: { ...BASE_ARGS, - coderInfo: { - state: "available", - version: "2.28.0", - username: "coder-user", - url: "https://coder.example.com", - }, + coderInfo: mockCoderInfoAvailable, }, play: async ({ canvasElement }) => { const storyRoot = document.getElementById("storybook-root") ?? canvasElement; @@ -68,10 +68,7 @@ export const CoderAvailable: Story = { export const CoderNotAvailable: Story = { args: { ...BASE_ARGS, - coderInfo: { - state: "unavailable", - reason: "missing", - }, + coderInfo: mockCoderInfoMissing, }, play: async ({ canvasElement }) => { const storyRoot = document.getElementById("storybook-root") ?? canvasElement; @@ -87,11 +84,7 @@ export const CoderNotAvailable: Story = { export const CoderOutdated: Story = { args: { ...BASE_ARGS, - coderInfo: { - state: "outdated", - version: "2.20.0", - minVersion: "2.25.0", - }, + coderInfo: mockCoderInfoOutdated, }, play: async ({ canvasElement }) => { const storyRoot = document.getElementById("storybook-root") ?? canvasElement; diff --git a/src/browser/features/Runtime/CoderControls.stories.tsx b/src/browser/features/Runtime/CoderControls.stories.tsx index 66b0a7ca93..cc632314b6 100644 --- a/src/browser/features/Runtime/CoderControls.stories.tsx +++ b/src/browser/features/Runtime/CoderControls.stories.tsx @@ -3,12 +3,14 @@ import { waitFor, within } from "@storybook/test"; import { useState } from "react"; import { lightweightMeta } from "@/browser/stories/meta.js"; -import type { - CoderInfo, - CoderPreset, - CoderTemplate, - CoderWorkspace, -} from "@/common/orpc/schemas/coder"; +import { + mockCoderInfoOutdated, + mockCoderParseError, + mockCoderPresetsCoderOnCoder, + mockCoderTemplates, + mockCoderWorkspaces, +} from "@/browser/stories/mocks/coder"; +import type { CoderInfo } from "@/common/orpc/schemas/coder"; import type { CoderWorkspaceConfig } from "@/common/types/runtime"; import { @@ -23,55 +25,6 @@ interface CoderAvailabilityMessageStoryProps { coderInfo: CoderInfo | null; } -const mockTemplates: CoderTemplate[] = [ - { name: "coder-on-coder", displayName: "Coder on Coder", organizationName: "default" }, - { name: "kubernetes-dev", displayName: "Kubernetes Development", organizationName: "default" }, - { name: "aws-windows", displayName: "AWS Windows Instance", organizationName: "default" }, -]; - -const mockPresetsCoderOnCoder: CoderPreset[] = [ - { - id: "preset-sydney", - name: "Sydney", - description: "Australia region", - isDefault: false, - }, - { - id: "preset-helsinki", - name: "Helsinki", - description: "Europe region", - isDefault: false, - }, - { - id: "preset-pittsburgh", - name: "Pittsburgh", - description: "US East region", - isDefault: true, - }, -]; - -const mockWorkspaces: CoderWorkspace[] = [ - { - name: "mux-dev", - templateName: "coder-on-coder", - templateDisplayName: "Coder on Coder", - status: "running", - }, - { - name: "api-testing", - templateName: "kubernetes-dev", - templateDisplayName: "Kubernetes Dev", - status: "running", - }, - { - name: "frontend-v2", - templateName: "coder-on-coder", - templateDisplayName: "Coder on Coder", - status: "running", - }, -]; - -const mockParseError = "Unexpected token u in JSON at position 0"; const notLoggedInMessage = "Run `coder login ` first."; const NEW_WORKSPACE_CONFIG: CoderWorkspaceConfig = { @@ -87,11 +40,11 @@ const EXISTING_WORKSPACE_CONFIG: CoderWorkspaceConfig = { const baseCoderWorkspaceFormProps: CoderWorkspaceFormStoryProps = { coderConfig: NEW_WORKSPACE_CONFIG, - templates: mockTemplates, + templates: mockCoderTemplates, templatesError: null, - presets: mockPresetsCoderOnCoder, + presets: mockCoderPresetsCoderOnCoder, presetsError: null, - existingWorkspaces: mockWorkspaces, + existingWorkspaces: mockCoderWorkspaces, workspacesError: null, loadingTemplates: false, loadingPresets: false, @@ -171,7 +124,7 @@ export const TemplatesParseError: Story = { @@ -179,7 +132,7 @@ export const TemplatesParseError: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); - await canvas.findByText(mockParseError); + await canvas.findByText(mockCoderParseError); await waitFor(() => { const templateSelect = canvas.queryByTestId("coder-template-select"); if (!templateSelect?.hasAttribute("data-disabled")) { @@ -194,14 +147,14 @@ export const PresetsParseError: Story = { ), play: async ({ canvasElement }) => { const canvas = within(canvasElement); - await canvas.findByText(mockParseError); + await canvas.findByText(mockCoderParseError); }, }; @@ -211,14 +164,14 @@ export const ExistingWorkspaceParseError: Story = { {...getCoderWorkspaceFormProps({ coderConfig: EXISTING_WORKSPACE_CONFIG, existingWorkspaces: [], - workspacesError: mockParseError, + workspacesError: mockCoderParseError, })} /> ), play: async ({ canvasElement }) => { const canvas = within(canvasElement); - await canvas.findByText(mockParseError); + await canvas.findByText(mockCoderParseError); }, }; @@ -226,7 +179,7 @@ export const NoPresets: Story = { render: () => ( @@ -298,15 +251,7 @@ export const AvailabilityLoading: Story = { }; export const AvailabilityOutdated: Story = { - render: () => ( - - ), + render: () => , play: async ({ canvasElement }) => { const canvas = within(canvasElement); diff --git a/src/browser/stories/App.coder.stories.tsx b/src/browser/stories/App.coder.stories.tsx deleted file mode 100644 index d485bc523c..0000000000 --- a/src/browser/stories/App.coder.stories.tsx +++ /dev/null @@ -1,574 +0,0 @@ -/** - * Coder workspace integration stories. - * Tests the UI for creating and connecting to Coder cloud workspaces. - */ - -import { within, userEvent, waitFor } from "@storybook/test"; - -import { appMeta, AppWithMocks, type AppStory } from "./meta.js"; -import { createMockORPCClient } from "@/browser/stories/mocks/orpc"; -import { expandProjects } from "./storyHelpers"; -import type { ProjectConfig } from "@/node/config"; -import type { CoderTemplate, CoderPreset, CoderWorkspace } from "@/common/orpc/schemas/coder"; -import { getLastRuntimeConfigKey, getRuntimeKey } from "@/common/constants/storage"; - -async function openProjectCreationView(storyRoot: HTMLElement): Promise { - // App now boots into the built-in mux-chat workspace. - // Navigate to the project creation page so runtime controls are visible. - if (typeof localStorage !== "undefined") { - // Ensure runtime selection state doesn't leak between stories. - localStorage.removeItem(getLastRuntimeConfigKey("/Users/dev/my-project")); - localStorage.removeItem(getRuntimeKey("/Users/dev/my-project")); - } - - const projectRow = await waitFor( - () => { - const el = storyRoot.querySelector( - '[data-project-path="/Users/dev/my-project"][aria-controls]' - ); - if (!el) throw new Error("Project row not found"); - return el; - }, - { timeout: 10_000 } - ); - - await userEvent.click(projectRow); -} - -async function openWorkspaceTypeMenu(storyRoot: HTMLElement): Promise { - const canvas = within(storyRoot); - await canvas.findByRole("group", { name: "Runtime type" }, { timeout: 10000 }); - const trigger = await canvas.findByLabelText("Workspace type", {}, { timeout: 10000 }); - await userEvent.click(trigger); -} - -async function selectWorkspaceType(storyRoot: HTMLElement, label: string): Promise { - await openWorkspaceTypeMenu(storyRoot); - const option = await within(document.body).findByRole( - "option", - { name: new RegExp(`^${label}`, "i") }, - { timeout: 10000 } - ); - await userEvent.click(option); -} - -async function findWorkspaceTypeOption(label: string): Promise { - return within(document.body).findByRole( - "option", - { name: new RegExp(`^${label}`, "i") }, - { timeout: 10000 } - ); -} -export default { - ...appMeta, - title: "App/Coder", -}; - -/** Helper to create a project config for a path with no workspaces */ -function projectWithNoWorkspaces(path: string): [string, ProjectConfig] { - return [path, { workspaces: [] }]; -} - -/** Mock Coder templates */ -const mockTemplates: CoderTemplate[] = [ - { - name: "coder-on-coder", - displayName: "Coder on Coder", - organizationName: "default", - }, - { - name: "kubernetes-dev", - displayName: "Kubernetes Development", - organizationName: "default", - }, - { - name: "aws-windows", - displayName: "AWS Windows Instance", - organizationName: "default", - }, -]; - -/** Mock presets for coder-on-coder template */ -const mockPresetsCoderOnCoder: CoderPreset[] = [ - { - id: "preset-sydney", - name: "Sydney", - description: "Australia region", - isDefault: false, - }, - { - id: "preset-helsinki", - name: "Helsinki", - description: "Europe region", - isDefault: false, - }, - { - id: "preset-pittsburgh", - name: "Pittsburgh", - description: "US East region", - isDefault: true, - }, -]; - -/** Mock presets for kubernetes template (only one) */ -const mockPresetsK8s: CoderPreset[] = [ - { - id: "preset-k8s-1", - name: "Standard", - description: "Default configuration", - isDefault: true, - }, -]; - -/** Mock existing Coder workspaces */ -const mockWorkspaces: CoderWorkspace[] = [ - { - name: "mux-dev", - templateName: "coder-on-coder", - templateDisplayName: "Coder on Coder", - status: "running", - }, - { - name: "api-testing", - templateName: "kubernetes-dev", - templateDisplayName: "Kubernetes Dev", - status: "running", - }, - { - name: "frontend-v2", - templateName: "coder-on-coder", - templateDisplayName: "Coder on Coder", - status: "running", - }, -]; - -const mockParseError = "Unexpected token u in JSON at position 0"; - -const mockCoderInfo = { - state: "available" as const, - version: "2.28.0", - // Include username + URL so Storybook renders the logged-in label in Coder stories. - username: "coder-user", - url: "https://coder.example.com", -}; - -/** - * Coder available - shows Coder runtime button. - * When Coder CLI is available, the Coder button appears in the runtime selector. - */ -export const SSHWithCoderAvailable: AppStory = { - render: () => ( - { - expandProjects(["/Users/dev/my-project"]); - return createMockORPCClient({ - projects: new Map([projectWithNoWorkspaces("/Users/dev/my-project")]), - workspaces: [], - coderInfo: mockCoderInfo, - coderTemplates: mockTemplates, - coderPresets: new Map([ - ["coder-on-coder", mockPresetsCoderOnCoder], - ["kubernetes-dev", mockPresetsK8s], - ["aws-windows", []], - ]), - coderWorkspaces: mockWorkspaces, - }); - }} - /> - ), - play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { - const storyRoot = document.getElementById("storybook-root") ?? canvasElement; - await openProjectCreationView(storyRoot); - - // Coder option should appear when Coder CLI is available. - await openWorkspaceTypeMenu(storyRoot); - await findWorkspaceTypeOption("Coder"); - await userEvent.keyboard("{Escape}"); - }, -}; - -/** - * Coder new workspace flow - shows template and preset dropdowns. - * User clicks Coder runtime button, then selects template and optionally a preset. - */ -export const CoderNewWorkspace: AppStory = { - render: () => ( - { - expandProjects(["/Users/dev/my-project"]); - return createMockORPCClient({ - projects: new Map([projectWithNoWorkspaces("/Users/dev/my-project")]), - workspaces: [], - coderInfo: mockCoderInfo, - coderTemplates: mockTemplates, - coderPresets: new Map([ - ["coder-on-coder", mockPresetsCoderOnCoder], - ["kubernetes-dev", mockPresetsK8s], - ["aws-windows", []], - ]), - coderWorkspaces: mockWorkspaces, - }); - }} - /> - ), - play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { - const storyRoot = document.getElementById("storybook-root") ?? canvasElement; - await openProjectCreationView(storyRoot); - const canvas = within(storyRoot); - - // Select Coder runtime from Workspace Type dropdown. - await selectWorkspaceType(storyRoot, "Coder"); - - // Wait for Coder controls to appear - await canvas.findByTestId("coder-controls-inner"); - - // The template dropdown should be visible with templates loaded - await canvas.findByTestId("coder-template-select"); - }, -}; - -/** - * Coder existing workspace flow - shows workspace dropdown. - * User clicks Coder runtime, switches to "Existing" mode and selects from running workspaces. - */ -export const CoderExistingWorkspace: AppStory = { - render: () => ( - { - expandProjects(["/Users/dev/my-project"]); - return createMockORPCClient({ - projects: new Map([projectWithNoWorkspaces("/Users/dev/my-project")]), - workspaces: [], - coderInfo: mockCoderInfo, - coderTemplates: mockTemplates, - coderPresets: new Map([ - ["coder-on-coder", mockPresetsCoderOnCoder], - ["kubernetes-dev", mockPresetsK8s], - ["aws-windows", []], - ]), - coderWorkspaces: mockWorkspaces, - }); - }} - /> - ), - play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { - const storyRoot = document.getElementById("storybook-root") ?? canvasElement; - await openProjectCreationView(storyRoot); - const canvas = within(storyRoot); - - // Select Coder runtime from Workspace Type dropdown. - await selectWorkspaceType(storyRoot, "Coder"); - - // Wait for Coder controls - await canvas.findByTestId("coder-controls-inner"); - - // Click "Existing" button — use findByRole (retry-capable) to handle - // transient DOM gaps between awaits. - const existingButton = await canvas.findByRole("button", { name: "Existing" }); - await userEvent.click(existingButton); - - // Wait for workspace dropdown to appear - await canvas.findByTestId("coder-workspace-select"); - }, -}; - -/** - * Coder existing workspace flow with parse error. - * Shows the error state when listing workspaces fails to parse. - */ -export const CoderExistingWorkspaceParseError: AppStory = { - render: () => ( - { - expandProjects(["/Users/dev/my-project"]); - return createMockORPCClient({ - projects: new Map([projectWithNoWorkspaces("/Users/dev/my-project")]), - workspaces: [], - coderInfo: mockCoderInfo, - coderTemplates: mockTemplates, - coderPresets: new Map([ - ["coder-on-coder", mockPresetsCoderOnCoder], - ["kubernetes-dev", mockPresetsK8s], - ["aws-windows", []], - ]), - coderWorkspaces: mockWorkspaces, - coderWorkspacesResult: { ok: false, error: mockParseError }, - }); - }} - /> - ), - play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { - const storyRoot = document.getElementById("storybook-root") ?? canvasElement; - await openProjectCreationView(storyRoot); - const canvas = within(storyRoot); - - // Select Coder runtime from Workspace Type dropdown. - await selectWorkspaceType(storyRoot, "Coder"); - - // Wait for Coder controls - await canvas.findByTestId("coder-controls-inner"); - - // Click "Existing" button — use findByRole (retry-capable) to handle - // transient DOM gaps between awaits. - const existingButton = await canvas.findByRole("button", { name: "Existing" }); - await userEvent.click(existingButton); - - // Error message should appear for workspace listing - await canvas.findByText(mockParseError); - }, -}; - -/** - * Coder new workspace flow with template parse error. - * Shows the error state when listing templates fails to parse. - */ -export const CoderTemplatesParseError: AppStory = { - render: () => ( - { - expandProjects(["/Users/dev/my-project"]); - return createMockORPCClient({ - projects: new Map([projectWithNoWorkspaces("/Users/dev/my-project")]), - workspaces: [], - coderInfo: mockCoderInfo, - coderTemplatesResult: { ok: false, error: mockParseError }, - coderWorkspaces: mockWorkspaces, - }); - }} - /> - ), - play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { - const storyRoot = document.getElementById("storybook-root") ?? canvasElement; - await openProjectCreationView(storyRoot); - const canvas = within(storyRoot); - - // Select Coder runtime from Workspace Type dropdown. - await selectWorkspaceType(storyRoot, "Coder"); - - // Wait for Coder controls - await canvas.findByTestId("coder-controls-inner"); - - await canvas.findByText(mockParseError); - - // Re-query inside waitFor to avoid stale DOM refs after React re-renders. - await waitFor(() => { - const templateSelect = canvas.queryByTestId("coder-template-select"); - if (!templateSelect?.hasAttribute("data-disabled")) { - throw new Error("Template dropdown should be disabled when templates fail to load"); - } - }); - }, -}; - -/** - * Coder new workspace flow with preset parse error. - * Shows the error state when listing presets fails to parse. - */ -export const CoderPresetsParseError: AppStory = { - render: () => ( - { - expandProjects(["/Users/dev/my-project"]); - return createMockORPCClient({ - projects: new Map([projectWithNoWorkspaces("/Users/dev/my-project")]), - workspaces: [], - coderInfo: mockCoderInfo, - coderTemplates: mockTemplates, - coderPresets: new Map([ - ["coder-on-coder", mockPresetsCoderOnCoder], - ["kubernetes-dev", mockPresetsK8s], - ["aws-windows", []], - ]), - coderPresetsResult: new Map([["coder-on-coder", { ok: false, error: mockParseError }]]), - coderWorkspaces: mockWorkspaces, - }); - }} - /> - ), - play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { - const storyRoot = document.getElementById("storybook-root") ?? canvasElement; - await openProjectCreationView(storyRoot); - const canvas = within(storyRoot); - - // Select Coder runtime from Workspace Type dropdown. - await selectWorkspaceType(storyRoot, "Coder"); - - // Wait for Coder controls and template select - await canvas.findByTestId("coder-controls-inner"); - await canvas.findByTestId("coder-template-select"); - - await canvas.findByText(mockParseError); - }, -}; - -/** - * Coder not available - Coder button should not appear. - * When Coder CLI is not installed, the runtime selector only shows SSH (no Coder). - */ -export const CoderNotAvailable: AppStory = { - render: () => ( - { - expandProjects(["/Users/dev/my-project"]); - return createMockORPCClient({ - projects: new Map([projectWithNoWorkspaces("/Users/dev/my-project")]), - workspaces: [], - coderInfo: { state: "unavailable", reason: "missing" }, - }); - }} - /> - ), - play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { - const storyRoot = document.getElementById("storybook-root") ?? canvasElement; - await openProjectCreationView(storyRoot); - - await openWorkspaceTypeMenu(storyRoot); - - // SSH option should be present. - await findWorkspaceTypeOption("SSH"); - - // Coder option should NOT appear when Coder CLI is unavailable. - const coderOption = within(document.body).queryByRole("option", { name: /^Coder/i }); - if (coderOption) { - throw new Error("Coder option should not appear when Coder CLI is unavailable"); - } - - await userEvent.keyboard("{Escape}"); - }, -}; - -/** - * Coder CLI outdated - Coder button appears but is disabled with tooltip. - * When Coder CLI is installed but version is below minimum, shows explanation on hover. - */ -export const CoderOutdated: AppStory = { - render: () => ( - { - expandProjects(["/Users/dev/my-project"]); - return createMockORPCClient({ - projects: new Map([projectWithNoWorkspaces("/Users/dev/my-project")]), - workspaces: [], - coderInfo: { state: "outdated", version: "2.20.0", minVersion: "2.25.0" }, - }); - }} - /> - ), - play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { - const storyRoot = document.getElementById("storybook-root") ?? canvasElement; - await openProjectCreationView(storyRoot); - - await openWorkspaceTypeMenu(storyRoot); - - // Coder option should appear but be disabled. - const coderOption = await findWorkspaceTypeOption("Coder"); - if (coderOption.getAttribute("aria-disabled") !== "true") { - throw new Error("Coder option should be disabled when CLI is outdated"); - } - - if (!coderOption.textContent?.includes("2.20.0")) { - throw new Error("Coder option should mention the current CLI version"); - } - if (!coderOption.textContent?.includes("2.25.0")) { - throw new Error("Coder option should mention the minimum required version"); - } - - await userEvent.keyboard("{Escape}"); - }, -}; - -/** - * Coder with template that has no presets. - * When selecting a template with 0 presets, the preset dropdown is visible but disabled. - */ -export const CoderNoPresets: AppStory = { - render: () => ( - { - expandProjects(["/Users/dev/my-project"]); - return createMockORPCClient({ - projects: new Map([projectWithNoWorkspaces("/Users/dev/my-project")]), - workspaces: [], - coderInfo: mockCoderInfo, - coderTemplates: [ - { name: "simple-vm", displayName: "Simple VM", organizationName: "default" }, - ], - coderPresets: new Map([["simple-vm", []]]), - coderWorkspaces: [], - }); - }} - /> - ), - play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { - const storyRoot = document.getElementById("storybook-root") ?? canvasElement; - await openProjectCreationView(storyRoot); - const canvas = within(storyRoot); - - // Select Coder runtime from Workspace Type dropdown. - await selectWorkspaceType(storyRoot, "Coder"); - - // Wait for Coder controls - await canvas.findByTestId("coder-controls-inner"); - - // Template dropdown should be visible - await canvas.findByTestId("coder-template-select"); - - // Preset dropdown should be visible but disabled (shows "No presets" placeholder). - // Re-query inside waitFor to avoid stale DOM refs after React re-renders. - await waitFor(() => { - // Radix UI Select sets data-disabled="" (empty string) when disabled - const presetSelect = canvas.queryByTestId("coder-preset-select"); - if (!presetSelect?.hasAttribute("data-disabled")) { - throw new Error("Preset dropdown should be disabled when template has no presets"); - } - }); - }, -}; - -/** - * Coder with no running workspaces. - * When switching to "Existing" mode with no running workspaces, shows empty state. - */ -export const CoderNoRunningWorkspaces: AppStory = { - render: () => ( - { - expandProjects(["/Users/dev/my-project"]); - return createMockORPCClient({ - projects: new Map([projectWithNoWorkspaces("/Users/dev/my-project")]), - workspaces: [], - coderInfo: mockCoderInfo, - coderTemplates: mockTemplates, - coderPresets: new Map([ - ["coder-on-coder", mockPresetsCoderOnCoder], - ["kubernetes-dev", mockPresetsK8s], - ]), - coderWorkspaces: [], // No running workspaces - }); - }} - /> - ), - play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { - const storyRoot = document.getElementById("storybook-root") ?? canvasElement; - await openProjectCreationView(storyRoot); - const canvas = within(storyRoot); - - // Select Coder runtime from Workspace Type dropdown. - await selectWorkspaceType(storyRoot, "Coder"); - - // Click "Existing" button - const existingButton = await canvas.findByRole("button", { name: "Existing" }); - await userEvent.click(existingButton); - - // Workspace dropdown should show "No workspaces found" placeholder. - // Note: Radix UI Select doesn't render native