- 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 配置收敛。
291 lines
14 KiB
TypeScript
291 lines
14 KiB
TypeScript
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();
|
||
}
|
||
});
|
||
});
|