Files
novalon-website/e2e/mobile-performance.spec.ts
zhangxiang f3fc969bef 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
2026-08-03 18:32:29 +08:00

256 lines
8.8 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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('触摸目标尺寸 ≥ 44pxWCAG 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);
});
});