Skip to content

Commit 7b44cdf

Browse files
committed
🤖 test: add Settings tests (Storybook + Playwright E2E)
- Add Storybook stories for Settings modal visual states - General, Providers, Models sections - ProvidersExpanded with accordion interaction - ModelsEmpty showing empty state - Interactive tests: overlay close, escape key, X button, sidebar nav - Add Playwright E2E tests for Settings functionality - Open/close modal via gear button, escape, X button, overlay - Section navigation - Provider accordion expansion - Models form visibility - Extend WorkspaceUI helper with settings methods _Generated with `mux`_
1 parent 4402995 commit 7b44cdf

File tree

3 files changed

+462
-0
lines changed

3 files changed

+462
-0
lines changed
Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
1+
import type { Meta, StoryObj } from "@storybook/react-vite";
2+
import { expect, userEvent, waitFor, within } from "storybook/test";
3+
import React, { useState } from "react";
4+
import { SettingsProvider, useSettings } from "@/browser/contexts/SettingsContext";
5+
import { SettingsModal } from "./SettingsModal";
6+
import type { IPCApi } from "@/common/types/ipc";
7+
8+
// Mock providers config for stories
9+
const mockProvidersConfig: Record<
10+
string,
11+
{ apiKeySet: boolean; baseUrl?: string; models?: string[] }
12+
> = {
13+
anthropic: { apiKeySet: true },
14+
openai: { apiKeySet: true, baseUrl: "https://custom.openai.com" },
15+
google: { apiKeySet: false },
16+
xai: { apiKeySet: false },
17+
ollama: { apiKeySet: false, models: ["llama3.2", "codestral"] },
18+
openrouter: { apiKeySet: true, models: ["mistral/mistral-7b"] },
19+
};
20+
21+
function setupMockAPI(config = mockProvidersConfig) {
22+
const mockProviders: IPCApi["providers"] = {
23+
setProviderConfig: () => Promise.resolve({ success: true, data: undefined }),
24+
setModels: () => Promise.resolve({ success: true, data: undefined }),
25+
getConfig: () => Promise.resolve(config),
26+
list: () => Promise.resolve([]),
27+
};
28+
29+
// @ts-expect-error - Assigning mock API to window for Storybook
30+
window.api = {
31+
providers: mockProviders,
32+
};
33+
}
34+
35+
// Wrapper component that auto-opens the settings modal
36+
function SettingsStoryWrapper(props: { initialSection?: string }) {
37+
return (
38+
<SettingsProvider>
39+
<SettingsAutoOpen initialSection={props.initialSection} />
40+
<SettingsModal />
41+
</SettingsProvider>
42+
);
43+
}
44+
45+
function SettingsAutoOpen(props: { initialSection?: string }) {
46+
const { open, isOpen } = useSettings();
47+
const [hasOpened, setHasOpened] = useState(false);
48+
49+
React.useEffect(() => {
50+
if (!hasOpened && !isOpen) {
51+
open(props.initialSection);
52+
setHasOpened(true);
53+
}
54+
}, [hasOpened, isOpen, open, props.initialSection]);
55+
56+
return null;
57+
}
58+
59+
// Interactive wrapper for testing close behavior
60+
function InteractiveSettingsWrapper(props: { initialSection?: string }) {
61+
const [reopenCount, setReopenCount] = useState(0);
62+
63+
return (
64+
<SettingsProvider key={reopenCount}>
65+
<div className="p-4">
66+
<button
67+
type="button"
68+
onClick={() => setReopenCount((c) => c + 1)}
69+
className="bg-accent mb-4 rounded px-4 py-2 text-white"
70+
>
71+
Reopen Settings
72+
</button>
73+
<div id="close-indicator" className="text-muted text-sm">
74+
Click overlay or press Escape to close
75+
</div>
76+
</div>
77+
<SettingsAutoOpen initialSection={props.initialSection} />
78+
<SettingsModal />
79+
</SettingsProvider>
80+
);
81+
}
82+
83+
const meta = {
84+
title: "Components/Settings",
85+
component: SettingsModal,
86+
parameters: {
87+
layout: "fullscreen",
88+
},
89+
tags: ["autodocs"],
90+
decorators: [
91+
(Story) => {
92+
setupMockAPI();
93+
return <Story />;
94+
},
95+
],
96+
} satisfies Meta<typeof SettingsModal>;
97+
98+
export default meta;
99+
type Story = StoryObj<typeof meta>;
100+
101+
/**
102+
* Default settings modal showing the General section.
103+
* Contains theme toggle between light/dark modes.
104+
*/
105+
export const General: Story = {
106+
render: () => <SettingsStoryWrapper initialSection="general" />,
107+
};
108+
109+
/**
110+
* Providers section showing API key configuration.
111+
* - Green dot indicates configured providers
112+
* - Accordion expands to show API Key and Base URL fields
113+
* - Shows masked "••••••••" for set keys
114+
*/
115+
export const Providers: Story = {
116+
render: () => <SettingsStoryWrapper initialSection="providers" />,
117+
};
118+
119+
/**
120+
* Providers section with expanded Anthropic accordion.
121+
*/
122+
export const ProvidersExpanded: Story = {
123+
render: () => <SettingsStoryWrapper initialSection="providers" />,
124+
play: async ({ canvasElement }) => {
125+
const canvas = within(canvasElement);
126+
127+
// Wait for modal to render
128+
await waitFor(async () => {
129+
const modal = canvas.getByRole("dialog");
130+
await expect(modal).toBeInTheDocument();
131+
});
132+
133+
// Click Anthropic to expand
134+
const anthropicButton = canvas.getByRole("button", { name: /Anthropic/i });
135+
await userEvent.click(anthropicButton);
136+
137+
// Verify the accordion expanded (API Key label should be visible)
138+
await waitFor(async () => {
139+
const apiKeyLabel = canvas.getByText("API Key");
140+
await expect(apiKeyLabel).toBeVisible();
141+
});
142+
},
143+
};
144+
145+
/**
146+
* Models section showing custom model management.
147+
* - Form to add new models with provider dropdown
148+
* - List of existing custom models with delete buttons
149+
*/
150+
export const Models: Story = {
151+
render: () => <SettingsStoryWrapper initialSection="models" />,
152+
};
153+
154+
/**
155+
* Models section with no custom models configured.
156+
*/
157+
export const ModelsEmpty: Story = {
158+
decorators: [
159+
(Story) => {
160+
setupMockAPI({
161+
anthropic: { apiKeySet: true },
162+
openai: { apiKeySet: true },
163+
google: { apiKeySet: false },
164+
xai: { apiKeySet: false },
165+
ollama: { apiKeySet: false },
166+
openrouter: { apiKeySet: false },
167+
});
168+
return <Story />;
169+
},
170+
],
171+
render: () => <SettingsStoryWrapper initialSection="models" />,
172+
};
173+
174+
/**
175+
* Test that clicking overlay closes the modal.
176+
*/
177+
export const OverlayClickCloses: Story = {
178+
render: () => <InteractiveSettingsWrapper />,
179+
play: async ({ canvasElement }) => {
180+
const canvas = within(canvasElement);
181+
182+
// Wait for modal
183+
await waitFor(async () => {
184+
const modal = canvas.getByRole("dialog");
185+
await expect(modal).toBeInTheDocument();
186+
});
187+
188+
// Wait for event listeners to attach
189+
await new Promise((resolve) => setTimeout(resolve, 100));
190+
191+
// Click overlay
192+
const overlay = document.querySelector('[role="presentation"]');
193+
await expect(overlay).toBeInTheDocument();
194+
await userEvent.click(overlay!);
195+
196+
// Modal should close
197+
await waitFor(async () => {
198+
const closedModal = canvas.queryByRole("dialog");
199+
await expect(closedModal).not.toBeInTheDocument();
200+
});
201+
},
202+
};
203+
204+
/**
205+
* Test that pressing Escape closes the modal.
206+
*/
207+
export const EscapeKeyCloses: Story = {
208+
render: () => <InteractiveSettingsWrapper />,
209+
play: async ({ canvasElement }) => {
210+
const canvas = within(canvasElement);
211+
212+
// Wait for modal
213+
await waitFor(async () => {
214+
const modal = canvas.getByRole("dialog");
215+
await expect(modal).toBeInTheDocument();
216+
});
217+
218+
// Wait for event listeners
219+
await new Promise((resolve) => setTimeout(resolve, 100));
220+
221+
// Press Escape
222+
await userEvent.keyboard("{Escape}");
223+
224+
// Modal should close
225+
await waitFor(async () => {
226+
const closedModal = canvas.queryByRole("dialog");
227+
await expect(closedModal).not.toBeInTheDocument();
228+
});
229+
},
230+
};
231+
232+
/**
233+
* Test sidebar navigation between sections.
234+
*/
235+
export const SidebarNavigation: Story = {
236+
render: () => <SettingsStoryWrapper initialSection="general" />,
237+
play: async ({ canvasElement }) => {
238+
const canvas = within(canvasElement);
239+
240+
// Wait for modal
241+
await waitFor(async () => {
242+
const modal = canvas.getByRole("dialog");
243+
await expect(modal).toBeInTheDocument();
244+
});
245+
246+
// Should start on General - verify by checking theme toggle presence
247+
await expect(canvas.getByText("Theme")).toBeVisible();
248+
249+
// Click Providers in sidebar
250+
const providersNav = canvas.getByRole("button", { name: /Providers/i });
251+
await userEvent.click(providersNav);
252+
253+
// Content should update to show Providers section text
254+
await waitFor(async () => {
255+
const providersText = canvas.getByText(/Configure API keys/i);
256+
await expect(providersText).toBeVisible();
257+
});
258+
259+
// Click Models in sidebar
260+
const modelsNav = canvas.getByRole("button", { name: /Models/i });
261+
await userEvent.click(modelsNav);
262+
263+
// Content should update to show Models section text
264+
await waitFor(async () => {
265+
const modelsText = canvas.getByText(/Add custom models/i);
266+
await expect(modelsText).toBeVisible();
267+
});
268+
},
269+
};
270+
271+
/**
272+
* Test X button closes the modal.
273+
*/
274+
export const CloseButtonCloses: Story = {
275+
render: () => <InteractiveSettingsWrapper />,
276+
play: async ({ canvasElement }) => {
277+
const canvas = within(canvasElement);
278+
279+
// Wait for modal
280+
await waitFor(async () => {
281+
const modal = canvas.getByRole("dialog");
282+
await expect(modal).toBeInTheDocument();
283+
});
284+
285+
// Click close button
286+
const closeButton = canvas.getByRole("button", { name: /Close settings/i });
287+
await userEvent.click(closeButton);
288+
289+
// Modal should close
290+
await waitFor(async () => {
291+
const closedModal = canvas.queryByRole("dialog");
292+
await expect(closedModal).not.toBeInTheDocument();
293+
});
294+
},
295+
};

0 commit comments

Comments
 (0)