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:
张翔
2026-03-05 16:45:18 +08:00
21 changed files with 4237 additions and 3 deletions
+263
View File
@@ -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. 更新本文档(如需要)
+2516
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -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"
} }
} }
+33 -3
View File
@@ -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',
+50
View File
@@ -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);
});
});
+1
View File
@@ -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 {
+197
View File
@@ -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,
});
}
}
+56
View File
@@ -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,
};
}
}
+88
View File
@@ -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,
};
};
+98
View File
@@ -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');
}
}
+88
View File
@@ -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);
}
}
+49
View File
@@ -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)