Files
novalon-website/e2e/src/tests/accessibility/accessibility.spec.ts
T
张翔 9cbc80742a feat: 重构联系页面并增强安全性
refactor: 优化导航和路由逻辑

fix: 修复移动端样式问题

perf: 优化字体加载和性能

test: 添加安全性和可访问性测试

style: 调整按钮和表单样式

chore: 更新依赖版本

ci: 添加安全头配置

build: 优化构建配置

docs: 更新常量信息
2026-03-01 10:56:54 +08:00

334 lines
11 KiB
TypeScript

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();
}
}
});
});