Skip to content

Commit a8dc3e3

Browse files
authored
Merge pull request #438 from DialmasterOrg/feature/storybook-migration
Migrate Storybook & add CI validation — prep for theming
2 parents a4696ba + 43f1220 commit a8dc3e3

File tree

89 files changed

+6586
-132
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

89 files changed

+6586
-132
lines changed

.github/workflows/ci.yml

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -178,12 +178,52 @@ jobs:
178178
client/coverage/coverage-summary.json
179179
client/coverage/lcov.info
180180
181+
test-storybook:
182+
name: Storybook Tests
183+
runs-on: ubuntu-latest
184+
timeout-minutes: 15
185+
steps:
186+
- name: Checkout code
187+
uses: actions/checkout@v3
188+
189+
- name: Setup Node.js
190+
uses: actions/setup-node@v3
191+
with:
192+
node-version: '20.x'
193+
cache: 'npm'
194+
195+
- name: Install root dependencies
196+
run: npm ci
197+
198+
- name: Install client dependencies
199+
run: |
200+
cd client
201+
npm ci
202+
203+
- name: Cache Storybook build
204+
id: cache-storybook
205+
uses: actions/cache@v3
206+
with:
207+
path: client/storybook-static
208+
key: ${{ runner.os }}-storybook-${{ hashFiles('client/src/**', 'client/.storybook/**', 'client/package-lock.json') }}
209+
restore-keys: |
210+
${{ runner.os }}-storybook-
211+
212+
- name: Build Storybook for validation
213+
if: steps.cache-storybook.outputs.cache-hit != 'true'
214+
run: cd client && npm run build-storybook
215+
216+
- name: Run Jest story validation tests
217+
run: cd client && npm test -- src/tests/storybook_coverage.test.js --passWithNoTests
218+
env:
219+
CI: true
220+
181221
# This job is required for branch protection rules
182222
# It will only succeed if all tests and linting pass
183223
check-all:
184224
name: All Checks
185225
runs-on: ubuntu-latest
186-
needs: [lint, test-backend, test-frontend]
226+
needs: [lint, test-backend, test-frontend, test-storybook]
187227
if: always()
188228
steps:
189229
- name: Check all results
@@ -192,17 +232,20 @@ jobs:
192232
echo "Lint: ${{ needs.lint.result }}"
193233
echo "Backend Tests: ${{ needs.test-backend.result }}"
194234
echo "Frontend Tests: ${{ needs.test-frontend.result }}"
235+
echo "Storybook Tests: ${{ needs.test-storybook.result }}"
195236
196237
if [ "${{ needs.lint.result }}" != "success" ] || \
197238
[ "${{ needs.test-backend.result }}" != "success" ] || \
198-
[ "${{ needs.test-frontend.result }}" != "success" ]; then
239+
[ "${{ needs.test-frontend.result }}" != "success" ] || \
240+
[ "${{ needs.test-storybook.result }}" != "success" ]; then
199241
echo ""
200242
echo "❌ One or more checks failed!"
201243
echo ""
202244
echo "Failed checks:"
203245
[ "${{ needs.lint.result }}" != "success" ] && echo " - Linting"
204246
[ "${{ needs.test-backend.result }}" != "success" ] && echo " - Backend Tests"
205247
[ "${{ needs.test-frontend.result }}" != "success" ] && echo " - Frontend Tests"
248+
[ "${{ needs.test-storybook.result }}" != "success" ] && echo " - Storybook Tests"
206249
exit 1
207250
fi
208251

.gitignore

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,16 @@ downloads/*
5555

5656
# Backup archive location
5757
backups/
58+
59+
# Storybook outputs and caches
60+
storybook-static/
61+
build-storybook/
62+
client/build-storybook/
63+
.cache/storybook/
64+
65+
# Storybook test artifacts
66+
.out_storybook_
67+
.storyshots
68+
69+
# MSW service worker - regenerate with: cd client && npx msw init public/ --save
70+
client/public/mockServiceWorker.js
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/**
2+
* Default MSW request handlers shared across all Storybook stories.
3+
*
4+
* These provide baseline API responses so stories render without real network
5+
* requests. Individual stories can override handlers via `parameters.msw`.
6+
*
7+
* Regenerate mockServiceWorker.js if it goes missing:
8+
* cd client && npx msw init public/ --save
9+
*/
10+
import { http, HttpResponse } from 'msw';
11+
import { DEFAULT_CONFIG } from '../../src/config/configSchema';
12+
13+
export const defaultMswHandlers = [
14+
http.get('/getconfig', () =>
15+
HttpResponse.json({
16+
...DEFAULT_CONFIG,
17+
preferredResolution: '1080',
18+
channelFilesToDownload: 3,
19+
youtubeOutputDirectory: '/downloads/youtube',
20+
isPlatformManaged: {
21+
plexUrl: false,
22+
authEnabled: true,
23+
useTmpForDownloads: false,
24+
},
25+
deploymentEnvironment: {
26+
platform: null,
27+
isWsl: false,
28+
},
29+
})
30+
),
31+
http.get('/storage-status', () =>
32+
HttpResponse.json({
33+
availableGB: '100',
34+
percentFree: 50,
35+
totalGB: '200',
36+
})
37+
),
38+
http.get('/api/channels/subfolders', () => HttpResponse.json(['Movies', 'Shows'])),
39+
http.get('/api/cookies/status', () =>
40+
HttpResponse.json({
41+
cookiesEnabled: false,
42+
customCookiesUploaded: false,
43+
customFileExists: false,
44+
})
45+
),
46+
http.get('/api/keys', () => HttpResponse.json({ keys: [] })),
47+
http.get('/api/db-status', () => HttpResponse.json({ status: 'healthy' })),
48+
http.get('/setup/status', () =>
49+
HttpResponse.json({
50+
requiresSetup: false,
51+
isLocalhost: true,
52+
platformManaged: false,
53+
})
54+
),
55+
http.get('/getCurrentReleaseVersion', () =>
56+
HttpResponse.json({
57+
version: '1.0.0',
58+
ytDlpVersion: '2024.01.01',
59+
})
60+
),
61+
http.get('/get-running-jobs', () => HttpResponse.json([])),
62+
http.get('/runningjobs', () => HttpResponse.json([])),
63+
];

client/.storybook/main.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { mergeConfig } from 'vite';
2+
import tsconfigPaths from 'vite-tsconfig-paths';
3+
4+
const config = {
5+
stories: [
6+
'../src/**/__tests__/**/*.story.@(js|jsx|mjs|ts|tsx|mdx)',
7+
],
8+
addons: ['@storybook/addon-a11y', '@storybook/addon-links'],
9+
framework: {
10+
name: '@storybook/react-vite',
11+
options: {},
12+
},
13+
async viteFinal(config) {
14+
return mergeConfig(config, {
15+
plugins: [tsconfigPaths()],
16+
define: {
17+
...(config.define ?? {}),
18+
// Explicitly define NODE_ENV rather than wiping all of process.env,
19+
// which would conflict with envPrefix env-var injection.
20+
'process.env.NODE_ENV': JSON.stringify('development'),
21+
},
22+
envPrefix: ['VITE_', 'REACT_APP_'],
23+
});
24+
},
25+
};
26+
27+
export default config;

client/.storybook/preview.js

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import React from 'react';
2+
import { initialize, mswLoader } from 'msw-storybook-addon';
3+
import { ThemeProvider } from '@mui/material/styles';
4+
import CssBaseline from '@mui/material/CssBaseline';
5+
import { LocalizationProvider } from '@mui/x-date-pickers';
6+
import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns';
7+
import WebSocketContext from '../src/contexts/WebSocketContext';
8+
import { lightTheme, darkTheme } from '../src/theme';
9+
import { defaultMswHandlers } from './fixtures/mswHandlers';
10+
11+
/**
12+
* STORYBOOK ROUTER CONFIGURATION
13+
*
14+
* Stories for components that use React Router hooks (useNavigate, useParams, useLocation)
15+
* must explicitly wrap their components with MemoryRouter to avoid runtime errors.
16+
*
17+
* Router-dependent components with stories:
18+
* - ChannelManager (.../ChannelManager.story.tsx)
19+
* - ChannelPage (.../ChannelPage.story.tsx)
20+
* - DownloadManager (.../DownloadManager.story.tsx)
21+
* - ChannelVideos (.../ChannelPage/__tests__/ChannelVideos.story.tsx)
22+
* - DownloadProgress (.../DownloadManager/__tests__/DownloadProgress.story.tsx)
23+
*
24+
* To add routing to a story:
25+
*
26+
* 1. For components that need routing context but no specific routes:
27+
* import { MemoryRouter } from 'react-router-dom';
28+
* const meta: Meta<typeof MyComponent> = {
29+
* // ...
30+
* decorators: [
31+
* (Story) => <MemoryRouter><Story /></MemoryRouter>
32+
* ]
33+
* };
34+
*
35+
* 2. For components that need specific routes/parameters:
36+
* import { MemoryRouter, Routes, Route } from 'react-router-dom';
37+
* const meta: Meta<typeof MyComponent> = {
38+
* // ...
39+
* render: (args) => (
40+
* <MemoryRouter initialEntries={['/path/to/route']}>
41+
* <Routes>
42+
* <Route path="/path/:id" element={<MyComponent {...args} />} />
43+
* </Routes>
44+
* </MemoryRouter>
45+
* )
46+
* };
47+
*/
48+
49+
initialize({
50+
onUnhandledRequest: 'bypass',
51+
});
52+
53+
/**
54+
* Stub WebSocket context for stories. subscribe/unsubscribe are no-ops since
55+
* stories don't need live socket events. Override via story decorators if needed.
56+
*/
57+
const mockWebSocketContext = {
58+
socket: null,
59+
subscribe: () => {},
60+
unsubscribe: () => {},
61+
};
62+
63+
const normalizeHandlers = (value) => {
64+
if (!value) return [];
65+
if (Array.isArray(value)) return value;
66+
if (typeof value === 'object') {
67+
return Object.values(value).flat().filter(Boolean);
68+
}
69+
return [];
70+
};
71+
72+
const mergeMswHandlersLoader = async (context) => {
73+
const existingMsw = context.parameters?.msw;
74+
const existingHandlers = normalizeHandlers(
75+
existingMsw && typeof existingMsw === 'object' && 'handlers' in existingMsw
76+
? existingMsw.handlers
77+
: existingMsw
78+
);
79+
80+
context.parameters = {
81+
...context.parameters,
82+
msw: {
83+
...(typeof existingMsw === 'object' ? existingMsw : {}),
84+
handlers: [...existingHandlers, ...defaultMswHandlers],
85+
},
86+
};
87+
88+
return {};
89+
};
90+
91+
const preview = {
92+
loaders: [mergeMswHandlersLoader, mswLoader],
93+
parameters: {
94+
actions: { argTypesRegex: '^on[A-Z].*' },
95+
controls: {
96+
matchers: {
97+
color: /(background|color)$/i,
98+
date: /Date$/i,
99+
},
100+
},
101+
},
102+
globalTypes: {
103+
theme: {
104+
name: 'Theme',
105+
description: 'Global theme for components',
106+
defaultValue: 'light',
107+
toolbar: {
108+
icon: 'circlehollow',
109+
items: [
110+
{ value: 'light', title: 'Light' },
111+
{ value: 'dark', title: 'Dark' },
112+
],
113+
},
114+
},
115+
},
116+
decorators: [
117+
(Story, context) => {
118+
const selectedTheme = context.globals.theme === 'dark' ? darkTheme : lightTheme;
119+
120+
return React.createElement(
121+
LocalizationProvider,
122+
{ dateAdapter: AdapterDateFns },
123+
React.createElement(
124+
ThemeProvider,
125+
{ theme: selectedTheme },
126+
React.createElement(CssBaseline, null),
127+
React.createElement(
128+
WebSocketContext.Provider,
129+
{ value: mockWebSocketContext },
130+
React.createElement(Story)
131+
)
132+
)
133+
);
134+
},
135+
],
136+
};
137+
138+
export default preview;

0 commit comments

Comments
 (0)