/** * V2 Smoke Test — 验证主题切换/登录/导航/Modal 等关键交互在 V2 改动下没坏。 * * Run: node test/v2-smoke.mjs * Pre-req: backend 8000 + frontend 5173 都已启动 */ import { chromium } from '@playwright/test'; const BASE = 'http://localhost:5173'; const API = 'http://localhost:8000'; const ADMIN = { username: 'admin', password: 'admin123' }; const TEAM_USER = { username: 'screenshot_user', password: 'shotpass123' }; const results = []; const errors = []; function pass(name) { results.push({ name, status: '✓ PASS' }); console.log(` ✓ ${name}`); } function fail(name, err) { results.push({ name, status: '✗ FAIL' }); errors.push({ name, err: err?.message || String(err) }); console.log(` ✗ ${name}: ${err?.message || err}`); } async function login(page, creds) { const res = await page.request.post(`${API}/api/v1/auth/login`, { data: creds }); if (!res.ok()) throw new Error(`login ${creds.username}: ${res.status()}`); const body = await res.json(); await page.goto(`${BASE}/login`, { waitUntil: 'domcontentloaded' }); await page.evaluate(({ access, refresh, user }) => { localStorage.setItem('access_token', access); if (refresh) localStorage.setItem('refresh_token', refresh); if (user) localStorage.setItem('user', JSON.stringify(user)); }, { access: body?.tokens?.access, refresh: body?.tokens?.refresh, user: body?.user }); } async function main() { const browser = await chromium.launch({ headless: true }); const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } }); const page = await ctx.newPage(); const consoleErrors = []; const pageErrors = []; page.on('console', (msg) => { if (msg.type() === 'error') { const t = msg.text(); // 忽略已知 API 401/404 (test_db 数据缺失) + DevTools React 警告 if (/401|404|Failed to load resource|Download the React DevTools/.test(t)) return; consoleErrors.push(t); } }); page.on('pageerror', (e) => pageErrors.push(e.message)); console.log('\n══════════════════════════════════════════'); console.log(' V2 SMOKE TEST — 主题切换 / 登录 / 导航'); console.log('══════════════════════════════════════════'); // ───────────────────────────────────── // Group 1: Login page (no auth) + LoginModal submitBtn // ───────────────────────────────────── console.log('\n[1/5] Login Page + LoginModal'); try { await page.goto(`${BASE}/login`, { waitUntil: 'domcontentloaded' }); await page.waitForTimeout(800); // LoginModal 应自动弹出 (autoLogin) const hasLoginModal = await page.locator('input[type="text"]').first().isVisible(); if (hasLoginModal) pass('1.1 LoginModal 自动弹出 (autoLogin)'); else fail('1.1 LoginModal 自动弹出', new Error('找不到用户名输入框')); // 数据-theme 属性默认应 dark const theme1 = await page.evaluate(() => document.documentElement.dataset.theme); if (theme1 === 'dark') pass('1.2 默认主题=dark'); else fail('1.2 默认主题=dark', new Error(`got ${theme1}`)); // V2: 浅色切换后 LoginModal 仍可见 (LandingPage data-theme="dark" 被移除) await page.evaluate(() => { localStorage.setItem('airdrama-theme', 'light'); document.documentElement.dataset.theme = 'light'; }); await page.reload({ waitUntil: 'domcontentloaded' }); await page.waitForTimeout(800); const lightLoginVisible = await page.locator('input[type="text"]').first().isVisible(); if (lightLoginVisible) pass('1.3 浅色 LoginModal 仍渲染 (V2 关键)'); else fail('1.3 浅色 LoginModal 仍渲染', new Error('浅色下 LoginModal 不可见')); // submitBtn 仍可点 (V2 字重 600 + 加 box-shadow,不改 onClick) const submitBtn = page.locator('button:has-text("登录")').first(); const submitVisible = await submitBtn.isVisible(); if (submitVisible) pass('1.4 submitBtn 在浅色下仍可见可点'); else fail('1.4 submitBtn', new Error('登录按钮不可见')); } catch (e) { fail('1.x LoginPage block', e); } // ───────────────────────────────────── // Group 2: 实际登录 — admin // ───────────────────────────────────── console.log('\n[2/5] 登录 admin 走完真实流程'); try { await page.evaluate(() => { localStorage.clear(); }); await page.goto(`${BASE}/login`, { waitUntil: 'domcontentloaded' }); await page.waitForTimeout(600); await page.locator('input[type="text"]').first().fill(ADMIN.username); await page.locator('input[type="password"]').first().fill(ADMIN.password); await page.locator('button:has-text("登录")').first().click(); await page.waitForURL((url) => url.pathname.startsWith('/admin'), { timeout: 8000 }); pass('2.1 admin 登录成功 → 跳转 /admin/*'); // access_token 已持久化 const hasToken = await page.evaluate(() => !!localStorage.getItem('access_token')); if (hasToken) pass('2.2 access_token 写入 localStorage'); else fail('2.2 access_token', new Error('localStorage 没有 access_token')); } catch (e) { fail('2.x admin 登录', e); } // ───────────────────────────────────── // Group 3: Theme toggle 端到端 // ───────────────────────────────────── console.log('\n[3/5] Theme Toggle 端到端'); try { // 确保深色态进入 await page.evaluate(() => { localStorage.setItem('airdrama-theme', 'dark'); document.documentElement.dataset.theme = 'dark'; }); await page.reload({ waitUntil: 'domcontentloaded' }); await page.waitForTimeout(800); const themeBefore = await page.evaluate(() => document.documentElement.dataset.theme); if (themeBefore === 'dark') pass('3.1 初始 theme=dark'); else fail('3.1 初始 theme', new Error(`got ${themeBefore}`)); // 找 Sidebar 切换按钮 (aria-label 含 "切换到浅色主题") const toggle = page.locator('button[aria-label*="切换"]').first(); const toggleVisible = await toggle.isVisible(); if (toggleVisible) pass('3.2 Sidebar 主题切换按钮可见'); else fail('3.2 切换按钮', new Error('找不到切换按钮')); if (toggleVisible) { await toggle.click(); await page.waitForTimeout(500); const themeAfter = await page.evaluate(() => document.documentElement.dataset.theme); if (themeAfter === 'light') pass('3.3 点按钮 → theme=light'); else fail('3.3 切换到 light', new Error(`got ${themeAfter}`)); // localStorage 持久化 const stored = await page.evaluate(() => localStorage.getItem('airdrama-theme')); if (stored === 'light') pass('3.4 localStorage 持久化 light'); else fail('3.4 localStorage', new Error(`got ${stored}`)); // 刷新后保持 await page.reload({ waitUntil: 'domcontentloaded' }); await page.waitForTimeout(600); const themePersisted = await page.evaluate(() => document.documentElement.dataset.theme); if (themePersisted === 'light') pass('3.5 刷新后 theme=light 保持'); else fail('3.5 刷新持久化', new Error(`got ${themePersisted}`)); // 切回深色 await toggle.click(); await page.waitForTimeout(300); const themeBack = await page.evaluate(() => document.documentElement.dataset.theme); if (themeBack === 'dark') pass('3.6 再点 → theme=dark (来回切换 OK)'); else fail('3.6 切回 dark', new Error(`got ${themeBack}`)); } } catch (e) { fail('3.x theme toggle', e); } // ───────────────────────────────────── // Group 4: Admin sidebar nav (V2 AdminLayout transparent + glass 改动) // ───────────────────────────────────── console.log('\n[4/5] Admin 侧栏导航'); const adminRoutes = [ { name: '用户管理', url: '/admin/users' }, { name: '消费记录', url: '/admin/records' }, { name: '内容资产', url: '/admin/assets' }, { name: '系统设置', url: '/admin/settings' }, { name: '安全日志', url: '/admin/security' }, { name: '操作日志', url: '/admin/logs' }, { name: '团队管理', url: '/admin/teams' }, ]; try { for (const r of adminRoutes) { try { await page.goto(`${BASE}${r.url}`, { waitUntil: 'domcontentloaded' }); await page.waitForTimeout(600); // 页面 mount 标志:body 不为空 + 找到 sidebar 主导航 const sidebarVisible = await page.locator('text=AirDrama Admin').first().isVisible().catch(() => false); if (sidebarVisible) pass(`4.x ${r.name} (${r.url}) 渲染 OK + sidebar 在`); else fail(`4.x ${r.name}`, new Error('sidebar 或页面没渲染')); } catch (e) { fail(`4.x ${r.name}`, e); } } } catch (e) { fail('4.x admin 导航', e); } // ───────────────────────────────────── // Group 5: Team user → 生成页 + Modal 交互 // ───────────────────────────────────── console.log('\n[5/5] 团管用户 → 生成页 + AnnouncementModal'); try { await ctx.clearCookies(); await page.evaluate(() => localStorage.clear()); await login(page, TEAM_USER); await page.goto(`${BASE}/app`, { waitUntil: 'domcontentloaded' }); await page.waitForTimeout(1200); // 生成页 sidebar 可见 (component/Sidebar 76px 窄版,V2 玻璃) const genSidebar = page.locator('aside').first(); const sidebarOk = await genSidebar.isVisible(); if (sidebarOk) pass('5.1 生成页 Sidebar (76px 玻璃版) 可见'); else fail('5.1 生成页 Sidebar', new Error('找不到 aside 元素')); // 主题切换按钮在 const themeBtn = page.locator('button[aria-label*="切换"]').first(); const themeBtnOk = await themeBtn.isVisible(); if (themeBtnOk) pass('5.2 团管 Sidebar 主题切换按钮在'); else fail('5.2 主题切换按钮', new Error('团管侧栏没切换按钮')); // 公告弹窗 — first-visit only,所以用 soft check (有就关掉,没有跳过) const announcement = page.locator('text=AirDrama 使用说明').first(); const annOk = await announcement.isVisible({ timeout: 1500 }).catch(() => false); if (annOk) { pass('5.3 AnnouncementModal 弹出 (首次访问)'); const closeBtn = page.locator('button:has-text("我知道了")').first(); if (await closeBtn.isVisible().catch(() => false)) { await closeBtn.click(); await page.waitForTimeout(400); const annStillOpen = await announcement.isVisible().catch(() => false); if (!annStillOpen) pass('5.4 公告 modal 关闭按钮 OK'); else fail('5.4 公告关闭', new Error('点了我知道了 modal 还在')); } } else { pass('5.3 公告 modal soft-skip (已读过)'); } // 5.5 生成页 InputBar 核心元素 — prompt 输入区可见 const promptInput = page.locator('[contenteditable="true"]').first(); const inputOk = await promptInput.isVisible({ timeout: 3000 }).catch(() => false); if (inputOk) pass('5.5 InputBar prompt 输入区可见 (生成页核心)'); else fail('5.5 InputBar', new Error('找不到 contenteditable prompt 输入')); } catch (e) { fail('5.x team user 生成页', e); } // ───────────────────────────────────── // 收尾 — console / pageerror // ───────────────────────────────────── if (consoleErrors.length > 0) { fail('JS 运行错误', new Error(`${consoleErrors.length} 个 console.error:\n ` + consoleErrors.slice(0, 5).join('\n '))); } else { pass('全程无 console.error'); } if (pageErrors.length > 0) { fail('JS 异常崩溃', new Error(`${pageErrors.length} 个未捕获:\n ` + pageErrors.slice(0, 5).join('\n '))); } else { pass('全程无 page crash'); } await browser.close(); // 汇总 console.log('\n══════════════════════════════════════════'); console.log(' 汇总'); console.log('══════════════════════════════════════════'); const passCount = results.filter(r => r.status.includes('PASS')).length; const failCount = results.filter(r => r.status.includes('FAIL')).length; console.log(`Pass: ${passCount} Fail: ${failCount}`); if (failCount > 0) { console.log('\n❌ 失败明细:'); errors.forEach(e => console.log(` • ${e.name}: ${e.err}`)); process.exit(1); } console.log('\n✅ 全部 smoke check 通过'); } main().catch(err => { console.error('❌ Smoke test crashed:', err); process.exit(1); });