Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 45 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -178,12 +178,52 @@ jobs:
client/coverage/coverage-summary.json
client/coverage/lcov.info

test-storybook:
name: Storybook Tests
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout code
uses: actions/checkout@v3

- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '20.x'
cache: 'npm'

- name: Install root dependencies
run: npm ci

- name: Install client dependencies
run: |
cd client
npm ci

- name: Cache Storybook build
id: cache-storybook
uses: actions/cache@v3
with:
path: client/storybook-static
key: ${{ runner.os }}-storybook-${{ hashFiles('client/src/**', 'client/.storybook/**', 'client/package-lock.json') }}
restore-keys: |
${{ runner.os }}-storybook-

- name: Build Storybook for validation
if: steps.cache-storybook.outputs.cache-hit != 'true'
run: cd client && npm run build-storybook

- name: Run Jest story validation tests
run: cd client && npm test -- src/tests/storybook_coverage.test.js --passWithNoTests
env:
CI: true

# This job is required for branch protection rules
# It will only succeed if all tests and linting pass
check-all:
name: All Checks
runs-on: ubuntu-latest
needs: [lint, test-backend, test-frontend]
needs: [lint, test-backend, test-frontend, test-storybook]
if: always()
steps:
- name: Check all results
Expand All @@ -192,17 +232,20 @@ jobs:
echo "Lint: ${{ needs.lint.result }}"
echo "Backend Tests: ${{ needs.test-backend.result }}"
echo "Frontend Tests: ${{ needs.test-frontend.result }}"
echo "Storybook Tests: ${{ needs.test-storybook.result }}"

if [ "${{ needs.lint.result }}" != "success" ] || \
[ "${{ needs.test-backend.result }}" != "success" ] || \
[ "${{ needs.test-frontend.result }}" != "success" ]; then
[ "${{ needs.test-frontend.result }}" != "success" ] || \
[ "${{ needs.test-storybook.result }}" != "success" ]; then
echo ""
echo "❌ One or more checks failed!"
echo ""
echo "Failed checks:"
[ "${{ needs.lint.result }}" != "success" ] && echo " - Linting"
[ "${{ needs.test-backend.result }}" != "success" ] && echo " - Backend Tests"
[ "${{ needs.test-frontend.result }}" != "success" ] && echo " - Frontend Tests"
[ "${{ needs.test-storybook.result }}" != "success" ] && echo " - Storybook Tests"
exit 1
fi

Expand Down
13 changes: 13 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,16 @@ downloads/*

# Backup archive location
backups/

# Storybook outputs and caches
storybook-static/
build-storybook/
client/build-storybook/
.cache/storybook/

# Storybook test artifacts
.out_storybook_
.storyshots

# MSW service worker - regenerate with: cd client && npx msw init public/ --save
client/public/mockServiceWorker.js
63 changes: 63 additions & 0 deletions client/.storybook/fixtures/mswHandlers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/**
* Default MSW request handlers shared across all Storybook stories.
*
* These provide baseline API responses so stories render without real network
* requests. Individual stories can override handlers via `parameters.msw`.
*
* Regenerate mockServiceWorker.js if it goes missing:
* cd client && npx msw init public/ --save
*/
import { http, HttpResponse } from 'msw';
import { DEFAULT_CONFIG } from '../../src/config/configSchema';

export const defaultMswHandlers = [
http.get('/getconfig', () =>
HttpResponse.json({
...DEFAULT_CONFIG,
preferredResolution: '1080',
channelFilesToDownload: 3,
youtubeOutputDirectory: '/downloads/youtube',
isPlatformManaged: {
plexUrl: false,
authEnabled: true,
useTmpForDownloads: false,
},
deploymentEnvironment: {
platform: null,
isWsl: false,
},
})
),
http.get('/storage-status', () =>
HttpResponse.json({
availableGB: '100',
percentFree: 50,
totalGB: '200',
})
),
http.get('/api/channels/subfolders', () => HttpResponse.json(['Movies', 'Shows'])),
http.get('/api/cookies/status', () =>
HttpResponse.json({
cookiesEnabled: false,
customCookiesUploaded: false,
customFileExists: false,
})
),
http.get('/api/keys', () => HttpResponse.json({ keys: [] })),
http.get('/api/db-status', () => HttpResponse.json({ status: 'healthy' })),
http.get('/setup/status', () =>
HttpResponse.json({
requiresSetup: false,
isLocalhost: true,
platformManaged: false,
})
),
http.get('/getCurrentReleaseVersion', () =>
HttpResponse.json({
version: '1.0.0',
ytDlpVersion: '2024.01.01',
})
),
http.get('/get-running-jobs', () => HttpResponse.json([])),
http.get('/runningjobs', () => HttpResponse.json([])),
];
27 changes: 27 additions & 0 deletions client/.storybook/main.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { mergeConfig } from 'vite';
import tsconfigPaths from 'vite-tsconfig-paths';

const config = {
stories: [
'../src/**/__tests__/**/*.story.@(js|jsx|mjs|ts|tsx|mdx)',
],
addons: ['@storybook/addon-a11y', '@storybook/addon-links'],
framework: {
name: '@storybook/react-vite',
options: {},
},
async viteFinal(config) {
return mergeConfig(config, {
plugins: [tsconfigPaths()],
define: {
...(config.define ?? {}),
// Explicitly define NODE_ENV rather than wiping all of process.env,
// which would conflict with envPrefix env-var injection.
'process.env.NODE_ENV': JSON.stringify('development'),
},
envPrefix: ['VITE_', 'REACT_APP_'],
});
},
};

export default config;
138 changes: 138 additions & 0 deletions client/.storybook/preview.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import React from 'react';
import { initialize, mswLoader } from 'msw-storybook-addon';
import { ThemeProvider } from '@mui/material/styles';
import CssBaseline from '@mui/material/CssBaseline';
import { LocalizationProvider } from '@mui/x-date-pickers';
import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns';
import WebSocketContext from '../src/contexts/WebSocketContext';
import { lightTheme, darkTheme } from '../src/theme';
import { defaultMswHandlers } from './fixtures/mswHandlers';

/**
* STORYBOOK ROUTER CONFIGURATION
*
* Stories for components that use React Router hooks (useNavigate, useParams, useLocation)
* must explicitly wrap their components with MemoryRouter to avoid runtime errors.
*
* Router-dependent components with stories:
* - ChannelManager (.../ChannelManager.story.tsx)
* - ChannelPage (.../ChannelPage.story.tsx)
* - DownloadManager (.../DownloadManager.story.tsx)
* - ChannelVideos (.../ChannelPage/__tests__/ChannelVideos.story.tsx)
* - DownloadProgress (.../DownloadManager/__tests__/DownloadProgress.story.tsx)
*
* To add routing to a story:
*
* 1. For components that need routing context but no specific routes:
* import { MemoryRouter } from 'react-router-dom';
* const meta: Meta<typeof MyComponent> = {
* // ...
* decorators: [
* (Story) => <MemoryRouter><Story /></MemoryRouter>
* ]
* };
*
* 2. For components that need specific routes/parameters:
* import { MemoryRouter, Routes, Route } from 'react-router-dom';
* const meta: Meta<typeof MyComponent> = {
* // ...
* render: (args) => (
* <MemoryRouter initialEntries={['/path/to/route']}>
* <Routes>
* <Route path="/path/:id" element={<MyComponent {...args} />} />
* </Routes>
* </MemoryRouter>
* )
* };
*/

initialize({
onUnhandledRequest: 'bypass',
});

/**
* Stub WebSocket context for stories. subscribe/unsubscribe are no-ops since
* stories don't need live socket events. Override via story decorators if needed.
*/
const mockWebSocketContext = {
socket: null,
subscribe: () => {},
unsubscribe: () => {},
};

const normalizeHandlers = (value) => {
if (!value) return [];
if (Array.isArray(value)) return value;
if (typeof value === 'object') {
return Object.values(value).flat().filter(Boolean);
}
return [];
};

const mergeMswHandlersLoader = async (context) => {
const existingMsw = context.parameters?.msw;
const existingHandlers = normalizeHandlers(
existingMsw && typeof existingMsw === 'object' && 'handlers' in existingMsw
? existingMsw.handlers
: existingMsw
);

context.parameters = {
...context.parameters,
msw: {
...(typeof existingMsw === 'object' ? existingMsw : {}),
handlers: [...existingHandlers, ...defaultMswHandlers],
},
};

return {};
};

const preview = {
loaders: [mergeMswHandlersLoader, mswLoader],
parameters: {
actions: { argTypesRegex: '^on[A-Z].*' },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
},
globalTypes: {
theme: {
name: 'Theme',
description: 'Global theme for components',
defaultValue: 'light',
toolbar: {
icon: 'circlehollow',
items: [
{ value: 'light', title: 'Light' },
{ value: 'dark', title: 'Dark' },
],
},
},
},
decorators: [
(Story, context) => {
const selectedTheme = context.globals.theme === 'dark' ? darkTheme : lightTheme;

return React.createElement(
LocalizationProvider,
{ dateAdapter: AdapterDateFns },
React.createElement(
ThemeProvider,
{ theme: selectedTheme },
React.createElement(CssBaseline, null),
React.createElement(
WebSocketContext.Provider,
{ value: mockWebSocketContext },
React.createElement(Story)
)
)
);
},
],
};

export default preview;
Loading