test(storybook): Playwright suite for full Storybook + Spotify/Discord polish
- Add playwright.config.storybook.ts: runs against storybook-static on :6007 - Add e2e/tests/storybook/storybook-all.spec.ts: one test per story (load iframe, no errors) - Add scripts/serve-storybook-static.cjs: serves build or stub (empty index) when no build - npm run test:storybook:playwright (after build-storybook) for full coverage - Storybook decorator: use bg-background / design tokens for dark (#121212) - Preview: default dark background #121212 - Button: secondary/ghost/glass aligned to Spotify/Discord (white/5, white/10 hover) - KodoEmptyState: softer orbs, compact copy, primary CTA without heavy glow Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
37c5acc302
commit
1efafe0cc5
8 changed files with 204 additions and 31 deletions
|
|
@ -8,6 +8,7 @@
|
|||
*/
|
||||
import React from 'react';
|
||||
import type { Decorator } from '@storybook/react';
|
||||
import { cn } from '../src/lib/utils';
|
||||
import { ThemeProvider } from '../src/components/theme/ThemeProvider';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
|
|
@ -36,13 +37,10 @@ export const StorybookDecorator: Decorator = (Story, context) => {
|
|||
<I18nextProvider i18n={i18n}>
|
||||
<ThemeProvider defaultTheme={isDark ? 'dark' : 'light'}>
|
||||
<div
|
||||
className={isDark ? 'dark' : ''}
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
padding: '1rem',
|
||||
background: isDark ? '#0a0a0a' : '#ffffff',
|
||||
color: isDark ? '#ffffff' : '#0a0a0a',
|
||||
}}
|
||||
className={cn(
|
||||
isDark ? 'dark' : '',
|
||||
'min-h-layout-story min-h-screen p-4 bg-background text-foreground',
|
||||
)}
|
||||
>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ToastProvider>
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ const preview: Preview = {
|
|||
backgrounds: {
|
||||
default: 'dark',
|
||||
values: [
|
||||
{ name: 'dark', value: '#0a0a0a' },
|
||||
{ name: 'dark', value: '#121212' },
|
||||
{ name: 'light', value: '#ffffff' },
|
||||
{ name: 'steel', value: '#1a1a2e' },
|
||||
],
|
||||
|
|
|
|||
80
apps/web/e2e/tests/storybook/storybook-all.spec.ts
Normal file
80
apps/web/e2e/tests/storybook/storybook-all.spec.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const INDEX_PATH = path.join(process.cwd(), 'storybook-static', 'index.json');
|
||||
const IFRAME_URL = (id: string) => `/iframe.html?id=${encodeURIComponent(id)}&viewMode=story`;
|
||||
const NAV_TIMEOUT_MS = 20000;
|
||||
const POST_LOAD_MS = 200;
|
||||
|
||||
/** Story IDs from built Storybook index (available at load time). */
|
||||
function getStoryIds(): string[] {
|
||||
if (!fs.existsSync(INDEX_PATH)) return [];
|
||||
try {
|
||||
const index = JSON.parse(fs.readFileSync(INDEX_PATH, 'utf8'));
|
||||
const entries = index.entries ?? {};
|
||||
return Object.values(entries).map((e: { id?: string }) => e.id).filter(Boolean);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const storyIds = getStoryIds();
|
||||
|
||||
test.describe('Storybook – all stories', () => {
|
||||
if (storyIds.length === 0) {
|
||||
test('run build-storybook first', async () => {
|
||||
test.skip(true, 'Run npm run build-storybook first. Then start a server on port 6007 (e.g. npx serve storybook-static -l 6007) or use the Playwright webServer.');
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
for (const storyId of storyIds) {
|
||||
test(storyId, async ({ page }) => {
|
||||
const consoleErrors: string[] = [];
|
||||
const pageErrors: string[] = [];
|
||||
|
||||
page.on('console', (msg) => {
|
||||
const type = msg.type();
|
||||
if (type === 'error') {
|
||||
const text = msg.text();
|
||||
if (!isIgnoredConsoleError(text)) consoleErrors.push(text);
|
||||
}
|
||||
});
|
||||
page.on('pageerror', (err) => {
|
||||
pageErrors.push(err.message);
|
||||
});
|
||||
|
||||
const response = await page.goto(IFRAME_URL(storyId), {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: NAV_TIMEOUT_MS,
|
||||
});
|
||||
expect(response?.status()).toBe(200);
|
||||
await page.waitForTimeout(POST_LOAD_MS);
|
||||
|
||||
const errors = [...pageErrors, ...consoleErrors];
|
||||
expect(
|
||||
errors,
|
||||
errors.length ? `Story ${storyId}: ${errors.slice(0, 3).join('; ')}` : undefined
|
||||
).toHaveLength(0);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/** Ignore known benign Storybook/addon or runtime messages. */
|
||||
function isIgnoredConsoleError(text: string): boolean {
|
||||
const ignored = [
|
||||
'ResizeObserver',
|
||||
'Warning: ReactDOM.render',
|
||||
'Download the React DevTools',
|
||||
'sb-manager',
|
||||
'sb-addons',
|
||||
'sb-common-assets',
|
||||
'mockServiceWorker',
|
||||
'Failed to load resource: net::ERR_ABORTED',
|
||||
'ChunkLoadError',
|
||||
'Loading chunk',
|
||||
'hydration',
|
||||
];
|
||||
return ignored.some((s) => text.includes(s));
|
||||
}
|
||||
|
|
@ -43,7 +43,8 @@
|
|||
"prepare": "husky",
|
||||
"storybook": "cross-env VITE_API_URL=/api/v1 VITE_USE_MSW=true VITE_STORYBOOK=true storybook dev -p 6006",
|
||||
"build-storybook": "cross-env VITE_API_URL=/api/v1 VITE_USE_MSW=true VITE_STORYBOOK=true storybook build",
|
||||
"test:storybook": "node scripts/audit-storybook.js"
|
||||
"test:storybook": "node scripts/audit-storybook.js",
|
||||
"test:storybook:playwright": "playwright test --config=playwright.config.storybook.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
|
|
|
|||
59
apps/web/playwright.config.storybook.ts
Normal file
59
apps/web/playwright.config.storybook.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Playwright config for testing the full Storybook (all stories).
|
||||
*
|
||||
* Prerequisite: build Storybook first:
|
||||
* npm run build-storybook
|
||||
*
|
||||
* Then run:
|
||||
* npm run test:storybook:playwright
|
||||
*
|
||||
* The config starts a static server for storybook-static on port 6007
|
||||
* (reuse existing if already running). Tests load each story iframe and
|
||||
* assert no console errors / page crashes.
|
||||
*/
|
||||
const STORYBOOK_PORT = 6007;
|
||||
const STORYBOOK_BASE = `http://localhost:${STORYBOOK_PORT}`;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e/tests/storybook',
|
||||
testMatch: /.*\.spec\.ts/,
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
workers: process.env.CI ? 2 : 4,
|
||||
timeout: 25000,
|
||||
|
||||
outputDir: 'e2e/test-results-storybook',
|
||||
reporter: [
|
||||
['html', { outputFolder: 'e2e/playwright-report-storybook', open: 'never' }],
|
||||
['list'],
|
||||
],
|
||||
|
||||
use: {
|
||||
baseURL: STORYBOOK_BASE,
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
video: 'off',
|
||||
viewport: { width: 1280, height: 720 },
|
||||
},
|
||||
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
viewport: { width: 1280, height: 720 },
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
webServer: {
|
||||
command: 'node scripts/serve-storybook-static.cjs',
|
||||
url: `${STORYBOOK_BASE}/index.json`,
|
||||
reuseExistingServer: true,
|
||||
timeout: 60_000,
|
||||
cwd: process.cwd(),
|
||||
},
|
||||
});
|
||||
41
apps/web/scripts/serve-storybook-static.cjs
Normal file
41
apps/web/scripts/serve-storybook-static.cjs
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* Serves storybook-static on port 6007 for Playwright/audit.
|
||||
* If storybook-static is missing, serves a stub so index.json returns { entries: {} }
|
||||
* and tests can skip with "Run npm run build-storybook first".
|
||||
*/
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
const PORT = 6007;
|
||||
const staticDir = path.join(process.cwd(), 'storybook-static');
|
||||
const indexPath = path.join(staticDir, 'index.json');
|
||||
|
||||
if (fs.existsSync(indexPath)) {
|
||||
const proc = spawn('npx', ['serve', 'storybook-static', '-l', String(PORT)], {
|
||||
stdio: 'inherit',
|
||||
shell: true,
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
proc.on('error', (err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
proc.on('exit', (code) => process.exit(code ?? 0));
|
||||
} else {
|
||||
const stubIndex = JSON.stringify({ entries: {} });
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.url === '/index.json' || req.url === '/') {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(stubIndex);
|
||||
return;
|
||||
}
|
||||
res.statusCode = 404;
|
||||
res.end('Not found. Run npm run build-storybook first.');
|
||||
});
|
||||
server.listen(PORT, () => {
|
||||
console.log(`Stub Storybook server at http://localhost:${PORT} (no storybook-static; run build-storybook first)`);
|
||||
});
|
||||
}
|
||||
|
|
@ -28,35 +28,29 @@ export function KodoEmptyState({
|
|||
className,
|
||||
)}
|
||||
>
|
||||
{/* Subtle gradient orbs */}
|
||||
<div className="absolute inset-0 opacity-20 group-hover:opacity-30 transition-opacity duration-700 pointer-events-none">
|
||||
<div className="absolute top-1/4 left-1/4 w-64 h-64 bg-primary/30 rounded-full blur-[80px] animate-pulse-slow" />
|
||||
<div className="absolute bottom-1/4 right-1/4 w-64 h-64 bg-primary/20 rounded-full blur-[80px] animate-pulse-slow delay-700" />
|
||||
{/* Subtle ambient orbs */}
|
||||
<div className="absolute inset-0 opacity-[0.12] group-hover:opacity-20 transition-opacity duration-500 pointer-events-none">
|
||||
<div className="absolute top-1/4 left-1/4 w-64 h-64 bg-primary/40 rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-1/4 right-1/4 w-64 h-64 bg-primary/30 rounded-full blur-3xl" />
|
||||
</div>
|
||||
|
||||
<div className="relative mb-8 p-6">
|
||||
<div className="relative bg-card/80 backdrop-blur-xl p-6 rounded-xl border border-white/10 group-hover:scale-105 transition-transform duration-[var(--duration-immersive)] ease-in-out flex items-center justify-center">
|
||||
<Icon className="h-12 w-12 text-primary" />
|
||||
<div className="relative mb-6 p-4">
|
||||
<div className="relative bg-white/5 p-5 rounded-xl border border-white/10 flex items-center justify-center group-hover:border-white/15 transition-colors">
|
||||
<Icon className="h-10 w-10 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="text-2xl font-display font-bold text-foreground mb-3 tracking-tight relative z-10">
|
||||
<h3 className="text-xl font-semibold text-foreground mb-2 tracking-tight relative z-10">
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
<p className="text-muted-foreground max-w-sm mb-8 text-base leading-relaxed relative z-10">
|
||||
<p className="text-muted-foreground max-w-sm mb-6 text-sm leading-relaxed relative z-10">
|
||||
{description}
|
||||
</p>
|
||||
|
||||
{actionLabel && onAction && (
|
||||
<Button
|
||||
variant="primary"
|
||||
className="rounded-xl shadow-[0_0_20px_var(--color-primary)/0.25] hover:shadow-[0_0_24px_var(--color-primary)/0.35] transition-all duration-[var(--duration-immersive)] ease-in-out"
|
||||
onClick={onAction}
|
||||
>
|
||||
<span className="flex items-center gap-2 font-bold tracking-wide">
|
||||
{actionLabel}
|
||||
</span>
|
||||
<Button variant="primary" size="sm" onClick={onAction}>
|
||||
{actionLabel}
|
||||
</Button>
|
||||
)}
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -24,11 +24,11 @@ const buttonVariants = cva(
|
|||
/** Outlined - secondary actions, cancel */
|
||||
outline:
|
||||
'border border-border bg-transparent text-foreground hover:bg-muted/50 hover:border-border',
|
||||
/** Secondary - less prominent actions */
|
||||
/** Secondary - less prominent (Spotify/Discord style) */
|
||||
secondary:
|
||||
'bg-muted text-foreground hover:bg-muted/80 border border-border',
|
||||
'bg-white/5 text-foreground hover:bg-white/10 border border-white/10 hover:border-white/20',
|
||||
/** Ghost - icon buttons, menu items */
|
||||
ghost: 'hover:bg-muted/50 text-foreground',
|
||||
ghost: 'text-muted-foreground hover:text-foreground hover:bg-white/5',
|
||||
/** Gaming style - accent warning/gold */
|
||||
gaming:
|
||||
'bg-muted border border-warning/40 text-warning hover:bg-warning/10 hover:border-warning font-bold tracking-wider uppercase',
|
||||
|
|
@ -38,9 +38,9 @@ const buttonVariants = cva(
|
|||
/** Nature style - success green */
|
||||
nature:
|
||||
'bg-muted border border-success/30 text-success hover:bg-success/10 hover:border-success/50',
|
||||
/** Glass - glassmorphism */
|
||||
/** Glass - glassmorphism (Spotify/Discord) */
|
||||
glass:
|
||||
'bg-white/5 backdrop-blur-md border border-white/10 text-foreground hover:bg-white/10 hover:border-white/20 shadow-lg',
|
||||
'bg-white/5 backdrop-blur-md border border-white/10 text-foreground hover:bg-white/10 hover:border-white/15',
|
||||
},
|
||||
size: {
|
||||
/** Default size - standard buttons */
|
||||
|
|
|
|||
Loading…
Reference in a new issue