import { test, expect } from '@playwright/test'; import AxeBuilder from '@axe-core/playwright'; /** * 移动端可访问性专项测试 * * 覆盖范围: * 1. axe-core 合规扫描(WCAG 2.1 AA)— 移动视口 * 2. 触摸目标尺寸 ≥ 44px(WCAG 2.5.5) * 3. 焦点管理(移动端 Tab 导航) * 4. 表单标签关联 * 5. 图片 Alt 文本 * 6. 颜色对比度检查 * * 标记:@mobile @accessibility */ test.setTimeout(60000); // ==================== 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('首页交互元素触摸目标 ≥ 44px', 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 }> = []; 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`); }); } // 允许少量例外(如小图标装饰按钮),但不应超过 5 个 expect(smallTargets.length).toBeLessThanOrEqual(5); }); }); // ==================== 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' }, () => { 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; }); expect(contrastIssues.length, `发现颜色对比度问题:\n${contrastIssues.join('\n')}`).toBe(0); }); });