- 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 配置收敛。
203 lines
8.4 KiB
TypeScript
203 lines
8.4 KiB
TypeScript
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}`);
|
||
});
|
||
}); |