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:
2026-09-28 10:48:08 +08:00
parent a366bd1400
commit 6bb7c557ee
142 changed files with 6597 additions and 2653 deletions
+45 -58
View File
@@ -1,5 +1,6 @@
import { test, expect } from '@playwright/test';
import { test, expect, devices } from './fixtures';
import AxeBuilder from '@axe-core/playwright';
import { scanTouchTargets, formatTargets } from './touch-targets';
/**
* 移动端可访问性专项测试
@@ -17,6 +18,11 @@ import AxeBuilder from '@axe-core/playwright';
test.setTimeout(60000);
// 「移动端」在本文件名与 @mobile 标签里只是意图,viewport 实际由 project 决定:
// 同一份断言在桌面 project 量到的是桌面布局(实测 17 处小目标 vs 移动 11 处)。
// 与 mobile.spec.ts 保持同一约定,钉 iPhone 14 视口,四个引擎才是在量同一条移动布局。
test.use({ ...devices['iPhone 14'] });
// ==================== 1. axe-core 全量扫描 ====================
test.describe('移动端可访问性 - axe-core 合规扫描', { tag: '@mobile @accessibility' }, () => {
const pages = [
@@ -67,52 +73,23 @@ test.describe('移动端可访问性 - axe-core 合规扫描', { tag: '@mobile @
// ==================== 2. 触摸目标尺寸检查 ====================
test.describe('移动端可访问性 - 触摸目标尺寸', { tag: '@mobile @accessibility' }, () => {
test('首页交互元素触摸目标 ≥ 44px', async ({ page }) => {
test('首页交互元素触摸目标:AA 2.5.8 ≥ 24px 硬门禁', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(1500);
await page.evaluate(() => window.scrollTo(0, 0));
await page.waitForTimeout(300);
// 检查可见区域内的交互元素
const smallTargets = await page.evaluate(() => {
const interactiveElements = document.querySelectorAll(
'a, button, input, select, textarea, [role="button"], [tabindex]:not([tabindex="-1"])'
);
const issues: Array<{ tag: string; text: string; width: number; height: number }> = [];
const { aaViolations, aaaShortlist } = await scanTouchTargets(page);
interactiveElements.forEach(el => {
const rect = el.getBoundingClientRect();
// 只检查可见元素
if (rect.width > 0 && rect.height > 0 && rect.width < 1000) {
if (rect.width < 44 || rect.height < 44) {
const text = el.textContent?.trim().slice(0, 30) || el.tagName;
// 排除内联文本链接(段落中的 a 标签)
const isInline = el.closest('p, span, li, h1, h2, h3, h4, h5, h6');
if (!isInline) {
issues.push({
tag: el.tagName,
text: text,
width: Math.round(rect.width),
height: Math.round(rect.height),
});
}
}
}
});
return issues;
});
if (smallTargets.length > 0) {
console.log(`触摸目标不足 44px 的元素 (${smallTargets.length} 个):`);
smallTargets.slice(0, 10).forEach(t => {
console.log(` <${t.tag}> "${t.text}" — ${t.width}x${t.height}px`);
});
if (aaaShortlist.length > 0) {
console.log(`未达 AAA 2.5.5(44px)的目标 ${aaaShortlist.length} 个:\n${formatTargets(aaaShortlist)}`);
}
// 允许少量例外(如小图标装饰按钮),但不应超过 5 个
expect(smallTargets.length).toBeLessThanOrEqual(5);
expect(
aaViolations.length,
`低于 WCAG 2.2 AA 2.5.8(24×24)的触摸目标:\n${formatTargets(aaViolations)}`
).toBe(0);
});
});
@@ -190,27 +167,37 @@ test.describe('移动端可访问性 - 图片 Alt 文本', { tag: '@mobile @acce
// ==================== 6. 颜色对比度 ====================
test.describe('移动端可访问性 - 颜色对比度', { tag: '@mobile @accessibility' }, () => {
test('无白色文字在白色背景上的问题', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(1500);
const contrastIssues = await page.evaluate(() => {
const issues: string[] = [];
const textElements = document.querySelectorAll('p, h1, h2, h3, h4, h5, h6, span, a, li, td, th');
textElements.forEach(el => {
const style = window.getComputedStyle(el);
const color = style.color;
const bgColor = style.backgroundColor;
if (color === 'rgb(255, 255, 255)' && bgColor === 'rgb(255, 255, 255)') {
issues.push(`${el.tagName}: 白色文字在白色背景上`);
}
});
return issues;
// 验收 A-12:Cookie 同意条的「隐私政策」链接曾以 3.31:1 通过全站扫描 —— 因为 storageState.json
// 预置了 novalon-cookie-preferences,同意条在测试里根本不渲染,正文扫描永远看不见它。
// 这里显式清掉偏好、等条出现后再扫,且按 axe 的**节点数**归零(不按 impact 过滤),
// 否则「只拦 critical/serious」的旧口径会让同一条失败再次漏网。
test('Cookie 同意条可见时 axe color-contrast 违规节点为 0', async ({ page }) => {
await page.addInitScript(() => {
localStorage.removeItem('novalon-cookie-preferences');
localStorage.removeItem('ga_consent');
});
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
expect(contrastIssues.length, `发现颜色对比度问题:\n${contrastIssues.join('\n')}`).toBe(0);
const banner = page.locator('[data-testid="cookie-consent-banner"]');
await expect(banner, 'Cookie 同意条未出现(2s 定时器或偏好清除失效),本用例失去意义')
.toBeVisible({ timeout: 10000 });
// 同意条内的隐私政策链接才是 A-12 的现场;页面别处(footer)也有 /privacy 链接,
// 只按 href 定位会永远命中 footer,从而「通过」一个根本没被扫到的目标。
await expect(banner.locator('a[href="/privacy"]')).toBeVisible();
await page.waitForTimeout(600);
const results = await new AxeBuilder({ page })
.include('[data-testid="cookie-consent-banner"]')
.withRules(['color-contrast'])
.analyze();
const nodes = results.violations.flatMap((v) => v.nodes);
if (nodes.length > 0) {
console.log('color-contrast 违规节点:\n' + nodes.map((n) => `${n.impact} ${n.target.join(' ')} — ${n.failureSummary?.slice(0, 120)}`).join('\n'));
}
expect(nodes.length, `axe color-contrast 违规节点应为 0,实际 ${nodes.length}`).toBe(0);
// 放行条件⑤要求「节点计数 = 0」本身留痕,而不只是「用例通过」这一代理信号。
console.log(`[A-12 证据] axe color-contrast 在 cookie-consent-banner 范围内的违规节点数 = ${nodes.length}`);
});
});