feat: merge mobile testing implementation
- Add comprehensive mobile testing framework - Implement gesture simulator with swipe, pinch, long press, double tap, and drag - Add network simulator for various network conditions - Implement mobile performance monitor with Core Web Vitals - Create mobile test suites for performance, compatibility, gesture, PWA, and network - Add mobile test reporter with HTML report generation - Optimize parallel test execution - Add mobile testing documentation~
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
# 移动端测试指南
|
||||
|
||||
## 概述
|
||||
|
||||
本文档介绍如何使用移动端测试框架进行测试。
|
||||
|
||||
## 手势模拟器
|
||||
|
||||
### 基本用法
|
||||
|
||||
```typescript
|
||||
import { GestureSimulator } from '../utils/GestureSimulator';
|
||||
|
||||
const simulator = new GestureSimulator(page);
|
||||
|
||||
// 单指滑动
|
||||
await simulator.swipe({
|
||||
startX: 200,
|
||||
startY: 600,
|
||||
endX: 200,
|
||||
endY: 200,
|
||||
duration: 500,
|
||||
});
|
||||
|
||||
// 长按
|
||||
await simulator.longPress(element, 1000);
|
||||
|
||||
// 双击
|
||||
await simulator.doubleTap(element);
|
||||
|
||||
// 拖拽
|
||||
await simulator.drag({
|
||||
source: firstElement,
|
||||
target: secondElement,
|
||||
duration: 500,
|
||||
});
|
||||
|
||||
// 捏合
|
||||
await simulator.pinch({
|
||||
centerX: 200,
|
||||
centerY: 300,
|
||||
startDistance: 100,
|
||||
endDistance: 50,
|
||||
duration: 300,
|
||||
});
|
||||
```
|
||||
|
||||
## 网络模拟器
|
||||
|
||||
### 基本用法
|
||||
|
||||
```typescript
|
||||
import { NetworkSimulator } from '../utils/NetworkSimulator';
|
||||
import { networkConfigs } from '../config/network-configs';
|
||||
|
||||
const simulator = new NetworkSimulator(context);
|
||||
|
||||
// 设置网络条件
|
||||
await simulator.setNetworkCondition(networkConfigs['3g-fast']);
|
||||
|
||||
// 切换到离线模式
|
||||
await simulator.goOffline();
|
||||
|
||||
// 恢复在线
|
||||
await simulator.goOnline();
|
||||
|
||||
// 模拟网络切换
|
||||
await simulator.simulateNetworkSwitch(networkConfigs['wifi-fast'], networkConfigs['3g-fast']);
|
||||
|
||||
// 重置网络条件
|
||||
await simulator.resetNetworkCondition();
|
||||
|
||||
// 获取网络请求
|
||||
const requests = simulator.getRequests();
|
||||
|
||||
// 获取失败的请求
|
||||
const failedRequests = simulator.getFailedRequests();
|
||||
|
||||
// 获取慢速请求
|
||||
const slowRequests = simulator.getSlowRequests(1000);
|
||||
```
|
||||
|
||||
## 性能监控器
|
||||
|
||||
### 基本用法
|
||||
|
||||
```typescript
|
||||
import { MobilePerformanceMonitor } from '../utils/MobilePerformanceMonitor';
|
||||
|
||||
const monitor = new MobilePerformanceMonitor(page);
|
||||
|
||||
// 获取 Core Web Vitals
|
||||
const vitals = await monitor.getCoreWebVitals();
|
||||
|
||||
console.log('FCP:', vitals.FCP);
|
||||
console.log('LCP:', vitals.LCP);
|
||||
console.log('CLS:', vitals.CLS);
|
||||
```
|
||||
|
||||
## 运行测试
|
||||
|
||||
### 运行所有移动端测试
|
||||
|
||||
```bash
|
||||
npm run test -- --grep "@mobile"
|
||||
```
|
||||
|
||||
### 运行特定设备测试
|
||||
|
||||
```bash
|
||||
npm run test -- --project=mobile-iphone-13-pro
|
||||
```
|
||||
|
||||
### 运行性能测试
|
||||
|
||||
```bash
|
||||
npm run test -- --grep "@performance"
|
||||
```
|
||||
|
||||
### 运行兼容性测试
|
||||
|
||||
```bash
|
||||
npm run test -- --grep "@compatibility"
|
||||
```
|
||||
|
||||
### 运行手势交互测试
|
||||
|
||||
```bash
|
||||
npm run test -- --grep "@gesture"
|
||||
```
|
||||
|
||||
### 运行 PWA 测试
|
||||
|
||||
```bash
|
||||
npm run test -- --grep "@pwa"
|
||||
```
|
||||
|
||||
### 运行网络环境测试
|
||||
|
||||
```bash
|
||||
npm run test -- --grep "@network"
|
||||
```
|
||||
|
||||
## 查看报告
|
||||
|
||||
测试完成后,可以在以下位置查看报告:
|
||||
|
||||
- HTML 报告: `e2e/test-results/index.html`
|
||||
- Allure 报告: `e2e/allure-results/`
|
||||
|
||||
## 测试覆盖范围
|
||||
|
||||
### 性能测试
|
||||
- 首屏加载性能
|
||||
- 交互响应性能
|
||||
- 页面资源加载性能
|
||||
- JavaScript 执行性能
|
||||
|
||||
### 兼容性测试
|
||||
- 页面布局适配
|
||||
- 导航菜单可访问性
|
||||
- 表单元素可交互性
|
||||
- 图片资源加载
|
||||
- 横屏布局适配
|
||||
- 触摸事件支持
|
||||
|
||||
### 手势交互测试
|
||||
- 页面滚动(向上/向下滑动)
|
||||
- 长按手势
|
||||
- 双击手势
|
||||
- 拖拽手势
|
||||
- 捏合手势
|
||||
- 组合手势
|
||||
- 横向滑动
|
||||
|
||||
### PWA 功能测试
|
||||
- Service Worker 注册
|
||||
- 离线功能
|
||||
- 离线缓存功能
|
||||
- PWA manifest 加载
|
||||
- PWA 可安装提示
|
||||
- PWA 响应式设计
|
||||
- PWA 离线页面显示
|
||||
|
||||
### 网络环境测试
|
||||
- WiFi 快速网络测试
|
||||
- 4G LTE 网络测试
|
||||
- 3G 快速网络测试
|
||||
- 2G 慢速网络测试
|
||||
- 离线模式测试
|
||||
- 网络切换测试
|
||||
- 网络请求监控
|
||||
- 失败请求检测
|
||||
- 慢速请求检测
|
||||
- 网络条件重置测试
|
||||
|
||||
## 测试设备
|
||||
|
||||
### iPhone 系列
|
||||
- iPhone 13 Pro (390x844)
|
||||
- iPhone 14 Pro (393x852)
|
||||
- iPhone 15 Pro (393x852)
|
||||
|
||||
### Android 系列
|
||||
- Google Pixel 7 (412x915)
|
||||
- Samsung Galaxy S23 (360x780)
|
||||
|
||||
### iPad 系列
|
||||
- iPad Air (820x1180)
|
||||
- iPad Pro 12.9" (1024x1366)
|
||||
|
||||
## 网络条件
|
||||
|
||||
- WiFi Fast (30 Mbps / 15 Mbps / 2ms)
|
||||
- 4G LTE (4 Mbps / 3 Mbps / 20ms)
|
||||
- 3G Fast (1.6 Mbps / 750 Kbps / 100ms)
|
||||
- 2G Slow (250 Kbps / 50 Kbps / 2000ms)
|
||||
- Offline (离线模式)
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **测试隔离**: 每个测试用例应该独立运行,不依赖其他测试的状态
|
||||
2. **数据清理**: 测试完成后应该清理测试数据,避免影响其他测试
|
||||
3. **超时设置**: 为移动端测试设置合理的超时时间
|
||||
4. **网络模拟**: 在不同网络条件下测试应用性能
|
||||
5. **设备覆盖**: 在多种移动设备上测试应用兼容性
|
||||
6. **性能监控**: 使用性能监控器跟踪关键性能指标
|
||||
7. **错误处理**: 测试应该包含错误场景和边界情况
|
||||
8. **并行执行**: 利用并行测试提高测试效率
|
||||
|
||||
## 故障排查
|
||||
|
||||
### 测试失败
|
||||
1. 检查网络连接是否正常
|
||||
2. 确认测试服务器是否运行
|
||||
3. 查看测试日志获取详细错误信息
|
||||
4. 检查元素选择器是否正确
|
||||
5. 验证测试数据是否有效
|
||||
|
||||
### 性能问题
|
||||
1. 使用性能监控器分析性能指标
|
||||
2. 检查资源加载时间
|
||||
3. 优化图片和静态资源
|
||||
4. 减少不必要的网络请求
|
||||
5. 使用缓存策略提高性能
|
||||
|
||||
### 网络问题
|
||||
1. 检查网络模拟器配置
|
||||
2. 验证网络条件设置
|
||||
3. 测试不同网络条件下的表现
|
||||
4. 实现离线缓存策略
|
||||
5. 添加网络错误处理
|
||||
|
||||
## 贡献指南
|
||||
|
||||
如需添加新的测试用例或改进现有测试,请遵循以下步骤:
|
||||
|
||||
1. 创建新的测试文件或修改现有测试文件
|
||||
2. 确保测试用例命名清晰且描述性强
|
||||
3. 添加必要的测试标签(如 @mobile, @performance 等)
|
||||
4. 运行测试确保通过
|
||||
5. 提交代码并编写清晰的提交信息
|
||||
6. 更新本文档(如需要)
|
||||
Generated
+2516
File diff suppressed because it is too large
Load Diff
@@ -27,7 +27,9 @@
|
||||
"@types/node": "^20.11.0",
|
||||
"allure-commandline": "^2.37.0",
|
||||
"allure-playwright": "^3.5.0",
|
||||
"chrome-launcher": "^1.2.1",
|
||||
"glob": "^13.0.6",
|
||||
"lighthouse": "^13.0.3",
|
||||
"typescript": "^5.3.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
import { getEnvironment } from './src/config/environments';
|
||||
import { getMobileDevices } from './src/utils/devices';
|
||||
|
||||
const env = getEnvironment();
|
||||
|
||||
@@ -8,7 +9,7 @@ export default defineConfig({
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: env.retries,
|
||||
workers: process.env.CI ? 4 : undefined,
|
||||
workers: process.env.CI ? 4 : '50%',
|
||||
reporter: [
|
||||
['html', { open: 'never' }],
|
||||
['json', { outputFile: 'test-results/results.json' }],
|
||||
@@ -21,9 +22,9 @@ export default defineConfig({
|
||||
suiteTitle: false,
|
||||
}],
|
||||
],
|
||||
timeout: env.timeout,
|
||||
timeout: 60000,
|
||||
expect: {
|
||||
timeout: 30000
|
||||
timeout: 30000,
|
||||
},
|
||||
use: {
|
||||
baseURL: env.baseURL,
|
||||
@@ -59,6 +60,35 @@ export default defineConfig({
|
||||
name: 'Mobile Safari',
|
||||
use: { ...devices['iPhone 12'] },
|
||||
},
|
||||
...getMobileDevices().map(device => ({
|
||||
name: `mobile-${device.name.replace(/\s+/g, '-').toLowerCase()}`,
|
||||
use: {
|
||||
...devices['Mobile Chrome'],
|
||||
viewport: device.viewport,
|
||||
userAgent: device.userAgent,
|
||||
deviceScaleFactor: device.deviceScaleFactor,
|
||||
isMobile: true,
|
||||
},
|
||||
})),
|
||||
{
|
||||
name: 'performance-mobile',
|
||||
use: {
|
||||
...devices['Mobile Chrome'],
|
||||
viewport: { width: 375, height: 667 },
|
||||
isMobile: true,
|
||||
},
|
||||
testMatch: /.*\.perf\.spec\.ts/,
|
||||
},
|
||||
{
|
||||
name: 'pwa-mobile',
|
||||
use: {
|
||||
...devices['Mobile Chrome'],
|
||||
viewport: { width: 375, height: 667 },
|
||||
isMobile: true,
|
||||
serviceWorkers: 'allow',
|
||||
},
|
||||
testMatch: /.*\.pwa\.spec\.ts/,
|
||||
},
|
||||
],
|
||||
webServer: env.name === 'development' ? {
|
||||
command: 'cd .. && npm run dev',
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
export interface NetworkConfig {
|
||||
name: string;
|
||||
offline: boolean;
|
||||
downloadThroughput?: number;
|
||||
uploadThroughput?: number;
|
||||
latency?: number;
|
||||
}
|
||||
|
||||
export const networkConfigs: Record<string, NetworkConfig> = {
|
||||
'2g-slow': {
|
||||
name: '2G Slow',
|
||||
offline: false,
|
||||
downloadThroughput: 250 * 1024,
|
||||
uploadThroughput: 50 * 1024,
|
||||
latency: 2000,
|
||||
},
|
||||
'3g-fast': {
|
||||
name: '3G Fast',
|
||||
offline: false,
|
||||
downloadThroughput: 1.6 * 1024 * 1024,
|
||||
uploadThroughput: 750 * 1024,
|
||||
latency: 100,
|
||||
},
|
||||
'4g-lte': {
|
||||
name: '4G LTE',
|
||||
offline: false,
|
||||
downloadThroughput: 4 * 1024 * 1024,
|
||||
uploadThroughput: 3 * 1024 * 1024,
|
||||
latency: 20,
|
||||
},
|
||||
'wifi-fast': {
|
||||
name: 'WiFi Fast',
|
||||
offline: false,
|
||||
downloadThroughput: 30 * 1024 * 1024,
|
||||
uploadThroughput: 15 * 1024 * 1024,
|
||||
latency: 2,
|
||||
},
|
||||
'offline': {
|
||||
name: 'Offline',
|
||||
offline: true,
|
||||
},
|
||||
};
|
||||
|
||||
export function getNetworkConfig(key: string): NetworkConfig {
|
||||
return networkConfigs[key] || networkConfigs['wifi-fast'];
|
||||
}
|
||||
|
||||
export function getAllNetworkConfigs(): NetworkConfig[] {
|
||||
return Object.values(networkConfigs);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { getMobileDevices } from '../../../utils/devices';
|
||||
|
||||
test.describe('移动端兼容性测试 @mobile @compatibility', () => {
|
||||
const devices = getMobileDevices();
|
||||
|
||||
for (const device of devices) {
|
||||
test(`${device.name} - 页面布局正常`, async ({ page }) => {
|
||||
await page.setViewportSize(device.viewport);
|
||||
await page.goto('/');
|
||||
|
||||
await expect(page.locator('header')).toBeVisible();
|
||||
await expect(page.locator('main')).toBeVisible();
|
||||
await expect(page.locator('footer')).toBeVisible();
|
||||
});
|
||||
}
|
||||
|
||||
for (const device of devices) {
|
||||
test(`${device.name} - 导航菜单可访问`, async ({ page }) => {
|
||||
await page.setViewportSize(device.viewport);
|
||||
await page.goto('/');
|
||||
|
||||
const navMenu = page.locator('nav').first();
|
||||
await expect(navMenu).toBeVisible();
|
||||
|
||||
if (device.viewport.width < 768) {
|
||||
const mobileMenuToggle = page.locator('[aria-label="mobile-menu"]');
|
||||
if (await mobileMenuToggle.isVisible()) {
|
||||
await mobileMenuToggle.click();
|
||||
await expect(page.locator('.mobile-menu')).toBeVisible();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const device of devices) {
|
||||
test(`${device.name} - 表单元素可交互`, async ({ page }) => {
|
||||
await page.setViewportSize(device.viewport);
|
||||
await page.goto('/contact');
|
||||
|
||||
const nameInput = page.locator('input[name="name"]');
|
||||
const emailInput = page.locator('input[name="email"]');
|
||||
const submitButton = page.locator('button[type="submit"]');
|
||||
|
||||
await expect(nameInput).toBeVisible();
|
||||
await expect(emailInput).toBeVisible();
|
||||
await expect(submitButton).toBeVisible();
|
||||
|
||||
await nameInput.fill('Test User');
|
||||
await emailInput.fill('test@example.com');
|
||||
|
||||
expect(await nameInput.inputValue()).toBe('Test User');
|
||||
expect(await emailInput.inputValue()).toBe('test@example.com');
|
||||
});
|
||||
}
|
||||
|
||||
for (const device of devices) {
|
||||
test(`${device.name} - 图片资源加载正常`, async ({ page }) => {
|
||||
await page.setViewportSize(device.viewport);
|
||||
await page.goto('/');
|
||||
|
||||
const images = page.locator('img');
|
||||
const imageCount = await images.count();
|
||||
|
||||
for (let i = 0; i < imageCount; i++) {
|
||||
const image = images.nth(i);
|
||||
await expect(image).toHaveJSProperty('naturalWidth', { timeout: 5000 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('移动端 - 横屏布局适配', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 844, height: 390 });
|
||||
await page.goto('/');
|
||||
|
||||
await expect(page.locator('header')).toBeVisible();
|
||||
await expect(page.locator('main')).toBeVisible();
|
||||
|
||||
const headerHeight = await page.locator('header').evaluate(el => (el as HTMLElement).offsetHeight);
|
||||
expect(headerHeight).toBeLessThan(100);
|
||||
});
|
||||
|
||||
test('移动端 - 触摸事件支持', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
await page.goto('/');
|
||||
|
||||
const button = page.locator('button').first();
|
||||
await expect(button).toBeVisible();
|
||||
|
||||
await button.tap();
|
||||
|
||||
await expect(button).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { GestureSimulator } from '../../../utils/GestureSimulator';
|
||||
|
||||
test.describe('移动端手势交互测试 @mobile @gesture', () => {
|
||||
test('滑动 - 页面滚动', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
await page.goto('/');
|
||||
|
||||
const simulator = new GestureSimulator(page);
|
||||
|
||||
const initialScrollY = await page.evaluate(() => window.scrollY);
|
||||
expect(initialScrollY).toBe(0);
|
||||
|
||||
await simulator.slowSwipeUp();
|
||||
|
||||
const afterScrollY = await page.evaluate(() => window.scrollY);
|
||||
expect(afterScrollY).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('滑动 - 快速向下滑动', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto('/');
|
||||
|
||||
await page.evaluate(() => {
|
||||
window.scrollTo(0, 1000);
|
||||
});
|
||||
|
||||
const simulator = new GestureSimulator(page);
|
||||
const initialScrollY = await page.evaluate(() => window.scrollY);
|
||||
|
||||
await simulator.quickSwipeDown();
|
||||
|
||||
const afterScrollY = await page.evaluate(() => window.scrollY);
|
||||
expect(afterScrollY).toBeLessThan(initialScrollY);
|
||||
});
|
||||
|
||||
test('长按 - 元素上下文菜单', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 414, height: 896 });
|
||||
await page.goto('/');
|
||||
|
||||
const simulator = new GestureSimulator(page);
|
||||
const card = page.locator('.card').first();
|
||||
|
||||
await expect(card).toBeVisible();
|
||||
await simulator.longPress(card, 1000);
|
||||
|
||||
await expect(card).toBeVisible();
|
||||
});
|
||||
|
||||
test('双击 - 图片放大', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
await page.goto('/products');
|
||||
|
||||
const simulator = new GestureSimulator(page);
|
||||
const image = page.locator('.product-image').first();
|
||||
|
||||
await expect(image).toBeVisible();
|
||||
await simulator.doubleTap(image);
|
||||
|
||||
await expect(image).toBeVisible();
|
||||
});
|
||||
|
||||
test('拖拽 - 元素移动', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto('/products');
|
||||
|
||||
const simulator = new GestureSimulator(page);
|
||||
const firstCard = page.locator('.card').first();
|
||||
const secondCard = page.locator('.card').nth(1);
|
||||
|
||||
const firstCardInitialPosition = await firstCard.boundingBox();
|
||||
|
||||
await simulator.drag({
|
||||
source: firstCard,
|
||||
target: secondCard,
|
||||
duration: 500,
|
||||
});
|
||||
|
||||
const firstCardFinalPosition = await firstCard.boundingBox();
|
||||
|
||||
if (firstCardInitialPosition && firstCardFinalPosition) {
|
||||
expect(firstCardFinalPosition.y).toBeGreaterThan(firstCardInitialPosition.y);
|
||||
}
|
||||
});
|
||||
|
||||
test('捏合 - 图片缩放', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 414, height: 896 });
|
||||
await page.goto('/products');
|
||||
|
||||
const simulator = new GestureSimulator(page);
|
||||
const image = page.locator('.product-image').first();
|
||||
|
||||
await image.click();
|
||||
|
||||
await simulator.pinch({
|
||||
centerX: 200,
|
||||
centerY: 300,
|
||||
startDistance: 100,
|
||||
endDistance: 50,
|
||||
duration: 300,
|
||||
});
|
||||
|
||||
const transform = await image.evaluate((el) => {
|
||||
const style = window.getComputedStyle(el);
|
||||
return style.transform;
|
||||
});
|
||||
|
||||
expect(transform).toBeTruthy();
|
||||
});
|
||||
|
||||
test('组合手势 - 滑动后点击', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
await page.goto('/');
|
||||
|
||||
const simulator = new GestureSimulator(page);
|
||||
|
||||
await simulator.slowSwipeUp();
|
||||
|
||||
const button = page.locator('button').first();
|
||||
await expect(button).toBeVisible();
|
||||
|
||||
await button.tap();
|
||||
});
|
||||
|
||||
test('手势 - 横向滑动', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto('/');
|
||||
|
||||
const simulator = new GestureSimulator(page);
|
||||
|
||||
const initialScrollX = await page.evaluate(() => window.scrollX);
|
||||
|
||||
await simulator.swipe({
|
||||
startX: 300,
|
||||
startY: 400,
|
||||
endX: 100,
|
||||
endY: 400,
|
||||
duration: 500,
|
||||
});
|
||||
|
||||
const afterScrollX = await page.evaluate(() => window.scrollX);
|
||||
expect(afterScrollX).toBeGreaterThan(initialScrollX);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { NetworkSimulator } from '../../../utils/NetworkSimulator';
|
||||
import { networkConfigs } from '../../../config/network-configs';
|
||||
|
||||
test.describe('移动端网络环境测试 @mobile @network', () => {
|
||||
test('WiFi 快速网络 - 页面加载', async ({ page, context }) => {
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
|
||||
const simulator = new NetworkSimulator(context);
|
||||
await simulator.setNetworkCondition(networkConfigs['wifi-fast']);
|
||||
|
||||
await page.goto('/');
|
||||
await expect(page.locator('header')).toBeVisible();
|
||||
await expect(page.locator('main')).toBeVisible();
|
||||
});
|
||||
|
||||
test('4G LTE 网络 - 页面加载', async ({ page, context }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
|
||||
const simulator = new NetworkSimulator(context);
|
||||
await simulator.setNetworkCondition(networkConfigs['4g-lte']);
|
||||
|
||||
await page.goto('/');
|
||||
await expect(page.locator('header')).toBeVisible();
|
||||
await expect(page.locator('main')).toBeVisible();
|
||||
});
|
||||
|
||||
test('3G 快速网络 - 页面加载', async ({ page, context }) => {
|
||||
await page.setViewportSize({ width: 414, height: 896 });
|
||||
|
||||
const simulator = new NetworkSimulator(context);
|
||||
await simulator.setNetworkCondition(networkConfigs['3g-fast']);
|
||||
|
||||
await page.goto('/');
|
||||
await expect(page.locator('header')).toBeVisible();
|
||||
await expect(page.locator('main')).toBeVisible();
|
||||
});
|
||||
|
||||
test('2G 慢速网络 - 页面加载', async ({ page, context }) => {
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
|
||||
const simulator = new NetworkSimulator(context);
|
||||
await simulator.setNetworkCondition(networkConfigs['2g-slow']);
|
||||
|
||||
await page.goto('/');
|
||||
await expect(page.locator('header')).toBeVisible();
|
||||
await expect(page.locator('main')).toBeVisible();
|
||||
});
|
||||
|
||||
test('离线模式 - 页面显示', async ({ page, context }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
|
||||
const simulator = new NetworkSimulator(context);
|
||||
await simulator.goOffline();
|
||||
|
||||
await page.goto('/');
|
||||
await expect(page.locator('header')).toBeVisible();
|
||||
await expect(page.locator('main')).toBeVisible();
|
||||
|
||||
await simulator.goOnline();
|
||||
});
|
||||
|
||||
test('网络切换 - WiFi 到 3G', async ({ page, context }) => {
|
||||
await page.setViewportSize({ width: 414, height: 896 });
|
||||
|
||||
const simulator = new NetworkSimulator(context);
|
||||
await simulator.setNetworkCondition(networkConfigs['wifi-fast']);
|
||||
|
||||
await page.goto('/');
|
||||
await expect(page.locator('header')).toBeVisible();
|
||||
|
||||
await simulator.simulateNetworkSwitch(networkConfigs['wifi-fast'], networkConfigs['3g-fast']);
|
||||
|
||||
await page.reload();
|
||||
await expect(page.locator('header')).toBeVisible();
|
||||
});
|
||||
|
||||
test('网络切换 - 3G 到离线', async ({ page, context }) => {
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
|
||||
const simulator = new NetworkSimulator(context);
|
||||
await simulator.setNetworkCondition(networkConfigs['3g-fast']);
|
||||
|
||||
await page.goto('/');
|
||||
await expect(page.locator('header')).toBeVisible();
|
||||
|
||||
await simulator.simulateNetworkSwitch(networkConfigs['3g-fast'], networkConfigs['offline']);
|
||||
|
||||
await page.reload();
|
||||
await expect(page.locator('header')).toBeVisible();
|
||||
|
||||
await simulator.goOnline();
|
||||
});
|
||||
|
||||
test('网络请求监控', async ({ page, context }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
|
||||
const simulator = new NetworkSimulator(context);
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
const requests = simulator.getRequests();
|
||||
expect(requests.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('失败请求检测', async ({ page, context }) => {
|
||||
await page.setViewportSize({ width: 414, height: 896 });
|
||||
|
||||
const simulator = new NetworkSimulator(context);
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
const failedRequests = simulator.getFailedRequests();
|
||||
expect(Array.isArray(failedRequests)).toBe(true);
|
||||
});
|
||||
|
||||
test('慢速请求检测', async ({ page, context }) => {
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
|
||||
const simulator = new NetworkSimulator(context);
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
const slowRequests = simulator.getSlowRequests(1000);
|
||||
expect(Array.isArray(slowRequests)).toBe(true);
|
||||
});
|
||||
|
||||
test('网络条件重置', async ({ page, context }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
|
||||
const simulator = new NetworkSimulator(context);
|
||||
await simulator.setNetworkCondition(networkConfigs['3g-fast']);
|
||||
|
||||
await page.goto('/');
|
||||
await expect(page.locator('header')).toBeVisible();
|
||||
|
||||
await simulator.resetNetworkCondition();
|
||||
|
||||
await page.reload();
|
||||
await expect(page.locator('header')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { getMobileDevices } from '../../../utils/devices';
|
||||
import { networkConfigs } from '../../../config/network-configs';
|
||||
import { MobilePerformanceMonitor } from '../../../utils/MobilePerformanceMonitor';
|
||||
import { generatePerformanceBaseline } from '../../../utils/MobileTestDataGenerator';
|
||||
|
||||
test.describe('移动端性能测试 @mobile @performance', () => {
|
||||
const devices = getMobileDevices().slice(0, 3);
|
||||
const networkTypes = ['wifi-fast', '4g-lte', '3g-fast', '2g-slow'] as const;
|
||||
|
||||
for (const device of devices) {
|
||||
for (const network of networkTypes) {
|
||||
test(`${device.name} - ${networkConfigs[network].name} - 首屏加载性能`, async ({ page }) => {
|
||||
await page.setViewportSize(device.viewport);
|
||||
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const monitor = new MobilePerformanceMonitor(page);
|
||||
const vitals = await monitor.getCoreWebVitals();
|
||||
|
||||
const baseline = generatePerformanceBaseline(device.name, network);
|
||||
|
||||
expect(vitals.LCP).toBeLessThan(baseline.LCP);
|
||||
expect(vitals.FCP).toBeLessThan(baseline.FCP);
|
||||
expect(vitals.CLS).toBeLessThan(baseline.CLS);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test('移动端 - 交互响应性能', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
await page.goto('/');
|
||||
|
||||
const monitor = new MobilePerformanceMonitor(page);
|
||||
const vitals = await monitor.getCoreWebVitals();
|
||||
|
||||
expect(vitals.FCP).toBeLessThan(2000);
|
||||
expect(vitals.LCP).toBeLessThan(3000);
|
||||
expect(vitals.CLS).toBeLessThan(0.25);
|
||||
});
|
||||
|
||||
test('移动端 - 页面资源加载性能', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto('/');
|
||||
|
||||
const resources = await page.evaluate(() => {
|
||||
return performance.getEntriesByType('resource').map((r: any) => ({
|
||||
name: r.name,
|
||||
duration: r.duration,
|
||||
size: r.transferSize,
|
||||
}));
|
||||
});
|
||||
|
||||
const largeResources = resources.filter(r => r.size > 100000);
|
||||
expect(largeResources.length).toBeLessThan(5);
|
||||
});
|
||||
|
||||
test('移动端 - JavaScript 执行性能', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 414, height: 896 });
|
||||
await page.goto('/');
|
||||
|
||||
const jsMetrics = await page.evaluate(() => {
|
||||
return {
|
||||
totalTasks: performance.getEntriesByType('measure').length,
|
||||
longTasks: performance.getEntriesByType('measure').filter((m: any) => m.duration > 50).length,
|
||||
};
|
||||
});
|
||||
|
||||
expect(jsMetrics.longTasks).toBeLessThan(10);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('移动端 PWA 功能测试 @mobile @pwa', () => {
|
||||
test('Service Worker 注册成功', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
await page.goto('/');
|
||||
|
||||
const swRegistration = await page.evaluate(() => {
|
||||
return new Promise((resolve) => {
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.getRegistration().then((registration) => {
|
||||
resolve(registration !== null);
|
||||
}).catch(() => {
|
||||
resolve(false);
|
||||
});
|
||||
} else {
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
expect(swRegistration).toBe(true);
|
||||
});
|
||||
|
||||
test('离线功能正常', async ({ page, context }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await context.setOffline(true);
|
||||
|
||||
await page.reload();
|
||||
await expect(page.locator('header')).toBeVisible();
|
||||
await expect(page.locator('main')).toBeVisible();
|
||||
|
||||
await context.setOffline(false);
|
||||
});
|
||||
|
||||
test('离线缓存功能正常', async ({ page, context }) => {
|
||||
await page.setViewportSize({ width: 414, height: 896 });
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await context.setOffline(true);
|
||||
|
||||
await page.reload();
|
||||
await expect(page.locator('header')).toBeVisible();
|
||||
await expect(page.locator('main')).toBeVisible();
|
||||
|
||||
await context.setOffline(false);
|
||||
});
|
||||
|
||||
test('PWA manifest 加载正常', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
await page.goto('/');
|
||||
|
||||
const manifestLink = await page.evaluate(() => {
|
||||
const link = document.querySelector('link[rel="manifest"]');
|
||||
return link ? link.getAttribute('href') : null;
|
||||
});
|
||||
|
||||
expect(manifestLink).toBeTruthy();
|
||||
});
|
||||
|
||||
test('PWA 可安装提示', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto('/');
|
||||
|
||||
const beforeInstallPrompt = await page.evaluate(() => {
|
||||
return new Promise((resolve) => {
|
||||
let promptFired = false;
|
||||
|
||||
window.addEventListener('beforeinstallprompt', () => {
|
||||
promptFired = true;
|
||||
resolve(true);
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
resolve(promptFired);
|
||||
}, 2000);
|
||||
});
|
||||
});
|
||||
|
||||
expect(beforeInstallPrompt).toBeDefined();
|
||||
});
|
||||
|
||||
test('PWA 响应式设计', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
await page.goto('/');
|
||||
|
||||
await expect(page.locator('header')).toBeVisible();
|
||||
await expect(page.locator('main')).toBeVisible();
|
||||
await expect(page.locator('footer')).toBeVisible();
|
||||
|
||||
await page.setViewportSize({ width: 768, height: 1024 });
|
||||
|
||||
await expect(page.locator('header')).toBeVisible();
|
||||
await expect(page.locator('main')).toBeVisible();
|
||||
await expect(page.locator('footer')).toBeVisible();
|
||||
});
|
||||
|
||||
test('PWA 离线页面显示', async ({ page, context }) => {
|
||||
await page.setViewportSize({ width: 414, height: 896 });
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await context.setOffline(true);
|
||||
|
||||
await page.goto('/offline');
|
||||
await expect(page.locator('h1')).toContainText('离线');
|
||||
|
||||
await context.setOffline(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { GestureSimulator } from '../../utils/GestureSimulator';
|
||||
|
||||
test.describe('GestureSimulator - Swipe', () => {
|
||||
test('should perform swipe up', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
const simulator = new GestureSimulator(page);
|
||||
|
||||
const initialScrollY = await page.evaluate(() => window.scrollY);
|
||||
expect(initialScrollY).toBe(0);
|
||||
|
||||
await simulator.slowSwipeUp();
|
||||
|
||||
const afterScrollY = await page.evaluate(() => window.scrollY);
|
||||
expect(afterScrollY).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('should perform swipe down', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
const simulator = new GestureSimulator(page);
|
||||
|
||||
await page.evaluate(() => {
|
||||
window.scrollTo(0, 1000);
|
||||
});
|
||||
|
||||
const initialScrollY = await page.evaluate(() => window.scrollY);
|
||||
expect(initialScrollY).toBeGreaterThan(0);
|
||||
|
||||
await simulator.quickSwipeDown();
|
||||
|
||||
const afterScrollY = await page.evaluate(() => window.scrollY);
|
||||
expect(afterScrollY).toBeLessThan(initialScrollY);
|
||||
});
|
||||
|
||||
test('should perform pinch zoom', async ({ page }) => {
|
||||
await page.goto('/products');
|
||||
const simulator = new GestureSimulator(page);
|
||||
|
||||
const image = page.locator('.product-image').first();
|
||||
await image.click();
|
||||
|
||||
await simulator.pinch({
|
||||
centerX: 200,
|
||||
centerY: 300,
|
||||
startDistance: 100,
|
||||
endDistance: 50,
|
||||
duration: 300,
|
||||
});
|
||||
|
||||
const transform = await image.evaluate((el) => {
|
||||
const style = window.getComputedStyle(el);
|
||||
return style.transform;
|
||||
});
|
||||
|
||||
expect(transform).toBeTruthy();
|
||||
});
|
||||
|
||||
test('should perform long press', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
const simulator = new GestureSimulator(page);
|
||||
|
||||
const card = page.locator('.card').first();
|
||||
await simulator.longPress(card, 1000);
|
||||
|
||||
await expect(card).toBeVisible();
|
||||
});
|
||||
|
||||
test('should perform double tap', async ({ page }) => {
|
||||
await page.goto('/products');
|
||||
const simulator = new GestureSimulator(page);
|
||||
|
||||
const image = page.locator('.product-image').first();
|
||||
await simulator.doubleTap(image);
|
||||
|
||||
await expect(image).toBeVisible();
|
||||
});
|
||||
|
||||
test('should perform drag', async ({ page }) => {
|
||||
await page.goto('/products');
|
||||
const simulator = new GestureSimulator(page);
|
||||
|
||||
const firstCard = page.locator('.card').first();
|
||||
const secondCard = page.locator('.card').nth(1);
|
||||
|
||||
const firstCardInitialPosition = await firstCard.boundingBox();
|
||||
|
||||
await simulator.drag({
|
||||
source: firstCard,
|
||||
target: secondCard,
|
||||
duration: 500,
|
||||
});
|
||||
|
||||
const firstCardFinalPosition = await firstCard.boundingBox();
|
||||
|
||||
if (firstCardInitialPosition && firstCardFinalPosition) {
|
||||
expect(firstCardFinalPosition.y).toBeGreaterThan(firstCardInitialPosition.y);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { MobilePerformanceMonitor } from '../../utils/MobilePerformanceMonitor';
|
||||
|
||||
test.describe('MobilePerformanceMonitor', () => {
|
||||
test('should get Core Web Vitals', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
const monitor = new MobilePerformanceMonitor(page);
|
||||
|
||||
const vitals = await monitor.getCoreWebVitals();
|
||||
|
||||
expect(vitals.FCP).toBeGreaterThan(0);
|
||||
expect(vitals.LCP).toBeGreaterThan(0);
|
||||
expect(vitals.CLS).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
test('should get Core Web Vitals on product page', async ({ page }) => {
|
||||
await page.goto('/products');
|
||||
const monitor = new MobilePerformanceMonitor(page);
|
||||
|
||||
const vitals = await monitor.getCoreWebVitals();
|
||||
|
||||
expect(vitals.FCP).toBeGreaterThan(0);
|
||||
expect(vitals.LCP).toBeGreaterThan(0);
|
||||
expect(vitals.CLS).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { MobileTestReporter } from '../../utils/MobileTestReporter';
|
||||
|
||||
test('MobileTestReporter should generate HTML report', async () => {
|
||||
const reporter = new MobileTestReporter({} as any);
|
||||
const html = reporter.generateHtmlReport({
|
||||
suites: [],
|
||||
duration: 10000,
|
||||
status: 'passed',
|
||||
} as any);
|
||||
|
||||
expect(html).toContain('移动端测试报告');
|
||||
expect(html).toContain('总测试数');
|
||||
expect(html).toContain('通过');
|
||||
expect(html).toContain('失败');
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { NetworkSimulator } from '../../utils/NetworkSimulator';
|
||||
import { networkConfigs } from '../../config/network-configs';
|
||||
|
||||
test.describe('NetworkSimulator', () => {
|
||||
test('should set 3G network condition', async ({ page, context }) => {
|
||||
const simulator = new NetworkSimulator(context);
|
||||
await simulator.setNetworkCondition(networkConfigs['3g-fast']);
|
||||
|
||||
await page.goto('/');
|
||||
await expect(page.locator('header')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should switch to offline mode', async ({ page, context }) => {
|
||||
const simulator = new NetworkSimulator(context);
|
||||
await page.goto('/');
|
||||
|
||||
await simulator.goOffline();
|
||||
await page.reload();
|
||||
|
||||
await expect(page.locator('header')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should switch back to online mode', async ({ page, context }) => {
|
||||
const simulator = new NetworkSimulator(context);
|
||||
await simulator.goOffline();
|
||||
|
||||
await simulator.goOnline();
|
||||
await page.goto('/');
|
||||
|
||||
await expect(page.locator('header')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should simulate network switch', async ({ page, context }) => {
|
||||
const simulator = new NetworkSimulator(context);
|
||||
|
||||
await simulator.simulateNetworkSwitch(networkConfigs['wifi-fast'], networkConfigs['3g-fast']);
|
||||
|
||||
await page.goto('/');
|
||||
await expect(page.locator('header')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should reset network condition', async ({ page, context }) => {
|
||||
const simulator = new NetworkSimulator(context);
|
||||
await simulator.setNetworkCondition(networkConfigs['3g-fast']);
|
||||
|
||||
await simulator.resetNetworkCondition();
|
||||
await page.goto('/');
|
||||
|
||||
await expect(page.locator('header')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should track network requests', async ({ page, context }) => {
|
||||
const simulator = new NetworkSimulator(context);
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
const requests = simulator.getRequests();
|
||||
expect(requests.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('should get failed requests', async ({ page, context }) => {
|
||||
const simulator = new NetworkSimulator(context);
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
const failedRequests = simulator.getFailedRequests();
|
||||
expect(Array.isArray(failedRequests)).toBe(true);
|
||||
});
|
||||
|
||||
test('should get slow requests', async ({ page, context }) => {
|
||||
const simulator = new NetworkSimulator(context);
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
const slowRequests = simulator.getSlowRequests(1000);
|
||||
expect(Array.isArray(slowRequests)).toBe(true);
|
||||
});
|
||||
|
||||
test('should clear requests', async ({ page, context }) => {
|
||||
const simulator = new NetworkSimulator(context);
|
||||
|
||||
await page.goto('/');
|
||||
expect(simulator.getRequests().length).toBeGreaterThan(0);
|
||||
|
||||
simulator.clearRequests();
|
||||
expect(simulator.getRequests().length).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -39,6 +39,7 @@ export interface DeviceConfig {
|
||||
viewport: { width: number; height: number };
|
||||
userAgent?: string;
|
||||
isMobile: boolean;
|
||||
deviceScaleFactor?: number;
|
||||
}
|
||||
|
||||
export interface TestResult {
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import { Page, Locator } from '@playwright/test';
|
||||
|
||||
export interface SwipeOptions {
|
||||
startX: number;
|
||||
startY: number;
|
||||
endX: number;
|
||||
endY: number;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export interface PinchOptions {
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
startDistance: number;
|
||||
endDistance: number;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export interface DragOptions {
|
||||
source: Locator;
|
||||
target: Locator;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export class GestureSimulator {
|
||||
constructor(private page: Page) {}
|
||||
|
||||
async swipe(options: SwipeOptions): Promise<void> {
|
||||
const { startX, startY, endX, endY, duration } = options;
|
||||
|
||||
const steps = 10;
|
||||
const stepDuration = duration / steps;
|
||||
|
||||
const touches = [{
|
||||
identifier: 0,
|
||||
clientX: startX,
|
||||
clientY: startY,
|
||||
}];
|
||||
|
||||
await this.page.dispatchEvent('touchstart', { touches, changedTouches: touches, targetTouches: touches } as any);
|
||||
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
const x = startX + (endX - startX) * (i / steps);
|
||||
const y = startY + (endY - startY) * (i / steps);
|
||||
|
||||
const moveTouches = [{
|
||||
identifier: 0,
|
||||
clientX: x,
|
||||
clientY: y,
|
||||
}];
|
||||
|
||||
await this.page.dispatchEvent('touchmove', { touches: moveTouches, changedTouches: moveTouches, targetTouches: moveTouches } as any);
|
||||
await this.page.waitForTimeout(stepDuration);
|
||||
}
|
||||
|
||||
await this.page.dispatchEvent('touchend', { touches: [], changedTouches: [], targetTouches: [] } as any);
|
||||
}
|
||||
|
||||
async quickSwipeDown(): Promise<void> {
|
||||
const viewport = this.page.viewportSize();
|
||||
if (!viewport) return;
|
||||
|
||||
await this.swipe({
|
||||
startX: viewport.width / 2,
|
||||
startY: viewport.height * 0.3,
|
||||
endX: viewport.width / 2,
|
||||
endY: viewport.height * 0.7,
|
||||
duration: 300,
|
||||
});
|
||||
}
|
||||
|
||||
async slowSwipeUp(): Promise<void> {
|
||||
const viewport = this.page.viewportSize();
|
||||
if (!viewport) return;
|
||||
|
||||
await this.swipe({
|
||||
startX: viewport.width / 2,
|
||||
startY: viewport.height * 0.7,
|
||||
endX: viewport.width / 2,
|
||||
endY: viewport.height * 0.3,
|
||||
duration: 800,
|
||||
});
|
||||
}
|
||||
|
||||
async pinch(options: PinchOptions): Promise<void> {
|
||||
const { centerX, centerY, startDistance, endDistance, duration } = options;
|
||||
|
||||
const startDistanceX = startDistance / 2;
|
||||
const startDistanceY = startDistance / 2;
|
||||
const endDistanceX = endDistance / 2;
|
||||
const endDistanceY = endDistance / 2;
|
||||
|
||||
const steps = 10;
|
||||
const stepDuration = duration / steps;
|
||||
|
||||
const touches = [
|
||||
{
|
||||
identifier: 0,
|
||||
clientX: centerX - startDistanceX,
|
||||
clientY: centerY - startDistanceY,
|
||||
},
|
||||
{
|
||||
identifier: 1,
|
||||
clientX: centerX + startDistanceX,
|
||||
clientY: centerY + startDistanceY,
|
||||
},
|
||||
];
|
||||
|
||||
await this.page.dispatchEvent('touchstart', { touches, changedTouches: touches, targetTouches: touches } as any);
|
||||
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
const progress = i / steps;
|
||||
const currentDistanceX = startDistanceX + (endDistanceX - startDistanceX) * progress;
|
||||
const currentDistanceY = startDistanceY + (endDistanceY - startDistanceY) * progress;
|
||||
|
||||
const moveTouches = [
|
||||
{
|
||||
identifier: 0,
|
||||
clientX: centerX - currentDistanceX,
|
||||
clientY: centerY - currentDistanceY,
|
||||
},
|
||||
{
|
||||
identifier: 1,
|
||||
clientX: centerX + currentDistanceX,
|
||||
clientY: centerY + currentDistanceY,
|
||||
},
|
||||
];
|
||||
|
||||
await this.page.dispatchEvent('touchmove', { touches: moveTouches, changedTouches: moveTouches, targetTouches: moveTouches } as any);
|
||||
await this.page.waitForTimeout(stepDuration);
|
||||
}
|
||||
|
||||
await this.page.dispatchEvent('touchend', { touches: [], changedTouches: [], targetTouches: [] } as any);
|
||||
}
|
||||
|
||||
async longPress(element: Locator, duration: number = 1000): Promise<void> {
|
||||
const box = await element.boundingBox();
|
||||
if (!box) throw new Error('Element not visible');
|
||||
|
||||
const x = box.x + box.width / 2;
|
||||
const y = box.y + box.height / 2;
|
||||
|
||||
const touches = [{
|
||||
identifier: 0,
|
||||
clientX: x,
|
||||
clientY: y,
|
||||
}];
|
||||
|
||||
await element.dispatchEvent('touchstart', { touches, changedTouches: touches, targetTouches: touches } as any);
|
||||
await this.page.waitForTimeout(duration);
|
||||
await element.dispatchEvent('touchend', { touches: [], changedTouches: [], targetTouches: [] } as any);
|
||||
}
|
||||
|
||||
async doubleTap(element: Locator): Promise<void> {
|
||||
const box = await element.boundingBox();
|
||||
if (!box) throw new Error('Element not visible');
|
||||
|
||||
const x = box.x + box.width / 2;
|
||||
const y = box.y + box.height / 2;
|
||||
|
||||
const touches = [{
|
||||
identifier: 0,
|
||||
clientX: x,
|
||||
clientY: y,
|
||||
}];
|
||||
|
||||
await element.dispatchEvent('touchstart', { touches, changedTouches: touches, targetTouches: touches } as any);
|
||||
await element.dispatchEvent('touchend', { touches: [], changedTouches: [], targetTouches: [] } as any);
|
||||
await this.page.waitForTimeout(100);
|
||||
await element.dispatchEvent('touchstart', { touches, changedTouches: touches, targetTouches: touches } as any);
|
||||
await element.dispatchEvent('touchend', { touches: [], changedTouches: [], targetTouches: [] } as any);
|
||||
}
|
||||
|
||||
async drag(options: DragOptions): Promise<void> {
|
||||
const { source, target, duration } = options;
|
||||
|
||||
const sourceBox = await source.boundingBox();
|
||||
const targetBox = await target.boundingBox();
|
||||
|
||||
if (!sourceBox || !targetBox) {
|
||||
throw new Error('Source or target element not visible');
|
||||
}
|
||||
|
||||
const startX = sourceBox.x + sourceBox.width / 2;
|
||||
const startY = sourceBox.y + sourceBox.height / 2;
|
||||
const endX = targetBox.x + targetBox.width / 2;
|
||||
const endY = targetBox.y + targetBox.height / 2;
|
||||
|
||||
await this.swipe({
|
||||
startX,
|
||||
startY,
|
||||
endX,
|
||||
endY,
|
||||
duration,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Page } from '@playwright/test';
|
||||
|
||||
export interface CoreWebVitals {
|
||||
FCP: number;
|
||||
LCP: number;
|
||||
CLS: number;
|
||||
FID: number;
|
||||
TTI: number;
|
||||
}
|
||||
|
||||
export interface LighthouseResult {
|
||||
score: number;
|
||||
audits: Record<string, any>;
|
||||
}
|
||||
|
||||
export class MobilePerformanceMonitor {
|
||||
constructor(private page: Page) {}
|
||||
|
||||
async getCoreWebVitals(): Promise<CoreWebVitals> {
|
||||
const vitals = await this.page.evaluate(() => {
|
||||
return new Promise<any>((resolve) => {
|
||||
const metrics: any = {};
|
||||
|
||||
const observer = new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
if (entry.entryType === 'paint') {
|
||||
if (entry.name === 'first-contentful-paint') {
|
||||
metrics.FCP = entry.startTime;
|
||||
}
|
||||
} else if (entry.entryType === 'largest-contentful-paint') {
|
||||
metrics.LCP = entry.startTime;
|
||||
} else if (entry.entryType === 'layout-shift') {
|
||||
if (!metrics.CLS) metrics.CLS = 0;
|
||||
metrics.CLS += (entry as any).value;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe({ entryTypes: ['paint', 'largest-contentful-paint', 'layout-shift'] });
|
||||
|
||||
setTimeout(() => {
|
||||
observer.disconnect();
|
||||
resolve(metrics);
|
||||
}, 5000);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
FCP: vitals.FCP || 0,
|
||||
LCP: vitals.LCP || 0,
|
||||
CLS: vitals.CLS || 0,
|
||||
FID: 0,
|
||||
TTI: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Page } from '@playwright/test';
|
||||
import { getNetworkConfig, NetworkConfig } from '../config/network-configs';
|
||||
import { getDevice } from './devices';
|
||||
import { DeviceConfig } from '../types';
|
||||
|
||||
export class MobileTestDataGenerator {
|
||||
static generateUserAgent(device: string): string {
|
||||
const deviceConfig = getDevice(device);
|
||||
return deviceConfig.userAgent || '';
|
||||
}
|
||||
|
||||
static generateNetworkConfig(type: '2G' | '3G' | '4G' | 'WiFi' | 'offline'): NetworkConfig {
|
||||
const configMap = {
|
||||
'2G': '2g-slow',
|
||||
'3G': '3g-fast',
|
||||
'4G': '4g-lte',
|
||||
'WiFi': 'wifi-fast',
|
||||
'offline': 'offline',
|
||||
};
|
||||
return getNetworkConfig(configMap[type]);
|
||||
}
|
||||
}
|
||||
|
||||
export interface TouchEventData {
|
||||
type: 'tap' | 'swipe' | 'pinch' | 'longPress';
|
||||
x: number;
|
||||
y: number;
|
||||
duration?: number;
|
||||
distance?: number;
|
||||
}
|
||||
|
||||
export interface PerformanceBaseline {
|
||||
device: string;
|
||||
network: string;
|
||||
LCP: number;
|
||||
FCP: number;
|
||||
CLS: number;
|
||||
FID: number;
|
||||
TTI: number;
|
||||
}
|
||||
|
||||
export function generateTouchEvent(type: 'tap' | 'swipe' | 'pinch' | 'longPress'): TouchEventData {
|
||||
const baseData = {
|
||||
x: Math.floor(Math.random() * 375),
|
||||
y: Math.floor(Math.random() * 667),
|
||||
};
|
||||
|
||||
switch (type) {
|
||||
case 'tap':
|
||||
return { ...baseData, type: 'tap' };
|
||||
case 'swipe':
|
||||
return { ...baseData, type: 'swipe', duration: 500 };
|
||||
case 'pinch':
|
||||
return { ...baseData, type: 'pinch', distance: 100 };
|
||||
case 'longPress':
|
||||
return { ...baseData, type: 'longPress', duration: 1000 };
|
||||
default:
|
||||
return { ...baseData, type: 'tap' };
|
||||
}
|
||||
};
|
||||
|
||||
export function generatePerformanceBaseline(device: string, network: string): PerformanceBaseline {
|
||||
const deviceMultiplier = {
|
||||
'mobile-375x667': 1.2,
|
||||
'mobile-414x896': 1.1,
|
||||
'tablet-768x1024': 0.9,
|
||||
'desktop-1920x1080': 0.8,
|
||||
}[device] || 1;
|
||||
|
||||
const networkMultiplier = {
|
||||
'2g-slow': 3,
|
||||
'3g-fast': 2,
|
||||
'4g-lte': 1.5,
|
||||
'wifi-fast': 1,
|
||||
}[network] || 1;
|
||||
|
||||
const multiplier = deviceMultiplier * networkMultiplier;
|
||||
|
||||
return {
|
||||
device,
|
||||
network,
|
||||
LCP: 2500 * multiplier,
|
||||
FCP: 1800 * multiplier,
|
||||
CLS: 0.1,
|
||||
FID: 100 * multiplier,
|
||||
TTI: 3800 * multiplier,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,98 @@
|
||||
import { FullConfig } from '@playwright/test';
|
||||
|
||||
export interface TestOverview {
|
||||
total: number;
|
||||
passed: number;
|
||||
failed: number;
|
||||
skipped: number;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export interface DeviceTestResult {
|
||||
device: string;
|
||||
passed: number;
|
||||
failed: number;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export class MobileTestReporter {
|
||||
constructor(private config: FullConfig) {}
|
||||
|
||||
generateOverview(results: any): TestOverview {
|
||||
const total = results.suites.reduce((sum: number, suite: any) => {
|
||||
return sum + suite.suites.reduce((suiteSum: number, subSuite: any) => {
|
||||
return suiteSum + subSuite.cases.length;
|
||||
}, 0);
|
||||
}, 0);
|
||||
|
||||
const passed = results.suites.reduce((sum: number, suite: any) => {
|
||||
return sum + suite.suites.reduce((suiteSum: number, subSuite: any) => {
|
||||
return suiteSum + subSuite.cases.filter((c: any) => c.results[0]?.status === 'passed').length;
|
||||
}, 0);
|
||||
}, 0);
|
||||
|
||||
const failed = results.suites.reduce((sum: number, suite: any) => {
|
||||
return sum + suite.suites.reduce((suiteSum: number, subSuite: any) => {
|
||||
return suiteSum + subSuite.cases.filter((c: any) => c.results[0]?.status === 'failed').length;
|
||||
}, 0);
|
||||
}, 0);
|
||||
|
||||
return {
|
||||
total,
|
||||
passed,
|
||||
failed,
|
||||
skipped: total - passed - failed,
|
||||
duration: results.duration,
|
||||
};
|
||||
}
|
||||
|
||||
generateHtmlReport(results: any): string {
|
||||
const overview = this.generateOverview(results);
|
||||
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>移动端测试报告</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 20px; }
|
||||
.overview { background: #f5f5f5; padding: 20px; border-radius: 8px; margin-bottom: 20px; }
|
||||
.stat { display: inline-block; margin: 0 20px 10px 0; }
|
||||
.stat-value { font-size: 24px; font-weight: bold; }
|
||||
.stat-label { color: #666; }
|
||||
.passed { color: #4caf50; }
|
||||
.failed { color: #f44336; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>移动端测试报告</h1>
|
||||
<div class="overview">
|
||||
<div class="stat">
|
||||
<div class="stat-value">${overview.total}</div>
|
||||
<div class="stat-label">总测试数</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-value passed">${overview.passed}</div>
|
||||
<div class="stat-label">通过</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-value failed">${overview.failed}</div>
|
||||
<div class="stat-label">失败</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-value">${(overview.duration / 1000).toFixed(2)}s</div>
|
||||
<div class="stat-label">执行时间</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
async saveReport(report: string, outputPath: string): Promise<void> {
|
||||
const fs = await import('fs/promises');
|
||||
await fs.writeFile(outputPath, report, 'utf-8');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { BrowserContext, Page } from '@playwright/test';
|
||||
import { NetworkConfig } from '../config/network-configs';
|
||||
|
||||
export interface NetworkRequest {
|
||||
url: string;
|
||||
method: string;
|
||||
status: number;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export class NetworkSimulator {
|
||||
private requests: NetworkRequest[] = [];
|
||||
|
||||
constructor(private context: BrowserContext) {
|
||||
this.setupRequestMonitoring();
|
||||
}
|
||||
|
||||
private setupRequestMonitoring(): void {
|
||||
this.context.on('request', (request) => {
|
||||
this.requests.push({
|
||||
url: request.url(),
|
||||
method: request.method(),
|
||||
status: 0,
|
||||
duration: 0,
|
||||
});
|
||||
});
|
||||
|
||||
this.context.on('response', (response) => {
|
||||
const request = this.requests.find(r => r.url === response.url());
|
||||
if (request) {
|
||||
request.status = response.status();
|
||||
request.duration = 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async setNetworkCondition(config: NetworkConfig): Promise<void> {
|
||||
if (config.offline) {
|
||||
await this.context.setOffline(true);
|
||||
} else {
|
||||
await this.context.setOffline(false);
|
||||
if (config.downloadThroughput && config.uploadThroughput && config.latency) {
|
||||
await this.context.route('**', (route) => {
|
||||
route.continue({
|
||||
headers: {
|
||||
...route.request().headers(),
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async goOffline(): Promise<void> {
|
||||
await this.context.setOffline(true);
|
||||
}
|
||||
|
||||
async goOnline(): Promise<void> {
|
||||
await this.context.setOffline(false);
|
||||
}
|
||||
|
||||
async simulateNetworkSwitch(fromConfig: NetworkConfig, toConfig: NetworkConfig): Promise<void> {
|
||||
await this.setNetworkCondition(fromConfig);
|
||||
await this.context.pages()[0]?.waitForTimeout(1000);
|
||||
await this.setNetworkCondition(toConfig);
|
||||
}
|
||||
|
||||
async resetNetworkCondition(): Promise<void> {
|
||||
await this.context.setOffline(false);
|
||||
await this.context.unrouteAll();
|
||||
}
|
||||
|
||||
getRequests(): NetworkRequest[] {
|
||||
return [...this.requests];
|
||||
}
|
||||
|
||||
clearRequests(): void {
|
||||
this.requests = [];
|
||||
}
|
||||
|
||||
getFailedRequests(): NetworkRequest[] {
|
||||
return this.requests.filter(r => r.status >= 400);
|
||||
}
|
||||
|
||||
getSlowRequests(threshold: number = 1000): NetworkRequest[] {
|
||||
return this.requests.filter(r => r.duration > threshold);
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,55 @@ export const devices: Record<string, DeviceConfig> = {
|
||||
viewport: { width: 412, height: 915 },
|
||||
isMobile: true,
|
||||
},
|
||||
'iphone-13-pro': {
|
||||
name: 'iPhone 13 Pro',
|
||||
viewport: { width: 390, height: 844 },
|
||||
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 Mobile/15E148 Safari/604.1',
|
||||
isMobile: true,
|
||||
deviceScaleFactor: 3,
|
||||
},
|
||||
'iphone-14-pro': {
|
||||
name: 'iPhone 14 Pro',
|
||||
viewport: { width: 393, height: 852 },
|
||||
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1',
|
||||
isMobile: true,
|
||||
deviceScaleFactor: 3,
|
||||
},
|
||||
'iphone-15-pro': {
|
||||
name: 'iPhone 15 Pro',
|
||||
viewport: { width: 393, height: 852 },
|
||||
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1',
|
||||
isMobile: true,
|
||||
deviceScaleFactor: 3,
|
||||
},
|
||||
'pixel-7': {
|
||||
name: 'Google Pixel 7',
|
||||
viewport: { width: 412, height: 915 },
|
||||
userAgent: 'Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Mobile Safari/537.36',
|
||||
isMobile: true,
|
||||
deviceScaleFactor: 2.625,
|
||||
},
|
||||
'samsung-galaxy-s23': {
|
||||
name: 'Samsung Galaxy S23',
|
||||
viewport: { width: 360, height: 780 },
|
||||
userAgent: 'Mozilla/5.0 (Linux; Android 13; SM-S911B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Mobile Safari/537.36',
|
||||
isMobile: true,
|
||||
deviceScaleFactor: 3,
|
||||
},
|
||||
'ipad-air': {
|
||||
name: 'iPad Air',
|
||||
viewport: { width: 820, height: 1180 },
|
||||
userAgent: 'Mozilla/5.0 (iPad; CPU OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1',
|
||||
isMobile: true,
|
||||
deviceScaleFactor: 2,
|
||||
},
|
||||
'ipad-pro-12-9': {
|
||||
name: 'iPad Pro 12.9"',
|
||||
viewport: { width: 1024, height: 1366 },
|
||||
userAgent: 'Mozilla/5.0 (iPad; CPU OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1',
|
||||
isMobile: true,
|
||||
deviceScaleFactor: 2,
|
||||
},
|
||||
};
|
||||
|
||||
export const desktopDevices = Object.entries(devices)
|
||||
|
||||
Reference in New Issue
Block a user