9cbc80742a
refactor: 优化导航和路由逻辑 fix: 修复移动端样式问题 perf: 优化字体加载和性能 test: 添加安全性和可访问性测试 style: 调整按钮和表单样式 chore: 更新依赖版本 ci: 添加安全头配置 build: 优化构建配置 docs: 更新常量信息
334 lines
11 KiB
TypeScript
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();
|
|
}
|
|
}
|
|
});
|
|
}); |