From 1efafe0cc5f0dc99762a73286c8527aba726c05b Mon Sep 17 00:00:00 2001 From: senke Date: Sat, 7 Feb 2026 20:30:49 +0100 Subject: [PATCH] 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 --- apps/web/.storybook/decorators.tsx | 12 ++- apps/web/.storybook/preview.tsx | 2 +- .../e2e/tests/storybook/storybook-all.spec.ts | 80 +++++++++++++++++++ apps/web/package.json | 3 +- apps/web/playwright.config.storybook.ts | 59 ++++++++++++++ apps/web/scripts/serve-storybook-static.cjs | 41 ++++++++++ apps/web/src/components/ui/KodoEmptyState.tsx | 28 +++---- apps/web/src/components/ui/button.tsx | 10 +-- 8 files changed, 204 insertions(+), 31 deletions(-) create mode 100644 apps/web/e2e/tests/storybook/storybook-all.spec.ts create mode 100644 apps/web/playwright.config.storybook.ts create mode 100644 apps/web/scripts/serve-storybook-static.cjs diff --git a/apps/web/.storybook/decorators.tsx b/apps/web/.storybook/decorators.tsx index 5c55ea5ed..aff9d471e 100644 --- a/apps/web/.storybook/decorators.tsx +++ b/apps/web/.storybook/decorators.tsx @@ -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) => {
diff --git a/apps/web/.storybook/preview.tsx b/apps/web/.storybook/preview.tsx index 182fe014a..d58ead34f 100644 --- a/apps/web/.storybook/preview.tsx +++ b/apps/web/.storybook/preview.tsx @@ -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' }, ], diff --git a/apps/web/e2e/tests/storybook/storybook-all.spec.ts b/apps/web/e2e/tests/storybook/storybook-all.spec.ts new file mode 100644 index 000000000..8888ad398 --- /dev/null +++ b/apps/web/e2e/tests/storybook/storybook-all.spec.ts @@ -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)); +} diff --git a/apps/web/package.json b/apps/web/package.json index 4040103ac..8876087ea 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/apps/web/playwright.config.storybook.ts b/apps/web/playwright.config.storybook.ts new file mode 100644 index 000000000..fa7bc3a31 --- /dev/null +++ b/apps/web/playwright.config.storybook.ts @@ -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(), + }, +}); diff --git a/apps/web/scripts/serve-storybook-static.cjs b/apps/web/scripts/serve-storybook-static.cjs new file mode 100644 index 000000000..a355118eb --- /dev/null +++ b/apps/web/scripts/serve-storybook-static.cjs @@ -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)`); + }); +} diff --git a/apps/web/src/components/ui/KodoEmptyState.tsx b/apps/web/src/components/ui/KodoEmptyState.tsx index bf18c7da6..cfe069607 100644 --- a/apps/web/src/components/ui/KodoEmptyState.tsx +++ b/apps/web/src/components/ui/KodoEmptyState.tsx @@ -28,35 +28,29 @@ export function KodoEmptyState({ className, )} > - {/* Subtle gradient orbs */} -
-
-
+ {/* Subtle ambient orbs */} +
+
+
-
-
- +
+
+
-

+

{title}

-

+

{description}

{actionLabel && onAction && ( - )} diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx index df5209d35..d06b8278a 100644 --- a/apps/web/src/components/ui/button.tsx +++ b/apps/web/src/components/ui/button.tsx @@ -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 */