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 配置收敛。
This commit is contained in:
+118
-47
@@ -1,13 +1,37 @@
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
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: '服务列表' },
|
||||
@@ -69,18 +93,31 @@ test.describe('L2: 组件视觉状态测试', () => {
|
||||
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');
|
||||
// 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.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');
|
||||
}
|
||||
await primaryBtn.focus();
|
||||
await page.waitForTimeout(200);
|
||||
await expect(primaryBtn).toHaveScreenshot('button-focus.png');
|
||||
});
|
||||
|
||||
test('导航菜单 - 桌面端视觉', async ({ page }) => {
|
||||
@@ -88,9 +125,8 @@ test.describe('L2: 组件视觉状态测试', () => {
|
||||
await waitForPageStable(page);
|
||||
|
||||
const header = page.locator('header').first();
|
||||
if (await header.isVisible()) {
|
||||
await expect(header).toHaveScreenshot('header-navigation.png');
|
||||
}
|
||||
await expect(header).toBeVisible();
|
||||
await expect(header).toHaveScreenshot('header-navigation.png');
|
||||
});
|
||||
|
||||
test('页脚区域 - 视觉一致性', async ({ page }) => {
|
||||
@@ -98,19 +134,28 @@ test.describe('L2: 组件视觉状态测试', () => {
|
||||
await waitForPageStable(page);
|
||||
|
||||
const footer = page.locator('footer').first();
|
||||
if (await footer.isVisible()) {
|
||||
await expect(footer).toHaveScreenshot('footer-section.png');
|
||||
}
|
||||
await expect(footer).toBeVisible();
|
||||
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');
|
||||
}
|
||||
// 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 }) => {
|
||||
@@ -126,14 +171,37 @@ test.describe('L2: 组件视觉状态测试', () => {
|
||||
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');
|
||||
// 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();
|
||||
|
||||
await input.click();
|
||||
await page.waitForTimeout(200);
|
||||
await expect(input).toHaveScreenshot('input-focused.png');
|
||||
}
|
||||
// 可见字段:限定在表单内并用 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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -179,25 +247,25 @@ test.describe('L3: 排版与色彩验证', () => {
|
||||
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,
|
||||
};
|
||||
});
|
||||
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);
|
||||
// Support clamp()/variable-based font sizes that may resolve differently
|
||||
if (!isNaN(size)) {
|
||||
expect(size).toBeGreaterThanOrEqual(16);
|
||||
}
|
||||
expect(styles.color || styles.fontFamily).toBeTruthy();
|
||||
}
|
||||
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 }) => {
|
||||
@@ -214,6 +282,9 @@ test.describe('L3: 排版与色彩验证', () => {
|
||||
};
|
||||
});
|
||||
|
||||
expect(primaryColor.brand || primaryColor.ink || primaryColor.bg).toBeTruthy();
|
||||
// 逐个令牌断言,不用 `a || b` —— 任一令牌缺失即门失效
|
||||
for (const [token, value] of Object.entries(primaryColor)) {
|
||||
expect(value, `--color-${token} 未解析出值`).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user