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",
|
"@types/node": "^20.11.0",
|
||||||
"allure-commandline": "^2.37.0",
|
"allure-commandline": "^2.37.0",
|
||||||
"allure-playwright": "^3.5.0",
|
"allure-playwright": "^3.5.0",
|
||||||
|
"chrome-launcher": "^1.2.1",
|
||||||
"glob": "^13.0.6",
|
"glob": "^13.0.6",
|
||||||
|
"lighthouse": "^13.0.3",
|
||||||
"typescript": "^5.3.0"
|
"typescript": "^5.3.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { defineConfig, devices } from '@playwright/test';
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
import { getEnvironment } from './src/config/environments';
|
import { getEnvironment } from './src/config/environments';
|
||||||
|
import { getMobileDevices } from './src/utils/devices';
|
||||||
|
|
||||||
const env = getEnvironment();
|
const env = getEnvironment();
|
||||||
|
|
||||||
@@ -8,7 +9,7 @@ export default defineConfig({
|
|||||||
fullyParallel: true,
|
fullyParallel: true,
|
||||||
forbidOnly: !!process.env.CI,
|
forbidOnly: !!process.env.CI,
|
||||||
retries: env.retries,
|
retries: env.retries,
|
||||||
workers: process.env.CI ? 4 : undefined,
|
workers: process.env.CI ? 4 : '50%',
|
||||||
reporter: [
|
reporter: [
|
||||||
['html', { open: 'never' }],
|
['html', { open: 'never' }],
|
||||||
['json', { outputFile: 'test-results/results.json' }],
|
['json', { outputFile: 'test-results/results.json' }],
|
||||||
@@ -21,9 +22,9 @@ export default defineConfig({
|
|||||||
suiteTitle: false,
|
suiteTitle: false,
|
||||||
}],
|
}],
|
||||||
],
|
],
|
||||||
timeout: env.timeout,
|
timeout: 60000,
|
||||||
expect: {
|
expect: {
|
||||||
timeout: 30000
|
timeout: 30000,
|
||||||
},
|
},
|
||||||
use: {
|
use: {
|
||||||
baseURL: env.baseURL,
|
baseURL: env.baseURL,
|
||||||
@@ -59,6 +60,35 @@ export default defineConfig({
|
|||||||
name: 'Mobile Safari',
|
name: 'Mobile Safari',
|
||||||
use: { ...devices['iPhone 12'] },
|
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' ? {
|
webServer: env.name === 'development' ? {
|
||||||
command: 'cd .. && npm run dev',
|
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 };
|
viewport: { width: number; height: number };
|
||||||
userAgent?: string;
|
userAgent?: string;
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
|
deviceScaleFactor?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TestResult {
|
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 },
|
viewport: { width: 412, height: 915 },
|
||||||
isMobile: true,
|
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)
|
export const desktopDevices = Object.entries(devices)
|
||||||
|
|||||||
Reference in New Issue
Block a user