Files
novalon-website/e2e/visual-regression.spec.ts
T
zhangxiang 6bb7c557ee test(qa): e2e 装配修正 + 集成/门禁测试与工具链重构
- e2e:修死选择器与蜜罐误命中、补断言、GA4/security-headers 用例诚实标注
  skip 边界;新增 assert-helpers/fixtures/hydrated/primary-nav/touch-targets
  等复用桩。
- 集成层:config/test/jest.integration.config.js + tests-integration/ 真库
  一次性 SQLite 用例,teardown 守卫开发库指纹。
- 门禁工具链:jest.setup 归位到根、scripts/accessibility 全站 axe 节点计数、
  check-brand-text-token/check-motion-constraints 机械守卫、stryker/lighthouse
  配置收敛。
2026-09-28 10:48:08 +08:00

291 lines
14 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 './fixtures';
test.describe.configure({ mode: 'parallel' });
// dev 指示器 <nextjs-portal> 的剥离已上移到 e2e/fixtures.ts 的 context 夹具,全站所有 project 共用。
// 基线口径固定为「用户已表达 Cookie 偏好」。同意条由 2s 定时器后才渲染,慢引擎(firefox / webkit)
// 会在截图前把它画进首屏底部,快引擎(chromium)则赶不上 —— 这是与引擎速度相关的竞态,
// 且这两个 project 为规避 newContext 兼容噪声而使用空 storageState(见 5a4d310),拿不到预置偏好。
// 时间戳取常量而非 Date.now(),避免基线随采集日漂移。
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
localStorage.setItem(
'novalon-cookie-preferences',
JSON.stringify({
necessary: true,
analytics: false,
marketing: false,
functionality: true,
timestamp: 1784284000000,
}),
);
});
});
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' },
// 与 /products/erp 是不同路由:此前只在 Lighthouse URL 表里,视觉表缺它 ⇒ 同型覆盖缺口(验收 §8 限制⑦)
{ path: '/products/erp-upgrade', name: 'product-erp-upgrade', label: '产品 ERP 升级页' },
{ path: '/about/brand', name: 'about-brand', label: '品牌资产页' },
{ 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);
// N-15(c) 诊断结论:**装配缺陷,不是产品缺陷**。
// 旧选择器 `button, a[role="button"]` 的 .first() 取的是 DOM 序第一个 <button>,即桌面导航的
// 「产品」下拉触发器;它的祖先 <nav class="hidden md:flex …" data-testid="desktop-navigation">
// (src/components/layout/header.tsx:124)在 md(768px) 以下 display:none ⇒ 按设计不可见。
// 实测(同一 fixture:reducedMotion=reduce + 已同意 Cookie + 剥离 nextjs-portal,390x844 iPhone14):
// 匹配总数 4、其中可见 1;.first().isVisible() = false;
// 前 3 个匹配(产品/解决方案下拉 + header.tsx:169 那个 `hidden md:flex` 容器里的主题切换按钮)
// 的祖先链里各有一个 display:none 容器。
// 390px 下页面真正提供的按钮是 src/components/layout/header.tsx:184-196 的
// data-testid="mobile-menu-button"(aria-label="打开菜单"、style minWidth/minHeight 44px),
// 实测盒 44x44 @ (338,10) ⇒ 触控目标达标,导航入口存在 ⇒ 产品侧无缺陷。
// 修法:用 Playwright 的 :visible 伪类过滤,让每个 project 拍到「该视口下第一个真实可见的按钮」。
// 桌面/平板档目标元素不变(那里 .first() 本来就是可见节点,过滤不改变选择结果):
// 实测 1280px 下新选择器盒 = 81x43.98,与现存基线 button-default-visual-chromium-desktop.png 的 81x44 一致。
const primaryBtn = page.locator('button:visible, a[role="button"]:visible').first();
await expect(primaryBtn).toBeVisible();
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();
await expect(header).toBeVisible();
await expect(header).toHaveScreenshot('header-navigation.png');
});
test('页脚区域 - 视觉一致性', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded' });
await waitForPageStable(page);
const footer = page.locator('footer').first();
await expect(footer).toBeVisible();
await expect(footer).toHaveScreenshot('footer-section.png');
});
test('卡片组件 - 视觉样式', async ({ page }) => {
await page.goto('/products', { waitUntil: 'domcontentloaded' });
await waitForPageStable(page);
// N-15(b) 修正:旧选择器 [class*="card"] 在 /products 上匹配 **0 个元素**(实测 1280/834/390 三档
// 视口 count 均为 0)——本页卡片由 BentoItem 渲染,class 只有 Tailwind 的形状/颜色描述
// (`h-full md:col-span-2 lg:col-span-2 …`),不含 "card" 子串 ⇒ 用例在截图之前就死在
// `element(s) not found` 上,5/5 project 恒红,且 product-card.png 从未存在。
// 真实 DOM 的语义锚点:src/components/sections/bento-grid.tsx:48 输出 data-testid,
// 值由 src/app/(marketing)/products/products-content-v3.tsx:105 的
// `bento-product-card-${product.id}` 传入 ⇒ 实测 7 个匹配,首个为 bento-product-card-erp,
// 三档视口均 isVisible()=true(1280px 盒 594.5x791.8)。
const card = page.locator('[data-testid^="bento-product-card-"]').first();
await expect(card).toBeVisible();
// 钉住断言对象确实是 Bento 卡片单元(role="listitem" 的容器),防止选择器再次漂回空匹配
await expect(card).toHaveAttribute('role', 'listitem');
await expect(card).toHaveAttribute('data-bento-size', /.+/);
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);
// N-15(b) 修正:/contact 上 DOM 序第一个 input[type="text"] 是**反垃圾蜜罐**,它按设计隐藏
// (src/app/(marketing)/contact/contact-content-v3.tsx:431-439:
// style={{ display: 'none' }} + tabIndex={-1} + autoComplete="off" + aria-hidden="true"
// + aria-label="honeypot" + name="website")。
// 旧选择器 `input[type="text"], input[type="email"]` 的 .first() 恰好命中它
// (实测 name="website"、isVisible()=false,5/5 project 恒红,报 Expected: visible / Received: hidden)
// ⇒ 断言对象是一个故意不可见的安全控件,失败是测试错而不是页面坏。
// 蜜罐覆盖**不删**:改为在此单独、显式地断言它的存在与隐藏属性(这才是它真正的契约)。
const honeypot = page.locator('form input[name="website"]');
await expect(honeypot).toHaveCount(1);
await expect(honeypot).toHaveAttribute('type', 'text');
await expect(honeypot).toHaveAttribute('tabindex', '-1');
await expect(honeypot).toHaveAttribute('autocomplete', 'off');
await expect(honeypot).toHaveAttribute('aria-hidden', 'true');
await expect(honeypot).toHaveAttribute('aria-label', 'honeypot');
await expect(honeypot).toBeHidden();
// 可见字段:限定在表单内并用 Playwright 的 :visible 过滤掉蜜罐。
// 实测(1280/834/390 三档一致):form input 共 5 个、可见 4 个,
// 第一个可见者 = <input id="name" name="name" data-testid="name-input">(真实姓名框,
// 由 src/components/ui/input.tsx:29 的 {...props} 透传 testid),蜜罐被自然排除。
const input = page.locator('form input:visible').first();
// 回归护栏:选中的必须是带 name 的真实字段,永不再漂回蜜罐
await expect(input).toHaveAttribute('name', /^(name|email|phone|subject)$/);
await expect(input).not.toHaveAttribute('aria-hidden', 'true');
await expect(input).toBeVisible();
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();
await expect(h1).toBeVisible();
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);
expect(isNaN(size)).toBe(false);
expect(size).toBeGreaterThanOrEqual(16);
expect(styles.color).toBeTruthy();
expect(styles.fontFamily).toBeTruthy();
expect(styles.fontWeight).toBeTruthy();
expect(styles.lineHeight).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(),
};
});
// 逐个令牌断言,不用 `a || b` —— 任一令牌缺失即门失效
for (const [token, value] of Object.entries(primaryColor)) {
expect(value, `--color-${token} 未解析出值`).toBeTruthy();
}
});
});