feat: 优化网站性能、响应式设计和测试覆盖率

- 更新next.config.ts配置以优化图片和静态资源
- 优化字体加载策略,减少首屏阻塞
- 使用Next.js Image组件替换img标签并实现懒加载
- 重构移动端菜单交互,提升触摸体验
- 新增安全测试和可访问性测试用例
- 修复导航栏滚动定位问题
- 更新部署就绪测试脚本
- 添加相关文档说明优化细节
This commit is contained in:
张翔
2026-02-28 22:32:45 +08:00
parent 7b2a8af19f
commit 13c4a2ca49
14 changed files with 2748 additions and 789 deletions
+304 -430
View File
@@ -1,458 +1,332 @@
import { test, expect } from '@playwright/test';
import { injectAxe, checkA11y, Violation } from '@axe-core/playwright';
import { HomePage } from '../../pages/HomePage';
import { ContactPage } from '../../pages/ContactPage';
import { ACCESSIBILITY_TEST_CASES } from '../../data/test-data';
test.describe('可访问性测试', () => {
test.describe('首页可访问性测试', () => {
let homePage: HomePage;
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.beforeEach(async ({ page }) => {
homePage = new HomePage(page);
await homePage.goto();
});
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('应该没有严重的可访问性违规', async () => {
await injectAxe(homePage.page);
const results = await checkA11y(homePage.page);
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');
const inputs = page.locator('input, 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);
});
const criticalViolations = results.violations.filter(
v => v.impact === 'critical'
);
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(criticalViolations.length).toBe(0);
});
expect(hasAccessibleName).toBeTruthy();
}
});
test('应该没有严重的可访问性违规', async () => {
await injectAxe(homePage.page);
const results = await checkA11y(homePage.page);
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);
});
const seriousViolations = results.violations.filter(
v => v.impact === 'serious'
);
expect(hasDescriptiveText).toBeTruthy();
}
});
test('焦点元素应该可见', async ({ page }) => {
await page.goto('http://localhost:3000');
const focusableElements = page.locator('a[href], button, input, textarea, select, [tabindex]:not([tabindex="-1"])');
const count = await focusableElements.count();
for (let i = 0; i < Math.min(count, 10); i++) {
const element = focusableElements.nth(i);
await element.focus();
expect(seriousViolations.length).toBe(0);
});
test('应该满足WCAG 2.1 AA标准', async () => {
await injectAxe(homePage.page);
const results = await checkA11y(homePage.page);
const isVisible = await element.evaluate(el => {
const style = window.getComputedStyle(el);
return style.display !== 'none' &&
style.visibility !== 'hidden' &&
style.opacity !== '0';
});
const wcagViolations = results.violations.filter(
v => v.tags.includes('wcag2a') || v.tags.includes('wcag21aa')
);
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');
expect(wcagViolations.length).toBeLessThan(5);
});
test('所有图片应该有alt属性', async () => {
const accessibility = await homePage.verifyAccessibility();
expect(accessibility.hasAltText).toBe(true);
});
test('所有交互元素应该有ARIA标签', async () => {
const accessibility = await homePage.verifyAccessibility();
expect(accessibility.hasAriaLabels).toBe(true);
});
test('应该能够通过键盘导航', async () => {
const accessibility = await homePage.verifyAccessibility();
expect(accessibility.hasKeyboardNavigation).toBe(true);
});
test('应该满足颜色对比度要求', async () => {
const hasValidContrast = await homePage.verifyColorContrast();
expect(hasValidContrast).toBe(true);
});
test('导航链接应该有正确的focus状态', async () => {
const navLinks = homePage.page.locator('nav a');
const count = await navLinks.count();
const firstFocused = await page.evaluate(() => {
return document.activeElement?.tagName;
});
for (let i = 0; i < count; i++) {
const link = navLinks.nth(i);
await link.focus();
const isFocused = await homePage.page.evaluate((el) =>
document.activeElement === el
);
expect(isFocused).toBe(true);
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('表单元素应该正确的标签', async () => {
const inputs = homePage.page.locator('input, select, textarea');
const count = await inputs.count();
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 input = inputs.nth(i);
const hasLabel = await input.evaluate((el) => {
const id = (el as HTMLInputElement).id;
const label = document.querySelector(`label[for="${id}"]`);
return label !== null || (el as HTMLInputElement).labels.length > 0;
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(hasLabel).toBe(true);
}
});
test('应该有正确的页面标题结构', async () => {
const h1Count = await homePage.page.locator('h1').count();
const h2Count = await homePage.page.locator('h2').count();
const h3Count = await homePage.page.locator('h3').count();
expect(h1Count).toBeLessThanOrEqual(1);
expect(h2Count).toBeGreaterThan(0);
expect(h3Count).toBeGreaterThan(0);
});
test('应该有正确的语言属性', async () => {
const html = homePage.page.locator('html');
const lang = await html.getAttribute('lang');
expect(lang).toBeTruthy();
expect(lang).toMatch(/^(zh|zh-CN|en-US)$/);
});
test('应该有正确的skip导航链接', async () => {
const skipLinks = homePage.page.locator('a[href^="#"]');
const count = await skipLinks.count();
for (let i = 0; i < count; i++) {
const link = skipLinks.nth(i);
const ariaLabel = await link.getAttribute('aria-label');
expect(ariaLabel).toBeTruthy();
}
});
});
test.describe('联系页面可访问性测试', () => {
let contactPage: ContactPage;
test.beforeEach(async ({ page }) => {
contactPage = new ContactPage(page);
await contactPage.goto();
});
test('应该没有严重的可访问性违规', async () => {
await injectAxe(contactPage.page);
const results = await checkA11y(contactPage.page);
const criticalViolations = results.violations.filter(
v => v.impact === 'critical'
);
expect(criticalViolations.length).toBe(0);
});
test('表单字段应该有正确的标签', async () => {
const labels = await contactPage.verifyFormLabels();
expect(labels.nameLabel).toBeTruthy();
expect(labels.emailLabel).toBeTruthy();
expect(labels.phoneLabel).toBeTruthy();
expect(labels.messageLabel).toBeTruthy();
});
test('表单字段应该有正确的ARIA属性', async () => {
const attributes = await contactPage.getFormAccessibilityAttributes();
expect(attributes.nameAriaLabel || attributes.submitAriaLabel).toBeTruthy();
expect(attributes.emailAriaLabel).toBeTruthy();
expect(attributes.phoneAriaLabel).toBeTruthy();
expect(attributes.messageAriaLabel).toBeTruthy();
});
test('必填字段应该有正确的标记', async () => {
const requiredFields = await contactPage.verifyRequiredFields();
expect(requiredFields.nameRequired).toBe(true);
expect(requiredFields.emailRequired).toBe(true);
expect(requiredFields.phoneRequired).toBe(true);
expect(requiredFields.messageRequired).toBe(true);
});
test('表单应该能够通过键盘导航', async () => {
const isAccessible = await contactPage.isFormKeyboardAccessible();
expect(isAccessible).toBe(true);
});
test('错误消息应该与相关字段关联', async () => {
await contactPage.fillContactForm({
name: '',
email: 'invalid-email',
phone: '123',
message: '',
});
await contactPage.submitForm();
await contactPage.waitForTimeout(1000);
const nameError = await contactPage.getNameError();
const emailError = await contactPage.getEmailError();
const phoneError = await contactPage.getPhoneError();
const messageError = await contactPage.getMessageError();
expect(nameError || emailError || phoneError || messageError).toBeTruthy();
});
test('提交按钮应该有正确的ARIA标签', async () => {
const attributes = await contactPage.getFormAccessibilityAttributes();
expect(attributes.submitAriaLabel).toBeTruthy();
});
test('表单应该有正确的autocomplete属性', async () => {
const autocomplete = await contactPage.getFormAutocompleteAttributes();
expect(autocomplete.nameAutocomplete).toBeTruthy();
expect(autocomplete.emailAutocomplete).toBeTruthy();
expect(autocomplete.phoneAutocomplete).toBeTruthy();
});
test('触摸目标应该足够大', async () => {
const touchTargets = contactPage.page.locator('button, a, input[type="submit"]');
const count = await touchTargets.count();
for (let i = 0; i < count; i++) {
const target = touchTargets.nth(i);
const boundingBox = await target.boundingBox();
if (boundingBox) {
const minSize = ACCESSIBILITY_TEST_CASES.touchTargetSize;
expect(boundingBox.width).toBeGreaterThanOrEqual(minSize);
expect(boundingBox.height).toBeGreaterThanOrEqual(minSize);
}
}
});
});
test.describe('响应式可访问性测试', () => {
test('移动端应该有可访问的菜单', async ({ page }) => {
const homePage = new HomePage(page);
await homePage.page.setViewportSize({ width: 375, height: 667 });
await homePage.goto();
const isMobileMenuAccessible = await homePage.verifyMobileMenu();
expect(isMobileMenuAccessible).toBe(true);
});
test('移动端触摸目标应该足够大', async ({ page }) => {
const homePage = new HomePage(page);
await homePage.page.setViewportSize({ width: 375, height: 667 });
await homePage.goto();
const touchTargets = homePage.page.locator('button, a');
const count = await touchTargets.count();
for (let i = 0; i < count; i++) {
const target = touchTargets.nth(i);
const boundingBox = await target.boundingBox();
if (boundingBox) {
expect(boundingBox.width).toBeGreaterThanOrEqual(44);
expect(boundingBox.height).toBeGreaterThanOrEqual(44);
}
}
});
test('桌面端应该有可访问的导航', async ({ page }) => {
const homePage = new HomePage(page);
await homePage.page.setViewportSize({ width: 1280, height: 720 });
await homePage.goto();
const isNavigationAccessible = await homePage.navigation.isVisible();
expect(isNavigationAccessible).toBe(true);
});
});
test.describe('颜色对比度测试', () => {
test('普通文本应该满足4.5:1对比度', async ({ page }) => {
const homePage = new HomePage(page);
await homePage.goto();
const textElements = homePage.page.locator('p, h1, h2, h3');
const count = await textElements.count();
for (let i = 0; i < count; i++) {
const element = textElements.nth(i);
const backgroundColor = await element.evaluate((el) =>
window.getComputedStyle(el).backgroundColor
);
const color = await element.evaluate((el) =>
window.getComputedStyle(el).color
);
if (backgroundColor !== 'rgba(0, 0, 0, 0)' && color !== 'rgba(0, 0, 0, 0)') {
expect(backgroundColor).not.toBe(color);
}
expect(hasCaptions).toBeTruthy();
}
});
}
});
test('大号文本应该满足3.0:1对比度', async ({ page }) => {
const homePage = new HomePage(page);
await homePage.goto();
const largeTextElements = homePage.page.locator('h1, h2');
const count = await largeTextElements.count();
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 element = largeTextElements.nth(i);
const backgroundColor = await element.evaluate((el) =>
window.getComputedStyle(el).backgroundColor
);
const color = await element.evaluate((el) =>
window.getComputedStyle(el).color
);
const table = tables.nth(i);
const hasCaption = await table.evaluate(el => {
return !!el.querySelector('caption') ||
el.hasAttribute('aria-label') ||
el.hasAttribute('title');
});
if (backgroundColor !== 'rgba(0, 0, 0, 0)' && color !== 'rgba(0, 0, 0, 0)') {
expect(backgroundColor).not.toBe(color);
}
expect(hasCaption).toBeTruthy();
}
});
}
});
test.describe('键盘导航测试', () => {
test('应该能够使用Tab键导航', async ({ page }) => {
const homePage = new HomePage(page);
await homePage.goto();
const focusableElements = homePage.page.locator(
'a[href], button, input, select, textarea'
);
const count = await focusableElements.count();
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++) {
await homePage.pressKey('Tab');
const activeElement = await homePage.page.evaluate(() =>
document.activeElement?.tagName
);
expect(['A', 'BUTTON', 'INPUT', 'SELECT', 'TEXTAREA']).toContain(activeElement);
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();
}
});
test('应该能够使用Enter键激活链接', async ({ page }) => {
const homePage = new HomePage(page);
await homePage.goto();
const firstLink = homePage.page.locator('a[href]').first();
await firstLink.focus();
await homePage.pressKey('Enter');
await homePage.waitForTimeout(1000);
const currentURL = homePage.getCurrentURL();
expect(currentURL).not.toBe('/');
});
test('应该能够使用Escape键关闭模态框', async ({ page }) => {
const homePage = new HomePage(page);
await homePage.goto();
await homePage.openMobileMenu();
await homePage.pressKey('Escape');
const isMenuVisible = await homePage.mobileMenu.isVisible();
expect(isMenuVisible).toBe(false);
});
}
});
test.describe('屏幕阅读器兼容性测试', () => {
test('应该有正确的ARIA角色', async ({ page }) => {
const homePage = new HomePage(page);
await homePage.goto();
const navigation = homePage.navigation;
const role = await navigation.getAttribute('role');
expect(role).toBe('navigation');
const main = homePage.page.locator('main');
const mainRole = await main.getAttribute('role');
expect(mainRole).toBe('main');
const footer = homePage.footer;
const footerRole = await footer.getAttribute('role');
expect(footerRole).toBe('contentinfo');
});
test('应该有正确的ARIA标签', async ({ page }) => {
const homePage = new HomePage(page);
await homePage.goto();
const logo = homePage.logo;
const altText = await logo.getAttribute('alt');
expect(altText).toBeTruthy();
const contactButton = homePage.page.locator('a:has-text("立即咨询")');
const ariaLabel = await contactButton.getAttribute('aria-label');
expect(ariaLabel).toBeTruthy();
});
test('应该有正确的live region', async ({ page }) => {
const homePage = new HomePage(page);
await homePage.goto();
const liveRegions = homePage.page.locator('[aria-live]');
const count = await liveRegions.count();
for (let i = 0; i < count; i++) {
const region = liveRegions.nth(i);
const polite = await region.getAttribute('aria-live');
expect(['polite', 'assertive', 'off']).toContain(polite || '');
}
});
});
test.describe('可访问性最佳实践测试', () => {
test('应该有正确的页面标题', async ({ page }) => {
const homePage = new HomePage(page);
await homePage.goto();
const title = await homePage.getTitle();
expect(title).toBeTruthy();
expect(title.length).toBeGreaterThan(10);
expect(title.length).toBeLessThan(60);
});
test('应该有正确的meta描述', async ({ page }) => {
const homePage = new HomePage(page);
await homePage.goto();
const metaDescription = await homePage.page.evaluate(() => {
const meta = document.querySelector('meta[name="description"]');
return meta ? meta.getAttribute('content') : null;
});
expect(metaDescription).toBeTruthy();
expect(metaDescription!.length).toBeGreaterThan(50);
expect(metaDescription!.length).toBeLessThan(160);
});
test('应该有正确的favicon', async ({ page }) => {
const homePage = new HomePage(page);
await homePage.goto();
const favicon = await homePage.page.evaluate(() => {
const link = document.querySelector('link[rel="icon"]');
return link ? link.getAttribute('href') : null;
});
expect(favicon).toBeTruthy();
});
test('应该有正确的跳过导航链接', async ({ page }) => {
const homePage = new HomePage(page);
await homePage.goto();
const skipLink = homePage.page.locator('a[href="#main"]');
const isVisible = await skipLink.isVisible();
expect(isVisible).toBe(true);
});
test('应该有正确的焦点管理', async ({ page }) => {
const homePage = new HomePage(page);
await homePage.goto();
const initialFocus = await homePage.page.evaluate(() =>
document.activeElement?.tagName
);
await homePage.pressKey('Tab');
const afterTabFocus = await homePage.page.evaluate(() =>
document.activeElement?.tagName
);
expect(initialFocus).not.toBe(afterTabFocus);
});
});
});
});