test(mobile): add mobile E2E test suite (53 tests) and fix layout issues

Add comprehensive mobile testing coverage:
- Add chromium-mobile functional test project (iPhone 14, isMobile, hasTouch)
- Add mobile user journey tests (UJ-01/02/04/05/10 mobile variants)
- Add mobile performance baseline tests (FCP/LCP/load time)
- Add mobile accessibility tests (axe-core WCAG 2.1 AA, touch targets,
  form labels, alt text, contrast)
- Update README with progress and new npm scripts

Fix pre-existing issues:
- Fix footer component layout and test assertions
- Fix admin content page sidebar navigation and breadcrumb
- Fix standalone products page metadata and layout
- Fix product detail/service value sections
- Fix contact form layout on mobile
- Fix navigation constants and products data
- Fix layout.tsx CMS config and theme handling
- Fix erp-upgrade content layout
This commit is contained in:
2026-08-03 18:32:29 +08:00
parent f4d8f0a8e9
commit f3fc969bef
20 changed files with 1095 additions and 93 deletions
+216
View File
@@ -0,0 +1,216 @@
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
/**
* 移动端可访问性专项测试
*
* 覆盖范围:
* 1. axe-core 合规扫描(WCAG 2.1 AA)— 移动视口
* 2. 触摸目标尺寸 ≥ 44pxWCAG 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);
});
});