import { test, expect } from '@playwright/test'; test.describe('可访问性测试 @accessibility', () => { test('页面应该有lang属性', async ({ page }) => { await page.goto('http://localhost:3000'); const html = page.locator('html'); await expect(html).toHaveAttribute('lang', 'zh-CN'); }); test('页面应该有正确的标题层级', async ({ page }) => { await page.goto('http://localhost:3000'); const headings = page.locator('h1, h2, h3, h4, h5, h6'); const count = await headings.count(); expect(count).toBeGreaterThan(0); const firstHeading = headings.first(); const firstTag = await firstHeading.evaluate(el => el.tagName.toLowerCase()); expect(firstTag).toBe('h1'); }); test('所有图片应该有alt属性', async ({ page }) => { await page.goto('http://localhost:3000'); const images = page.locator('img'); const count = await images.count(); for (let i = 0; i < count; i++) { const img = images.nth(i); const alt = await img.getAttribute('alt'); expect(alt).toBeTruthy(); expect(alt?.length).toBeGreaterThan(0); } }); test('表单输入应该有label', async ({ page }) => { await page.goto('http://localhost:3000/contact'); await page.waitForLoadState('networkidle'); const inputs = page.locator('input:not([type="hidden"]), textarea, select'); const count = await inputs.count(); for (let i = 0; i < count; i++) { const input = inputs.nth(i); const hasLabel = await input.evaluate(el => { const id = el.getAttribute('id'); const ariaLabel = el.getAttribute('aria-label'); const ariaLabelledBy = el.getAttribute('aria-labelledby'); const hasLabelFor = id && document.querySelector(`label[for="${id}"]`); const hasParentLabel = el.closest('label'); return !!(ariaLabel || ariaLabelledBy || hasLabelFor || hasParentLabel); }); expect(hasLabel).toBeTruthy(); } }); test('按钮应该有可访问的名称', async ({ page }) => { await page.goto('http://localhost:3000'); const buttons = page.locator('button, [role="button"]'); const count = await buttons.count(); for (let i = 0; i < count; i++) { const button = buttons.nth(i); const hasAccessibleName = await button.evaluate(el => { const text = el.textContent?.trim(); const ariaLabel = el.getAttribute('aria-label'); const ariaLabelledBy = el.getAttribute('aria-labelledby'); const title = el.getAttribute('title'); return !!(text || ariaLabel || ariaLabelledBy || title); }); expect(hasAccessibleName).toBeTruthy(); } }); test('链接应该有描述性文本', async ({ page }) => { await page.goto('http://localhost:3000'); const links = page.locator('a[href]').first(10); const count = await links.count(); for (let i = 0; i < count; i++) { const link = links.nth(i); const hasDescriptiveText = await link.evaluate(el => { const text = el.textContent?.trim(); const ariaLabel = el.getAttribute('aria-label'); const title = el.getAttribute('title'); const hasImg = el.querySelector('img[alt]'); return !!(text || ariaLabel || title || hasImg); }); expect(hasDescriptiveText).toBeTruthy(); } }); test('焦点元素应该可见', async ({ page }) => { await page.goto('http://localhost:3000'); await page.waitForLoadState('networkidle'); const focusableElements = page.locator('a[href]:visible, button:visible, input:visible, textarea:visible, select:visible, [tabindex]:not([tabindex="-1"]):visible'); const count = await focusableElements.count(); for (let i = 0; i < Math.min(count, 10); i++) { const element = focusableElements.nth(i); await element.focus(); const isVisible = await element.evaluate(el => { const style = window.getComputedStyle(el); return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0'; }); expect(isVisible).toBeTruthy(); } }); test('应该可以通过键盘导航', async ({ page }) => { await page.goto('http://localhost:3000'); const focusableElements = page.locator('a[href], button, input, textarea, select'); const count = await focusableElements.count(); if (count > 0) { await page.keyboard.press('Tab'); const firstFocused = await page.evaluate(() => { return document.activeElement?.tagName; }); expect(['A', 'BUTTON', 'INPUT', 'TEXTAREA', 'SELECT']).toContain(firstFocused || ''); } }); test('颜色对比度应该符合WCAG AA标准', async ({ page }) => { await page.goto('http://localhost:3000'); const textElements = page.locator('p, h1, h2, h3, h4, h5, h6, span, div').first(20); const count = await textElements.count(); for (let i = 0; i < count; i++) { const element = textElements.nth(i); const contrastRatio = await element.evaluate(el => { const style = window.getComputedStyle(el); const bgColor = style.backgroundColor; const textColor = style.color; if (bgColor === 'rgba(0, 0, 0, 0)' || bgColor === 'transparent') { return 21; } const getLuminance = (color: string) => { const rgb = color.match(/\d+/g); if (!rgb || rgb.length < 3) return 0; const [r, g, b] = rgb.map(Number).map(c => { c = c / 255; return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); }); return 0.2126 * r + 0.7152 * g + 0.0722 * b; }; const l1 = getLuminance(textColor); const l2 = getLuminance(bgColor); const lighter = Math.max(l1, l2); const darker = Math.min(l1, l2); return (lighter + 0.05) / (darker + 0.05); }); expect(contrastRatio).toBeGreaterThanOrEqual(4.5); } }); test('应该有跳过导航链接', async ({ page }) => { await page.goto('http://localhost:3000'); const skipLink = page.locator('a[href^="#"][class*="skip"], a[href^="#main"], a[href^="#content"]'); const hasSkipLink = await skipLink.count() > 0; if (hasSkipLink) { await skipLink.first().click(); const target = await skipLink.first().getAttribute('href'); const targetElement = page.locator(target || ''); await expect(targetElement.first()).toBeVisible(); } }); test('移动端菜单应该可以通过键盘关闭', async ({ page }) => { await page.setViewportSize({ width: 375, height: 667 }); await page.goto('http://localhost:3000'); const menuButton = page.locator('button[aria-label*="菜单"], button[aria-label*="打开"]'); await menuButton.click(); await page.waitForTimeout(500); const mobileMenu = page.locator('[role="navigation"][aria-label*="移动端"]'); await expect(mobileMenu).toBeVisible(); await page.keyboard.press('Escape'); await page.waitForTimeout(200); await expect(mobileMenu).not.toBeVisible(); await expect(menuButton).toHaveAttribute('aria-expanded', 'false'); }); test('表单错误应该与输入关联', async ({ page }) => { await page.goto('http://localhost:3000/contact'); const submitButton = page.locator('button[type="submit"]'); await submitButton.click(); const requiredInputs = page.locator('input[required], textarea[required]'); const count = await requiredInputs.count(); for (let i = 0; i < count; i++) { const input = requiredInputs.nth(i); const hasError = await input.evaluate(el => { const id = el.getAttribute('id'); const error = document.querySelector(`[role="alert"][for="${id}"], [aria-describedby*="${id}"]`); return !!error; }); if (hasError) { const ariaDescribedBy = await input.getAttribute('aria-describedby'); expect(ariaDescribedBy).toBeTruthy(); } } }); test('ARIA标签应该正确使用', async ({ page }) => { await page.goto('http://localhost:3000'); const ariaElements = page.locator('[aria-label], [aria-labelledby], [aria-describedby]'); const count = await ariaElements.count(); for (let i = 0; i < count; i++) { const element = ariaElements.nth(i); const isValidAria = await element.evaluate(el => { const ariaLabel = el.getAttribute('aria-label'); const ariaLabelledBy = el.getAttribute('aria-labelledby'); const ariaDescribedBy = el.getAttribute('aria-describedby'); if (ariaLabel) return ariaLabel.trim().length > 0; if (ariaLabelledBy) { const referenced = document.getElementById(ariaLabelledBy); return !!referenced; } if (ariaDescribedBy) { const referenced = document.getElementById(ariaDescribedBy); return !!referenced; } return true; }); expect(isValidAria).toBeTruthy(); } }); test('视频/音频应该有字幕', async ({ page }) => { await page.goto('http://localhost:3000'); const mediaElements = page.locator('video, audio'); const count = await mediaElements.count(); if (count > 0) { for (let i = 0; i < count; i++) { const media = mediaElements.nth(i); const hasCaptions = await media.evaluate(el => { const tagName = el.tagName.toLowerCase(); if (tagName === 'video') { return el.hasAttribute('crossorigin') || el.querySelector('track[kind="captions"], track[kind="subtitles"]'); } return true; }); expect(hasCaptions).toBeTruthy(); } } }); test('表格应该有正确的标题', async ({ page }) => { await page.goto('http://localhost:3000'); const tables = page.locator('table'); const count = await tables.count(); if (count > 0) { for (let i = 0; i < count; i++) { const table = tables.nth(i); const hasCaption = await table.evaluate(el => { return !!el.querySelector('caption') || el.hasAttribute('aria-label') || el.hasAttribute('title'); }); expect(hasCaption).toBeTruthy(); } } }); test('模态对话框应该有正确的ARIA属性', async ({ page }) => { await page.goto('http://localhost:3000'); const dialogs = page.locator('[role="dialog"], [role="alertdialog"]'); const count = await dialogs.count(); if (count > 0) { for (let i = 0; i < count; i++) { const dialog = dialogs.nth(i); const hasModalAttributes = await dialog.evaluate(el => { return el.hasAttribute('aria-modal') || el.hasAttribute('aria-labelledby') || el.hasAttribute('aria-label'); }); expect(hasModalAttributes).toBeTruthy(); } } }); });