Skip to content

Commit fda7812

Browse files
authored
πŸ€– tests: migrate settings stories to isolated colocated sections (#3045)
## Summary Replace the monolithic `App.settings.stories.tsx` (which used `AppWithMocks` + play-function navigation) with 10 colocated story files β€” one per settings section component β€” that render sections directly in isolation. ## Background Settings stories previously booted the full app shell via `AppWithMocks`, then used play functions to click through to each section. This was slow, fragile (navigation-dependent), and coupled stories to the app's routing/layout implementation. Colocated stories render each section directly with only the providers it needs, making them faster, more maintainable, and easier to iterate on. ## Implementation **Shared harness** (`settingsStoryUtils.tsx`): - `SettingsSectionStory` wrapper that composes the required provider tree (`APIProvider` β†’ `PolicyProvider` β†’ `RouterProvider` β†’ `ProjectProvider` β†’ `WorkspaceProvider` β†’ `ExperimentsProvider` β†’ `UILayoutsProvider` β†’ `SettingsProvider` β†’ `ProviderOptionsProvider` β†’ `ConfirmDialogProvider`) - Ported `setupSettingsStory()`, `setupSecurityStory()`, and `MOCK_SERVER_AUTH_SESSIONS` from the old file **10 colocated story files** (all in `src/browser/features/Settings/Sections/`): | File | Stories | |---|---| | `GeneralSection.stories.tsx` | `General` | | `TasksSection.stories.tsx` | `Tasks` | | `ProvidersSection.stories.tsx` | `ProvidersEmpty`, `ProvidersConfigured`, `ProvidersExpanded` | | `ModelsSection.stories.tsx` | `ModelsEmpty`, `ModelsConfigured` | | `LayoutsSection.stories.tsx` | `LayoutsEmpty`, `LayoutsConfigured` | | `System1Section.stories.tsx` | `System1` | | `ServerAccessSection.stories.tsx` | `ServerAccess` | | `ExperimentsSection.stories.tsx` | `Experiments`, `ExperimentsToggleOn`, `ExperimentsToggleOff` | | `KeybindsSection.stories.tsx` | `Keybinds` | | `SecuritySection.stories.tsx` | `SecurityEmpty`, `SecurityMixedTrust`, `SecurityAllTrusted`, `SecurityAllUntrusted` | **Cleanup:** - Deleted `src/browser/stories/App.settings.stories.tsx` - Updated `docs/reference/storybook.mdx` references - Regenerated `builtInSkillContent.generated.ts` ## Validation - `make typecheck` βœ… - `make lint` βœ… - `make storybook-build` βœ… - Verified no `AppWithMocks` imports in new story files - Verified no remaining references to `App.settings.stories` --- _Generated with `mux` β€’ Model: `anthropic:claude-opus-4-6` β€’ Thinking: `xhigh` β€’ Cost: `$6.83`_ <!-- mux-attribution: model=anthropic:claude-opus-4-6 thinking=xhigh costs=6.83 -->
1 parent 8a14c47 commit fda7812

16 files changed

+1108
-783
lines changed

β€Žbun.lockβ€Ž

Lines changed: 36 additions & 31 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

β€Ždocs/reference/storybook.mdxβ€Ž

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,14 @@ bun run storybook:build
2727

2828
The output will be in `storybook-static/`.
2929

30-
## Writing Stories (full app only)
30+
## Writing Stories
3131

32-
Mux intentionally uses **full-app** stories (no isolated component stories).
32+
Mux uses two Storybook patterns:
3333

34-
Stories live in `src/browser/stories/` and must be named `App.*.stories.tsx`.
34+
- **Full-app stories** for integration/routing coverage (for example `src/browser/stories/App.*.stories.tsx`)
35+
- **Colocated isolated stories** for Settings section-level testing (for example `src/browser/features/Settings/Sections/*.stories.tsx`)
3536

36-
Storybook is configured to load `src/browser/stories/**/*.stories.@(ts|tsx)` (see `.storybook/main.ts`).
37+
Storybook loads stories from `src/browser/stories/**/*.stories.@(ts|tsx)`, `src/browser/components/**/*.stories.@(ts|tsx)`, and `src/browser/features/**/*.stories.@(ts|tsx)` (see `.storybook/main.ts`).
3738

3839
### Basic App story structure
3940

@@ -81,7 +82,7 @@ See the existing stories for patterns:
8182

8283
- `src/browser/stories/App.sidebar.stories.tsx`
8384
- `src/browser/stories/App.chat.stories.tsx`
84-
- `src/browser/stories/App.settings.stories.tsx`
85+
- `src/browser/features/Settings/Sections/<SectionName>.stories.tsx`
8586

8687
## Configuration
8788

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { lightweightMeta } from "@/browser/stories/meta.js";
2+
import { EXPERIMENT_IDS } from "@/common/constants/experiments";
3+
import type { Meta, StoryObj } from "@storybook/react-vite";
4+
import { ExperimentsSection } from "./ExperimentsSection.js";
5+
import { SettingsSectionStory, setupSettingsStory } from "./settingsStoryUtils.js";
6+
7+
const meta: Meta = {
8+
...lightweightMeta,
9+
title: "Settings/Sections/ExperimentsSection",
10+
component: ExperimentsSection,
11+
};
12+
13+
export default meta;
14+
type Story = StoryObj<typeof meta>;
15+
16+
export const Experiments: Story = {
17+
render: () => (
18+
<SettingsSectionStory setup={() => setupSettingsStory({})}>
19+
<ExperimentsSection />
20+
</SettingsSectionStory>
21+
),
22+
};
23+
24+
export const ExperimentsToggleOn: Story = {
25+
render: () => (
26+
<SettingsSectionStory
27+
setup={() =>
28+
setupSettingsStory({
29+
experiments: { [EXPERIMENT_IDS.SYSTEM_1]: true },
30+
})
31+
}
32+
>
33+
<ExperimentsSection />
34+
</SettingsSectionStory>
35+
),
36+
};
37+
38+
export const ExperimentsToggleOff: Story = {
39+
render: () => (
40+
<SettingsSectionStory setup={() => setupSettingsStory({})}>
41+
<ExperimentsSection />
42+
</SettingsSectionStory>
43+
),
44+
};
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { lightweightMeta } from "@/browser/stories/meta.js";
2+
import type { Meta, StoryObj } from "@storybook/react-vite";
3+
import { within } from "@storybook/test";
4+
import { GeneralSection } from "./GeneralSection.js";
5+
import { SettingsSectionStory, setupSettingsStory } from "./settingsStoryUtils.js";
6+
7+
const meta: Meta = {
8+
...lightweightMeta,
9+
title: "Settings/Sections/GeneralSection",
10+
component: GeneralSection,
11+
};
12+
13+
export default meta;
14+
type Story = StoryObj<typeof meta>;
15+
16+
export const General: Story = {
17+
render: () => (
18+
<SettingsSectionStory setup={() => setupSettingsStory({})}>
19+
<GeneralSection />
20+
</SettingsSectionStory>
21+
),
22+
play: async ({ canvasElement }) => {
23+
const canvas = within(canvasElement);
24+
25+
await canvas.findByText(/^Theme$/i);
26+
await canvas.findByText(/^Terminal Font$/i);
27+
await canvas.findByText(/^Terminal Font Size$/i);
28+
},
29+
};
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { lightweightMeta } from "@/browser/stories/meta.js";
2+
import type { Meta, StoryObj } from "@storybook/react-vite";
3+
import { KeybindsSection } from "./KeybindsSection.js";
4+
5+
const meta: Meta = {
6+
...lightweightMeta,
7+
title: "Settings/Sections/KeybindsSection",
8+
component: KeybindsSection,
9+
};
10+
11+
export default meta;
12+
type Story = StoryObj<typeof meta>;
13+
14+
export const Keybinds: Story = {
15+
render: () => <KeybindsSection />,
16+
};
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { lightweightMeta } from "@/browser/stories/meta.js";
2+
import type { Meta, StoryObj } from "@storybook/react-vite";
3+
import { within } from "@storybook/test";
4+
import { LayoutsSection } from "./LayoutsSection.js";
5+
import { SettingsSectionStory, setupSettingsStory } from "./settingsStoryUtils.js";
6+
7+
const meta: Meta = {
8+
...lightweightMeta,
9+
title: "Settings/Sections/LayoutsSection",
10+
component: LayoutsSection,
11+
};
12+
13+
export default meta;
14+
type Story = StoryObj<typeof meta>;
15+
16+
export const LayoutsEmpty: Story = {
17+
render: () => (
18+
<SettingsSectionStory
19+
setup={() =>
20+
setupSettingsStory({
21+
layoutPresets: {
22+
version: 2,
23+
slots: [],
24+
},
25+
})
26+
}
27+
>
28+
<LayoutsSection />
29+
</SettingsSectionStory>
30+
),
31+
play: async ({ canvasElement }) => {
32+
const canvas = within(canvasElement);
33+
34+
await canvas.findByRole("heading", { name: /layout slots/i });
35+
await canvas.findByText(/^Add layout$/i);
36+
37+
if (canvas.queryByText(/Slot 1/i)) {
38+
throw new Error("Expected no slot rows to be rendered in the empty state");
39+
}
40+
},
41+
};
42+
43+
export const LayoutsConfigured: Story = {
44+
render: () => (
45+
<SettingsSectionStory
46+
setup={() =>
47+
setupSettingsStory({
48+
layoutPresets: {
49+
version: 2,
50+
slots: [
51+
{
52+
slot: 1,
53+
preset: {
54+
id: "preset-1",
55+
name: "My Layout",
56+
leftSidebarCollapsed: false,
57+
rightSidebar: {
58+
collapsed: false,
59+
width: { mode: "px", value: 420 },
60+
layout: {
61+
version: 1,
62+
nextId: 2,
63+
focusedTabsetId: "tabset-1",
64+
root: {
65+
type: "tabset",
66+
id: "tabset-1",
67+
tabs: ["costs", "review", "terminal_new:t1"],
68+
activeTab: "review",
69+
},
70+
},
71+
},
72+
},
73+
},
74+
{
75+
slot: 10,
76+
preset: {
77+
id: "preset-10",
78+
name: "Extra Layout",
79+
leftSidebarCollapsed: false,
80+
rightSidebar: {
81+
collapsed: true,
82+
width: { mode: "px", value: 400 },
83+
layout: {
84+
version: 1,
85+
nextId: 2,
86+
focusedTabsetId: "tabset-1",
87+
root: {
88+
type: "tabset",
89+
id: "tabset-1",
90+
tabs: ["costs"],
91+
activeTab: "costs",
92+
},
93+
},
94+
},
95+
},
96+
},
97+
],
98+
},
99+
})
100+
}
101+
>
102+
<LayoutsSection />
103+
</SettingsSectionStory>
104+
),
105+
play: async ({ canvasElement }) => {
106+
const canvas = within(canvasElement);
107+
108+
await canvas.findByRole("heading", { name: /layout slots/i });
109+
110+
await canvas.findByText(/My Layout/i);
111+
await canvas.findByText(/Extra Layout/i);
112+
await canvas.findByText(/^Slot 1$/i);
113+
await canvas.findByText(/^Slot 10$/i);
114+
await canvas.findByText(/^Add layout$/i);
115+
116+
if (canvas.queryByText(/Slot 2/i)) {
117+
throw new Error("Expected only configured layouts to render");
118+
}
119+
},
120+
};
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
/**
2+
* NOTE: ModelsSection contains an in-app CTA ("Go to Agent defaults") that calls
3+
* openSettings("tasks"). In isolated section stories the CTA navigates to the
4+
* agents section via settings context, but the visual section swap is only
5+
* exercised in the SettingsPage smoke story.
6+
*/
7+
import { lightweightMeta } from "@/browser/stories/meta.js";
8+
import type { Meta, StoryObj } from "@storybook/react-vite";
9+
import { waitFor, within } from "@storybook/test";
10+
import { ModelsSection } from "./ModelsSection.js";
11+
import { SettingsSectionStory, setupSettingsStory } from "./settingsStoryUtils.js";
12+
13+
const meta: Meta = {
14+
...lightweightMeta,
15+
title: "Settings/Sections/ModelsSection",
16+
component: ModelsSection,
17+
};
18+
19+
export default meta;
20+
type Story = StoryObj<typeof meta>;
21+
22+
export const ModelsEmpty: Story = {
23+
render: () => (
24+
<SettingsSectionStory
25+
setup={() =>
26+
setupSettingsStory({
27+
providersConfig: {
28+
anthropic: {
29+
apiKeySet: true,
30+
isEnabled: true,
31+
isConfigured: true,
32+
baseUrl: "",
33+
models: [],
34+
},
35+
openai: {
36+
apiKeySet: true,
37+
isEnabled: true,
38+
isConfigured: true,
39+
baseUrl: "",
40+
models: [],
41+
},
42+
},
43+
})
44+
}
45+
>
46+
<ModelsSection />
47+
</SettingsSectionStory>
48+
),
49+
play: async ({ canvasElement }) => {
50+
const canvas = within(canvasElement);
51+
52+
await waitFor(
53+
() => {
54+
if (canvas.queryAllByText(/Built-in Models/i).length === 0) {
55+
throw new Error("Expected Built-in Models to render");
56+
}
57+
},
58+
{ timeout: 5000 }
59+
);
60+
},
61+
};
62+
63+
export const ModelsConfigured: Story = {
64+
render: () => (
65+
<SettingsSectionStory
66+
setup={() => {
67+
window.localStorage.setItem(
68+
"provider_options_anthropic",
69+
JSON.stringify({ use1MContextModels: ["anthropic:claude-sonnet-4-20250514"] })
70+
);
71+
72+
return setupSettingsStory({
73+
providersConfig: {
74+
anthropic: {
75+
apiKeySet: true,
76+
isEnabled: true,
77+
isConfigured: true,
78+
baseUrl: "",
79+
models: ["claude-sonnet-4-20250514", "claude-opus-4-6"],
80+
},
81+
openai: {
82+
apiKeySet: true,
83+
isEnabled: true,
84+
isConfigured: true,
85+
baseUrl: "",
86+
models: ["gpt-4o", "gpt-4o-mini", "o1-preview"],
87+
},
88+
xai: {
89+
apiKeySet: false,
90+
isEnabled: true,
91+
isConfigured: false,
92+
baseUrl: "",
93+
models: ["grok-beta"],
94+
},
95+
},
96+
});
97+
}}
98+
>
99+
<ModelsSection />
100+
</SettingsSectionStory>
101+
),
102+
play: async ({ canvasElement }) => {
103+
const canvas = within(canvasElement);
104+
105+
await waitFor(
106+
() => {
107+
if (canvas.queryAllByText(/claude-sonnet-4-20250514/i).length === 0) {
108+
throw new Error("Expected claude-sonnet-4-20250514 to render");
109+
}
110+
},
111+
{ timeout: 5000 }
112+
);
113+
114+
await waitFor(
115+
() => {
116+
if (canvas.queryAllByText(/^gpt-4o$/i).length === 0) {
117+
throw new Error("Expected gpt-4o to render");
118+
}
119+
},
120+
{ timeout: 5000 }
121+
);
122+
},
123+
};

0 commit comments

Comments
Β (0)