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
256 lines
8.8 KiB
TypeScript
256 lines
8.8 KiB
TypeScript
import { test, expect } from '@playwright/test';
|
||
|
||
/**
|
||
* 移动端性能基线测试
|
||
*
|
||
* 目标:在 iPhone 14 视口(390x844)下测量关键性能指标,
|
||
* 建立移动端性能基线,与桌面端对比发现性能差距。
|
||
*
|
||
* 测试指标:
|
||
* 1. FCP (First Contentful Paint) < 2s
|
||
* 2. LCP (Largest Contentful Paint) < 2.5s
|
||
* 3. DOMContentLoaded < 3s
|
||
* 4. 完整加载时间 < 8s
|
||
* 5. 长任务计数
|
||
* 6. 资源加载分析
|
||
* 7. 关键页面加载时间对比
|
||
*
|
||
* 标记:@mobile @performance
|
||
*/
|
||
|
||
test.setTimeout(90000);
|
||
|
||
// ==================== 1. 核心性能指标 ====================
|
||
test.describe('移动端性能 - 核心加载指标', { tag: '@mobile @performance' }, () => {
|
||
test('首页 FCP < 2s', async ({ page }) => {
|
||
const fcp = await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 }).then(async () => {
|
||
await page.waitForTimeout(1500);
|
||
return page.evaluate(() => {
|
||
const entries = performance.getEntriesByType('paint');
|
||
const fcpEntry = entries.find(e => e.name === 'first-contentful-paint');
|
||
return fcpEntry ? fcpEntry.startTime : null;
|
||
});
|
||
});
|
||
|
||
if (fcp !== null) {
|
||
console.log(`Mobile FCP: ${fcp.toFixed(0)}ms`);
|
||
expect(fcp).toBeLessThan(2000);
|
||
}
|
||
});
|
||
|
||
test('首页 LCP < 2.5s', async ({ page }) => {
|
||
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||
await page.waitForTimeout(2000);
|
||
|
||
const lcp = await page.evaluate(() => {
|
||
return new Promise<number | null>((resolve) => {
|
||
const observer = new PerformanceObserver((list) => {
|
||
const entries = list.getEntries();
|
||
if (entries.length > 0) {
|
||
const lastEntry = entries[entries.length - 1];
|
||
resolve(lastEntry.startTime);
|
||
}
|
||
});
|
||
observer.observe({ type: 'largest-contentful-paint', buffered: true });
|
||
|
||
// 超时返回 null
|
||
setTimeout(() => resolve(null), 5000);
|
||
});
|
||
});
|
||
|
||
if (lcp !== null) {
|
||
console.log(`Mobile LCP: ${lcp.toFixed(0)}ms`);
|
||
expect(lcp).toBeLessThan(2500);
|
||
}
|
||
});
|
||
|
||
test('首页 DOMContentLoaded < 3s', async ({ page }) => {
|
||
const startTime = Date.now();
|
||
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||
const loadTime = Date.now() - startTime;
|
||
console.log(`Mobile DOMContentLoaded: ${loadTime}ms`);
|
||
expect(loadTime).toBeLessThan(3000);
|
||
});
|
||
|
||
test('首页完整加载时间 < 8s', async ({ page }) => {
|
||
const startTime = Date.now();
|
||
await page.goto('/', { waitUntil: 'load', timeout: 30000 });
|
||
await page.waitForTimeout(1000);
|
||
const loadTime = Date.now() - startTime;
|
||
console.log(`Mobile full load: ${loadTime}ms`);
|
||
expect(loadTime).toBeLessThan(8000);
|
||
});
|
||
|
||
test('长任务计数 ≤ 3', async ({ page }) => {
|
||
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||
await page.waitForTimeout(2000);
|
||
|
||
const longTaskCount = await page.evaluate(() => {
|
||
return new Promise<number>((resolve) => {
|
||
const observer = new PerformanceObserver((list) => {
|
||
resolve(list.getEntries().length);
|
||
});
|
||
observer.observe({ entryTypes: ['longtask'] });
|
||
setTimeout(() => resolve(0), 3000);
|
||
});
|
||
});
|
||
|
||
console.log(`Mobile long task count: ${longTaskCount}`);
|
||
expect(longTaskCount).toBeLessThanOrEqual(3);
|
||
});
|
||
});
|
||
|
||
// ==================== 2. 关键页面加载对比 ====================
|
||
test.describe('移动端性能 - 关键页面加载对比', { tag: '@mobile @performance' }, () => {
|
||
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: '案例' },
|
||
];
|
||
|
||
for (const { path, name } of pages) {
|
||
test(`${name} (${path}) 加载 < 5s`, async ({ page }) => {
|
||
const startTime = Date.now();
|
||
await page.goto(path, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||
await page.waitForTimeout(1500);
|
||
const loadTime = Date.now() - startTime;
|
||
console.log(`Mobile ${name} load: ${loadTime}ms`);
|
||
expect(loadTime).toBeLessThan(5000);
|
||
});
|
||
}
|
||
});
|
||
|
||
// ==================== 3. 资源加载分析 ====================
|
||
test.describe('移动端性能 - 资源加载分析', { tag: '@mobile @performance' }, () => {
|
||
test('图片资源大小合理', async ({ page }) => {
|
||
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||
await page.waitForTimeout(2000);
|
||
|
||
const imageAnalysis = await page.evaluate(() => {
|
||
const images = Array.from(document.querySelectorAll('img'));
|
||
let largeImages = 0;
|
||
let oversizedCount = 0;
|
||
|
||
images.forEach(img => {
|
||
if (img.naturalWidth > 1920) {
|
||
largeImages++;
|
||
}
|
||
// 检查移动端是否加载了远大于视口的图片
|
||
if (img.naturalWidth > 800 && img.naturalWidth > 0) {
|
||
oversizedCount++;
|
||
}
|
||
});
|
||
|
||
return {
|
||
totalImages: images.length,
|
||
largeImageCount: largeImages,
|
||
oversizedForMobile: oversizedCount,
|
||
};
|
||
});
|
||
|
||
console.log('Mobile image analysis:', imageAnalysis);
|
||
expect(imageAnalysis.totalImages).toBeGreaterThan(0);
|
||
});
|
||
|
||
test('JS/CSS 资源加载时间合理', async ({ page }) => {
|
||
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||
|
||
const resourceTiming = await page.evaluate(() => {
|
||
const entries = performance.getEntriesByType('resource') as PerformanceResourceTiming[];
|
||
return entries
|
||
.filter(e => e.initiatorType === 'script' || e.initiatorType === 'stylesheet')
|
||
.map(e => ({
|
||
name: e.name.split('/').pop(),
|
||
type: e.initiatorType,
|
||
duration: Math.round(e.duration),
|
||
transferSize: e.transferSize,
|
||
}));
|
||
});
|
||
|
||
console.log('Mobile resource timing:', resourceTiming.slice(0, 10));
|
||
|
||
const criticalResources = resourceTiming.filter(r =>
|
||
r.name?.includes('main') ||
|
||
r.name?.includes('chunk') ||
|
||
r.name?.includes('app')
|
||
);
|
||
|
||
for (const resource of criticalResources) {
|
||
expect(resource.duration).toBeLessThan(3000);
|
||
}
|
||
});
|
||
});
|
||
|
||
// ==================== 4. 移动端渲染性能 ====================
|
||
test.describe('移动端性能 - 渲染性能', { tag: '@mobile @performance' }, () => {
|
||
test('页面无水平滚动条', async ({ page }) => {
|
||
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||
await page.waitForTimeout(1500);
|
||
|
||
const hasHorizontalScroll = await page.evaluate(() => {
|
||
return document.documentElement.scrollWidth > document.documentElement.clientWidth;
|
||
});
|
||
|
||
expect(hasHorizontalScroll).toBeFalsy();
|
||
});
|
||
|
||
test('滚动流畅无卡顿', async ({ page }) => {
|
||
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||
await page.waitForTimeout(1500);
|
||
|
||
// 模拟快速滚动
|
||
for (let i = 0; i < 5; i++) {
|
||
await page.evaluate(() => window.scrollBy(0, window.innerHeight));
|
||
await page.waitForTimeout(200);
|
||
}
|
||
|
||
// 滚动后页面不应崩溃
|
||
const bodyVisible = await page.locator('body').isVisible();
|
||
expect(bodyVisible).toBeTruthy();
|
||
});
|
||
|
||
test('触摸目标尺寸 ≥ 44px(WCAG 2.1 AA)', async ({ page }) => {
|
||
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||
await page.waitForTimeout(1500);
|
||
|
||
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('Small touch targets found:', smallTargets.slice(0, 10));
|
||
}
|
||
// 允许少量例外(如小图标按钮),但不应超过 5 个
|
||
expect(smallTargets.length).toBeLessThanOrEqual(5);
|
||
});
|
||
}); |