Files
novalon-website/e2e/visual-regression.spec.ts
T
zhangxiang 0bd1e9a7e2 feat(theme): 主题三态偏好模型,默认跟随系统自动切换暗黑模式
- 新增 src/lib/theme.ts:三态偏好单一真源
  (system/light/dark,无存储值默认跟随系统)
- ThemeToggle 改三态循环(Monitor→Sun→Moon):
  实时监听 prefers-color-scheme(仅 system 档生效)
  + storage 跨标签页同步;尺寸位置不变
- layout.tsx FOUC 内联脚本支持 system 档,
  首帧前解析避免闪烁
- e2e 视觉回归 L2 改用 test.use({ colorScheme }) 驱动
  (事后 setAttribute 存在被挂载效果覆盖的竞态)
- header.test.tsx lucide 白名单补 Monitor
- CLAUDE.md 同步三态语义与 variant 踩坑
- 新增 scripts/audit/theme-system-verify.mjs
  (55 断言:首帧无闪烁/循环/实时跟随/旧值兼容/
  污染回退/6 路由冒烟)

验证:单测 131 套件 1662 通过 0 失败;
tsc 0 错;eslint 0 错;next build 通过;
运行时 55/55 PASS(dogfood-output/theme-system-verify/)
2026-09-20 11:50:58 +08:00

220 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test, expect, Page } from '@playwright/test';
test.describe.configure({ mode: 'parallel' });
const VISUAL_TEST_PAGES = [
{ path: '/', name: 'home', label: '首页' },
{ path: '/about', name: 'about', label: '关于我们' },
{ path: '/contact', name: 'contact', label: '联系我们' },
{ path: '/products', name: 'products', label: '产品中心' },
{ path: '/products/erp', name: 'product-erp', label: '产品详情-ERP' },
{ path: '/solutions', name: 'solutions', label: '解决方案列表' },
{ path: '/solutions/manufacturing', name: 'solution-manufacturing', label: '解决方案详情-制造' },
{ path: '/services', name: 'services', label: '服务列表' },
{ path: '/services/software', name: 'service-software', label: '服务详情-软件开发' },
{ path: '/news', name: 'news', label: '新闻列表' },
{ path: '/news/company-founded', name: 'news-detail', label: '新闻详情' },
{ path: '/team', name: 'team', label: '团队介绍' },
{ path: '/methodology', name: 'methodology', label: '方法论' },
];
async function waitForPageStable(page: Page) {
await page.waitForLoadState('networkidle');
await page.waitForTimeout(2000);
// 触发全页 ScrollReveal / StaggerReveal:先滚到底再回顶,让所有 below-the-fold
// 模块的 whileInView 动画进入最终态,避免全页截图时内容停在 opacity:0 初始态。
// 视觉回归项目已统一 reducedMotion:'reduce',此滚动作为双保险,覆盖任何媒体查询
// 在 viewport resize 期间短暂失真的边界情况。
await page.evaluate(() => window.scrollTo({ top: document.body.scrollHeight, behavior: 'instant' }));
await page.waitForTimeout(800);
await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'instant' }));
await page.waitForTimeout(200);
await page.evaluate(() => {
document.querySelectorAll('img').forEach(img => {
if (!img.complete) {
img.loading = 'eager';
}
});
});
await page.waitForTimeout(1000);
await page.evaluate(() => {
document.fonts?.ready;
});
await page.waitForTimeout(500);
}
test.describe('L1: 全页面视觉回归测试', () => {
test.setTimeout(60000);
for (const { path, name, label } of VISUAL_TEST_PAGES) {
test(`${label} (${path}) - 全页截图对比`, async ({ page }) => {
await page.goto(path, { waitUntil: 'domcontentloaded' });
await waitForPageStable(page);
await expect(page).toHaveScreenshot(`${name}-fullpage.png`, {
fullPage: true,
animations: 'disabled',
caret: 'hide',
});
});
}
});
test.describe('L2: 组件视觉状态测试', () => {
test.setTimeout(45000);
test('按钮组件 - 默认/悬停/聚焦状态', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded' });
await waitForPageStable(page);
const primaryBtn = page.locator('button, a[role="button"]').first();
if (await primaryBtn.count() > 0 && await primaryBtn.isVisible()) {
await expect(primaryBtn).toHaveScreenshot('button-default.png');
await primaryBtn.hover();
await page.waitForTimeout(300);
await expect(primaryBtn).toHaveScreenshot('button-hover.png');
await primaryBtn.focus();
await page.waitForTimeout(200);
await expect(primaryBtn).toHaveScreenshot('button-focus.png');
}
});
test('导航菜单 - 桌面端视觉', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded' });
await waitForPageStable(page);
const header = page.locator('header').first();
if (await header.isVisible()) {
await expect(header).toHaveScreenshot('header-navigation.png');
}
});
test('页脚区域 - 视觉一致性', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded' });
await waitForPageStable(page);
const footer = page.locator('footer').first();
if (await footer.isVisible()) {
await expect(footer).toHaveScreenshot('footer-section.png');
}
});
test('卡片组件 - 视觉样式', async ({ page }) => {
await page.goto('/products', { waitUntil: 'domcontentloaded' });
await waitForPageStable(page);
const card = page.locator('[class*="card"]').first();
if (await card.count() > 0 && await card.isVisible()) {
await expect(card).toHaveScreenshot('product-card.png');
}
});
test('创始团队观点 - 桌面端视觉', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded' });
await waitForPageStable(page);
const founder = page.locator('[data-testid="founder-quote-section"]');
await founder.scrollIntoViewIfNeeded();
await expect(founder).toHaveScreenshot('founder-quote-section.png');
});
test('表单输入框 - 默认/聚焦状态', async ({ page }) => {
await page.goto('/contact', { waitUntil: 'domcontentloaded' });
await waitForPageStable(page);
const input = page.locator('input[type="text"], input[type="email"]').first();
if (await input.count() > 0 && await input.isVisible()) {
await expect(input).toHaveScreenshot('input-default.png');
await input.click();
await page.waitForTimeout(200);
await expect(input).toHaveScreenshot('input-focused.png');
}
});
});
test.describe('L2: 主题切换视觉测试', () => {
test.setTimeout(45000);
// 主题由「系统偏好」驱动(默认跟随系统,见 src/lib/theme.ts),因此用 Playwright 的
// colorScheme 上下文选项模拟 OS 偏好,而不是事后 setAttribute
// 挂载后的 ThemeToggle 会把 html[data-theme] 重置为解析结果,事后改属性存在竞态
// hydration 晚于 setAttribute 时会被覆盖,深色快照拍成浅色)。
test.describe('系统偏好浅色', () => {
test.use({ colorScheme: 'light' });
test('浅色主题 - 首页视觉', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded' });
await waitForPageStable(page);
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
await expect(page.locator('main').first()).toHaveScreenshot('theme-light-main.png');
});
});
test.describe('系统偏好深色', () => {
test.use({ colorScheme: 'dark' });
test('深色主题 - 首页视觉', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded' });
await waitForPageStable(page);
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
await expect(page.locator('main').first()).toHaveScreenshot('theme-dark-main.png');
});
});
});
test.describe('L3: 排版与色彩验证', () => {
test.setTimeout(30000);
test('首页 H1 标题字体样式', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded' });
await waitForPageStable(page);
const h1 = page.locator('h1').first();
if (await h1.count() > 0 && await h1.isVisible()) {
const styles = await h1.evaluate(el => {
const computed = window.getComputedStyle(el);
return {
fontSize: computed.fontSize,
fontWeight: computed.fontWeight,
fontFamily: computed.fontFamily,
color: computed.color,
lineHeight: computed.lineHeight,
};
});
const size = parseFloat(styles.fontSize);
// Support clamp()/variable-based font sizes that may resolve differently
if (!isNaN(size)) {
expect(size).toBeGreaterThanOrEqual(16);
}
expect(styles.color || styles.fontFamily).toBeTruthy();
}
});
test('品牌主色调验证', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded' });
await waitForPageStable(page);
const primaryColor = await page.evaluate(() => {
const style = getComputedStyle(document.documentElement);
return {
brand: style.getPropertyValue('--color-brand')?.trim(),
ink: style.getPropertyValue('--color-ink')?.trim(),
bg: style.getPropertyValue('--color-bg-primary')?.trim(),
text: style.getPropertyValue('--color-text-primary')?.trim(),
};
});
expect(primaryColor.brand || primaryColor.ink || primaryColor.bg).toBeTruthy();
});
});