video-shuoshan/web/test/v2-smoke.mjs
seaislee1209 5f30258112 test+fix(theme): V2 smoke test (25/25 pass) + 修复 AdminLayout 缺主题切换按钮
新增功能回归 smoke test 覆盖 V2 改动点:
  test/v2-smoke.mjs — 25 个端到端检查,涵盖:
    1) LoginPage + LoginModal 浅色渲染 (4 项)
    2) admin 真实登录流程 + access_token 持久化 (2 项)
    3) 主题切换端到端 — 默认 dark / 点切换 → light / localStorage 持久化 /
       刷新保持 / 来回切换 (6 项)
    4) Admin 7 个路由侧栏导航 (7 项)
    5) 团管 Sidebar + 生成页 InputBar 核心元素 (4 项 + 公告 soft-skip)
    收尾: 全程无 console.error / 无 page crash (2 项)

发现并修复 1 个真 bug:
  AdminLayout (super admin 后台) 缺主题切换按钮。
  原因:V1 只给 components/Sidebar.tsx (76px 团管侧栏) 加了 toggle 按钮,
  pages/AdminLayout.tsx (220px super admin 侧栏) 漏了。super admin 无法切主题。
  修复:在 AdminLayout sidebarFooter 加月亮/太阳 SVG 切换按钮,
  跟 components/Sidebar 一致;收起态只显示 icon,展开态 icon + 文字。
  新增 .themeToggle class 样式 (hover bg + primary color)。

验证: tsc 过 / smoke 25/25 / 无 console.error / 无 page crash
本地 commit dev 不 push

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 21:03:53 +08:00

288 lines
13 KiB
JavaScript

/**
* 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);
});