Files
novalon-website/e2e/mobile-accessibility.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

203 lines
8.4 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, devices } from './fixtures';
import AxeBuilder from '@axe-core/playwright';
import { scanTouchTargets, formatTargets } from './touch-targets';
/**
* 移动端可访问性专项测试
*
* 覆盖范围:
* 1. axe-core 合规扫描(WCAG 2.1 AA)— 移动视口
* 2. 触摸目标尺寸 ≥ 44px(WCAG 2.5.5)
* 3. 焦点管理(移动端 Tab 导航)
* 4. 表单标签关联
* 5. 图片 Alt 文本
* 6. 颜色对比度检查
*
* 标记:@mobile @accessibility
*/
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 = [
{ path: '/', name: '首页' },
{ path: '/about', name: '关于我们' },
{ path: '/contact', name: '联系我们' },
{ path: '/products', name: '产品中心' },
{ path: '/solutions', name: '解决方案' },
{ path: '/services', name: '服务' },
{ path: '/news', name: '新闻' },
{ path: '/cases', name: '案例' },
{ path: '/team', name: '团队' },
];
for (const { path, name } of pages) {
test(`${name} (${path}) — WCAG 2.1 AA 无严重违规`, async ({ page }) => {
await page.goto(path, { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(2000);
const accessibilityScanResults = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
.analyze();
const violations = accessibilityScanResults.violations;
// 输出违规详情用于分析
if (violations.length > 0) {
console.log(`\n=== ${name} 可访问性违规 (${violations.length} 项) ===`);
for (const v of violations) {
console.log(` [${v.impact}] ${v.id}: ${v.help}`);
console.log(` Nodes: ${v.nodes.length}, URL: ${v.helpUrl}`);
// 输出前 2 个节点的摘要
v.nodes.slice(0, 2).forEach((node, i) => {
const target = node.target?.join(', ') || 'unknown';
console.log(` Node ${i + 1}: ${target.slice(0, 100)}`);
});
}
}
// 允许少量 low/moderate 违规,但 critical/serious 应为 0
const criticalSerious = violations.filter(
v => v.impact === 'critical' || v.impact === 'serious'
);
expect(criticalSerious.length).toBe(0);
});
}
});
// ==================== 2. 触摸目标尺寸检查 ====================
test.describe('移动端可访问性 - 触摸目标尺寸', { tag: '@mobile @accessibility' }, () => {
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 { aaViolations, aaaShortlist } = await scanTouchTargets(page);
if (aaaShortlist.length > 0) {
console.log(`未达 AAA 2.5.5(44px)的目标 ${aaaShortlist.length} 个:\n${formatTargets(aaaShortlist)}`);
}
expect(
aaViolations.length,
`低于 WCAG 2.2 AA 2.5.8(24×24)的触摸目标:\n${formatTargets(aaViolations)}`
).toBe(0);
});
});
// ==================== 3. 焦点管理 ====================
test.describe('移动端可访问性 - 焦点管理', { tag: '@mobile @accessibility' }, () => {
test('焦点元素数量合理', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(1500);
const focusableCount = await page.evaluate(() => {
return document.querySelectorAll(
'a, button, input, select, textarea, [tabindex]:not([tabindex="-1"])'
).length;
});
console.log(`Mobile focusable elements: ${focusableCount}`);
expect(focusableCount).toBeGreaterThan(0);
});
});
// ==================== 4. 表单标签关联 ====================
test.describe('移动端可访问性 - 表单标签', { tag: '@mobile @accessibility' }, () => {
test('联系表单输入框有关联标签或 aria-label', async ({ page }) => {
await page.goto('/contact', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(2500);
const formInputs = await page.evaluate(() => {
const inputs = document.querySelectorAll('input, textarea, select');
const results: Array<{ tag: string; id: string; hasLabel: boolean; ariaLabel: string | null }> = [];
inputs.forEach(input => {
const id = input.id;
const ariaLabel = input.getAttribute('aria-label');
let hasLabel = false;
if (id) {
const label = document.querySelector(`label[for="${id}"]`);
hasLabel = label !== null;
}
results.push({
tag: input.tagName,
id: id || '',
hasLabel,
ariaLabel,
});
});
return results;
});
for (const input of formInputs) {
const hasAccessibleName = input.hasLabel || input.ariaLabel;
expect(
hasAccessibleName,
`Input ${input.tag}${input.id ? '#' + input.id : ''} 缺少可访问名称`
).toBeTruthy();
}
});
});
// ==================== 5. 图片 Alt 文本 ====================
test.describe('移动端可访问性 - 图片 Alt 文本', { tag: '@mobile @accessibility' }, () => {
test('首页图片有 alt 属性', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(2000);
const imagesWithoutAlt = await page.evaluate(() => {
const images = Array.from(document.querySelectorAll('img'));
return images.filter(img => !img.hasAttribute('alt') || img.getAttribute('alt') === null).length;
});
console.log(`Images without alt attribute: ${imagesWithoutAlt}`);
expect(imagesWithoutAlt).toBe(0);
});
});
// ==================== 6. 颜色对比度 ====================
test.describe('移动端可访问性 - 颜色对比度', { tag: '@mobile @accessibility' }, () => {
// 验收 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 });
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}`);
});
});