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 React from 'react';
|
||||||
import type { Decorator } from '@storybook/react';
|
import type { Decorator } from '@storybook/react';
|
||||||
|
import { cn } from '../src/lib/utils';
|
||||||
import { ThemeProvider } from '../src/components/theme/ThemeProvider';
|
import { ThemeProvider } from '../src/components/theme/ThemeProvider';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { MemoryRouter } from 'react-router-dom';
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
|
@ -36,13 +37,10 @@ export const StorybookDecorator: Decorator = (Story, context) => {
|
||||||
<I18nextProvider i18n={i18n}>
|
<I18nextProvider i18n={i18n}>
|
||||||
<ThemeProvider defaultTheme={isDark ? 'dark' : 'light'}>
|
<ThemeProvider defaultTheme={isDark ? 'dark' : 'light'}>
|
||||||
<div
|
<div
|
||||||
className={isDark ? 'dark' : ''}
|
className={cn(
|
||||||
style={{
|
isDark ? 'dark' : '',
|
||||||
minHeight: '100vh',
|
'min-h-layout-story min-h-screen p-4 bg-background text-foreground',
|
||||||
padding: '1rem',
|
)}
|
||||||
background: isDark ? '#0a0a0a' : '#ffffff',
|
|
||||||
color: isDark ? '#ffffff' : '#0a0a0a',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,7 @@ const preview: Preview = {
|
||||||
backgrounds: {
|
backgrounds: {
|
||||||
default: 'dark',
|
default: 'dark',
|
||||||
values: [
|
values: [
|
||||||
{ name: 'dark', value: '#0a0a0a' },
|
{ name: 'dark', value: '#121212' },
|
||||||
{ name: 'light', value: '#ffffff' },
|
{ name: 'light', value: '#ffffff' },
|
||||||
{ name: 'steel', value: '#1a1a2e' },
|
{ 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",
|
"prepare": "husky",
|
||||||
"storybook": "cross-env VITE_API_URL=/api/v1 VITE_USE_MSW=true VITE_STORYBOOK=true storybook dev -p 6006",
|
"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",
|
"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": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@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,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Subtle gradient orbs */}
|
{/* Subtle ambient orbs */}
|
||||||
<div className="absolute inset-0 opacity-20 group-hover:opacity-30 transition-opacity duration-700 pointer-events-none">
|
<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/30 rounded-full blur-[80px] animate-pulse-slow" />
|
<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/20 rounded-full blur-[80px] animate-pulse-slow delay-700" />
|
<div className="absolute bottom-1/4 right-1/4 w-64 h-64 bg-primary/30 rounded-full blur-3xl" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative mb-8 p-6">
|
<div className="relative mb-6 p-4">
|
||||||
<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">
|
<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-12 w-12 text-primary" />
|
<Icon className="h-10 w-10 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
</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}
|
{title}
|
||||||
</h3>
|
</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}
|
{description}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{actionLabel && onAction && (
|
{actionLabel && onAction && (
|
||||||
<Button
|
<Button variant="primary" size="sm" onClick={onAction}>
|
||||||
variant="primary"
|
{actionLabel}
|
||||||
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>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
|
|
@ -24,11 +24,11 @@ const buttonVariants = cva(
|
||||||
/** Outlined - secondary actions, cancel */
|
/** Outlined - secondary actions, cancel */
|
||||||
outline:
|
outline:
|
||||||
'border border-border bg-transparent text-foreground hover:bg-muted/50 hover:border-border',
|
'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:
|
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 - 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 style - accent warning/gold */
|
||||||
gaming:
|
gaming:
|
||||||
'bg-muted border border-warning/40 text-warning hover:bg-warning/10 hover:border-warning font-bold tracking-wider uppercase',
|
'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 style - success green */
|
||||||
nature:
|
nature:
|
||||||
'bg-muted border border-success/30 text-success hover:bg-success/10 hover:border-success/50',
|
'bg-muted border border-success/30 text-success hover:bg-success/10 hover:border-success/50',
|
||||||
/** Glass - glassmorphism */
|
/** Glass - glassmorphism (Spotify/Discord) */
|
||||||
glass:
|
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: {
|
size: {
|
||||||
/** Default size - standard buttons */
|
/** Default size - standard buttons */
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue