优化分批执行点击菜单

This commit is contained in:
dengqichen 2025-03-07 16:53:34 +08:00
parent 015f8abf32
commit 40863e3eca
13 changed files with 493 additions and 78 deletions

View File

@ -12,9 +12,7 @@
"test:menu": "cross-env NODE_ENV=dev playwright test tests/e2e/menu.spec.js",
"test:menu:ui": "cross-env NODE_ENV=dev playwright test tests/e2e/menu.spec.js --ui",
"test:menu:debug": "cross-env NODE_ENV=dev playwright test tests/e2e/menu.spec.js --debug",
"test:menu:clean": "cross-env NODE_ENV=dev rimraf test-data/* && npm run test:menu",
"test:menu:collect": "cross-env NODE_ENV=dev playwright test tests/e2e/menu.spec.js --grep \"收集菜单数据\"",
"test:menu:batch": "cross-env NODE_ENV=dev playwright test tests/e2e/menu.spec.js --grep \"批量测试菜单\""
"test:menu:clean": "cross-env NODE_ENV=dev rimraf test-data/* && npm run test:menu"
},
"keywords": [
"playwright",

View File

@ -1,19 +1,18 @@
// @ts-check
const {defineConfig, devices} = require('@playwright/test');
const testLifecycle = require('./src/hooks/testLifecycle');
/**
* @see https://playwright.dev/docs/test-configuration
*/
/**
* @type {import('@playwright/test').PlaywrightTestConfig}
*/
const config = {
testDir: './tests',
/* 测试超时时间 */
timeout: parseInt(process.env.EXPECT_TIMEOUT),
timeout: parseInt(process.env.EXPECT_TIMEOUT || '30000'),
/* 每个测试的预期状态 */
expect: {
timeout: parseInt(process.env.EXPECT_TIMEOUT)
timeout: parseInt(process.env.EXPECT_TIMEOUT || '30000')
},
/* 测试运行并发数 */
fullyParallel: false,
@ -30,19 +29,21 @@ const config = {
/* 收集测试追踪信息 */
trace: 'on-first-retry',
/* 自动截图 */
screenshot: 'only-on-failure',
screenshot: {
mode: 'only-on-failure'
},
/* 录制视频 */
video: 'retain-on-failure',
/* 浏览器配置 */
headless: process.env.BROWSER_HEADLESS === 'true',
viewport: {
width: parseInt(process.env.VIEWPORT_WIDTH),
height: parseInt(process.env.VIEWPORT_HEIGHT)
width: parseInt(process.env.VIEWPORT_WIDTH || '1920'),
height: parseInt(process.env.VIEWPORT_HEIGHT || '1080')
},
/* 浏览器启动选项 */
launchOptions: {
slowMo: parseInt(process.env.BROWSER_SLOW_MO),
timeout: parseInt(process.env.BROWSER_TIMEOUT)
slowMo: parseInt(process.env.BROWSER_SLOW_MO || '50'),
timeout: parseInt(process.env.BROWSER_TIMEOUT || '30000')
}
},
@ -60,6 +61,10 @@ const config = {
// port: 3000,
// reuseExistingServer: !process.env.CI,
// },
/* 全局设置和清理 */
globalSetup: './src/hooks/setup.js',
globalTeardown: './src/hooks/teardown.js'
};
module.exports = config;
module.exports = defineConfig(config);

View File

@ -13,14 +13,6 @@ class LongiTestController {
return await menuDataService.fetchAndSaveMenuData();
}
/**
* 获取测试进度信息
* @returns {Object} - 进度信息
*/
getTestProgress() {
return menuDataService.getTestProgress();
}
/**
* 执行所有菜单的测试
* @returns {Promise<Object>} - 测试结果

11
src/hooks/setup.js Normal file
View File

@ -0,0 +1,11 @@
const testLifecycle = require('./testLifecycle');
/**
* 全局设置
* 在所有测试开始前执行
*/
async function globalSetup() {
await testLifecycle.beforeAll();
}
module.exports = globalSetup;

11
src/hooks/teardown.js Normal file
View File

@ -0,0 +1,11 @@
const testLifecycle = require('./testLifecycle');
/**
* 全局清理
* 在所有测试结束后执行
*/
async function globalTeardown() {
await testLifecycle.afterAll();
}
module.exports = globalTeardown;

119
src/hooks/testLifecycle.js Normal file
View File

@ -0,0 +1,119 @@
const performanceService = require('../services/PerformanceService');
const FileUtils = require('../utils/FileUtils');
/**
* 测试生命周期管理
* 负责管理测试的全局生命周期包括
* 1. 测试开始前的初始化工作
* 2. 测试结束后的清理和报告生成
* 3. 其他全局测试设置
*/
class TestLifecycle {
constructor() {
this.reportPath = process.env.PERFORMANCE_REPORT_PATH || './test-results/performance-report.json';
}
/**
* 测试开始前的初始化
* 在所有测试开始前执行用于准备测试环境
*/
async beforeAll() {
// 确保测试结果目录存在
await FileUtils.ensureDirectoryExists('./test-results');
// 清空之前的性能数据
await performanceService.clearPerformanceData();
console.log('✨ 测试环境初始化完成');
}
/**
* 测试结束后的处理
* 在所有测试结束后执行用于生成报告和清理工作
*/
async afterAll() {
const performanceData = performanceService.getPerformanceData();
const report = this.generateReport(performanceData);
await this.saveReport(report);
console.log(`📊 测试报告已生成: ${this.reportPath}`);
}
/**
* 生成测试报告
* @param {Array} performanceData 性能数据数组
* @returns {Object} 测试报告对象
*/
generateReport(performanceData) {
// 按页面名称分组的统计
const pageStats = {};
let totalDuration = 0;
let totalSuccess = 0;
let totalFailure = 0;
// 统计每个页面的性能数据
performanceData.forEach(record => {
const { pageName, duration, success } = record;
if (!pageStats[pageName]) {
pageStats[pageName] = {
totalTests: 0,
successCount: 0,
failureCount: 0,
totalDuration: 0,
averageDuration: 0,
minDuration: Infinity,
maxDuration: 0,
errors: []
};
}
const stats = pageStats[pageName];
stats.totalTests++;
success ? stats.successCount++ : stats.failureCount++;
stats.totalDuration += duration;
stats.minDuration = Math.min(stats.minDuration, duration);
stats.maxDuration = Math.max(stats.maxDuration, duration);
stats.averageDuration = stats.totalDuration / stats.totalTests;
if (!success && record.errorMessage) {
stats.errors.push({
timestamp: record.timestamp,
error: record.errorMessage
});
}
// 更新总体统计
totalDuration += duration;
success ? totalSuccess++ : totalFailure++;
});
// 生成报告
return {
summary: {
totalTests: performanceData.length,
successCount: totalSuccess,
failureCount: totalFailure,
successRate: (totalSuccess / performanceData.length * 100).toFixed(2) + '%',
averageDuration: totalDuration / performanceData.length,
totalDuration: totalDuration
},
pageStats,
timestamp: new Date().toISOString(),
rawData: performanceData
};
}
/**
* 保存测试报告
* @param {Object} report 测试报告对象
*/
async saveReport(report) {
try {
await FileUtils.writeJsonFile(this.reportPath, report);
} catch (error) {
console.error('保存测试报告失败:', error);
}
}
}
// 创建单例实例
const testLifecycle = new TestLifecycle();
module.exports = testLifecycle;

View File

@ -3,6 +3,8 @@
* 提供所有页面共用的方法和属性
*/
const performanceService = require('../services/PerformanceService');
class BasePage {
/**
* 创建页面对象
@ -192,20 +194,28 @@ class BasePage {
async waitForIBPPageLoadWithRetry(pageName) {
console.log(`等待页面 ${pageName} 数据加载...`);
const startTime = Date.now();
let retryCount = 0;
const {maxRetries, retryInterval, stabilityDelay} = this.config.pageLoad;
let errorMessage = null;
try {
while (retryCount < maxRetries) {
// 检查错误状态
const hasError = await this.checkPageError(pageName);
if (hasError) return false;
if (hasError) {
errorMessage = await this.getErrorMessage();
break;
}
// 检查加载状态
const isLoading = await this.elementExistsBySelector(this.selectors.loadingMask);
if (!isLoading) {
await this.waitForTimeout(stabilityDelay);
console.log(`✅ 页面 ${pageName} 加载完成`);
// 记录成功的性能数据
await performanceService.recordSuccess(pageName, startTime, Date.now());
return true;
}
@ -213,9 +223,17 @@ class BasePage {
await this.waitForTimeout(retryInterval);
}
console.error(`页面加载超时: ${pageName}, 重试次数: ${maxRetries}`);
if (retryCount >= maxRetries) {
errorMessage = `页面加载超时: ${pageName}, 重试次数: ${maxRetries}`;
}
// 记录失败的性能数据
await performanceService.recordFailure(pageName, startTime, Date.now(), errorMessage);
console.error(errorMessage);
return false;
} catch (error) {
// 记录异常的性能数据
await performanceService.recordError(pageName, startTime, Date.now(), error);
console.error(`页面加载出错: ${pageName}, 错误信息: ${error.message}`);
return false;
}
@ -242,6 +260,21 @@ class BasePage {
return false;
}
/**
* 获取错误信息
* @returns {Promise<string>} 错误信息
* @private
*/
async getErrorMessage() {
const errorSelectors = [this.selectors.errorBox, this.selectors.errorMessage];
for (const selector of errorSelectors) {
const elements = await this.page.locator(selector).all();
if (elements.length > 0) {
return await this.getTextByElement(elements[0]);
}
}
return null;
}
/**
* 获取当前URL
@ -251,7 +284,6 @@ class BasePage {
return this.page.url();
}
/**
* 等待指定时间
* @param {number} ms 等待时间毫秒

View File

@ -119,7 +119,7 @@ class LongiMainPage extends BasePage {
* @returns {Promise<Array>} 菜单项数组
*/
async findMenuItems() {
console.log('开始查找菜单项...');
// console.log('开始查找菜单项...');
// 等待菜单加载完成
await this.page.waitForSelector(this.selectors.sideNav, {
@ -128,7 +128,7 @@ class LongiMainPage extends BasePage {
// 获取所有菜单项
const items = await this.page.locator(this.selectors.menuItems).all();
console.log(`找到 ${items.length} 个菜单项元素`);
// console.log(`找到 ${items.length} 个菜单项元素`);
const menuItems = [];
// 处理每个菜单项
@ -165,7 +165,7 @@ class LongiMainPage extends BasePage {
// 综合判断是否有三级菜单
const hasThirdMenu = isSubMenuTitle || hasSubMenuIndicator || hasThirdLevelIndicator;
console.log(`菜单项 "${text.trim()}" ${hasThirdMenu ? '有' : '没有'}三级菜单 (通过DOM结构判断)`);
// console.log(`菜单项 "${text.trim()}" ${hasThirdMenu ? '有' : '没有'}三级菜单 (通过DOM结构判断)`);
// 生成唯一标识符,结合索引和文本
const uniqueId = `menu_${i}_${text.trim().replace(/\s+/g, '_')}`;

View File

@ -83,7 +83,9 @@ class LongiTestService {
const mainPage = new LongiMainPage(page);
await mainPage.handleAllMenuClicks(menuBatch);
await this.updateTestProgress(menuBatch);
} catch (error) {
console.error('批次测试执行失败:', error);
throw error;
} finally {
if (browser) await browser.close();
}
@ -95,13 +97,29 @@ class LongiTestService {
* @private
*/
async updateTestProgress(completedBatch) {
if (!completedBatch?.length) return;
const progress = this.getProgress();
const newProgress = [...progress];
// 验证并更新进度
for (const menuItem of completedBatch) {
if (!menuItem?.id) {
console.warn('警告: 菜单项缺少ID', menuItem);
continue;
}
if (!newProgress.includes(menuItem.id)) {
newProgress.push(menuItem.id);
}
}
// 验证新进度的有效性
const menuData = this.getMenuData();
if (newProgress.length > menuData.length) {
console.error('错误: 进度数超过总菜单数');
return;
}
this.saveProgress(newProgress);
}
@ -222,39 +240,89 @@ class LongiTestService {
* @returns {Promise<Object>} - 测试结果
*/
async runAllTests() {
// 清理之前的进度
this.saveProgress([]);
let retryCount = 0;
try {
// 获取菜单数据并验证
const menuData = this.getMenuData();
if (!menuData?.length) {
throw new Error('没有可用的菜单数据,请先执行 fetchAndSaveMenuData');
}
while (true) {
try {
const batch = this.getNextBatch();
if (!batch?.length) {
const progress = this.getTestProgress();
// 清理之前的进度
this.saveProgress([]);
if (progress.completed < progress.total && retryCount < this.retryCount) {
console.log(`\n还有未完成的测试,尝试重试 (${retryCount + 1}/${this.retryCount})...`);
retryCount++;
const delay = Math.min(this.batchInterval * Math.pow(2, retryCount), this.maxRetryDelay);
await new Promise(resolve => setTimeout(resolve, delay));
continue;
let retryCount = 0;
let lastProgress = null;
while (true) {
try {
const batch = this.getNextBatch();
if (!batch?.length) {
const progress = this.getTestProgress();
// 验证进度数据的有效性
if (!this.isValidProgress(progress)) {
throw new Error('进度数据无效');
}
if (progress.completed < progress.total && retryCount < this.retryCount) {
console.log(`\n还有未完成的测试,尝试重试 (${retryCount + 1}/${this.retryCount})...`);
retryCount++;
const delay = Math.min(this.batchInterval * Math.pow(2, retryCount), this.maxRetryDelay);
await new Promise(resolve => setTimeout(resolve, delay));
continue;
}
return progress;
}
return progress;
}
await this.runBatchTest(batch);
await this.updateTestProgress(batch);
await this.runBatchTest(batch);
await new Promise(resolve => setTimeout(resolve, this.batchInterval));
} catch (error) {
console.error('测试执行失败:', error);
if (error.message.includes('登录失败')) {
throw error; // 登录失败直接终止
// 验证进度是否正确更新
const currentProgress = this.getTestProgress();
if (lastProgress && currentProgress.completed <= lastProgress.completed) {
console.warn('警告: 进度可能未正确更新');
}
lastProgress = currentProgress;
await new Promise(resolve => setTimeout(resolve, this.batchInterval));
} catch (error) {
console.error('测试执行失败:', error);
if (error.message.includes('登录失败')) {
throw error; // 登录失败直接终止
}
// 其他错误继续下一批次
}
// 其他错误继续下一批次
}
} catch (error) {
console.error('运行所有测试失败:', error);
throw error;
}
}
/**
* 验证进度数据的有效性
* @param {Object} progress - 进度数据
* @returns {boolean} - 是否有效
* @private
*/
isValidProgress(progress) {
if (!progress || typeof progress !== 'object') return false;
const menuData = this.getMenuData();
if (!menuData?.length) return false;
return (
typeof progress.total === 'number' &&
typeof progress.completed === 'number' &&
typeof progress.remaining === 'number' &&
progress.total === menuData.length &&
progress.completed >= 0 &&
progress.completed <= progress.total &&
progress.remaining === (progress.total - progress.completed)
);
}
/**
* 获取测试进度信息
* @returns {Object} - 进度信息包含总数已完成数和剩余数
@ -264,15 +332,22 @@ class LongiTestService {
const menuData = this.getMenuData();
const progress = this.getProgress();
if (!menuData) {
if (!menuData?.length) {
console.warn('警告: 没有可用的菜单数据');
return {total: 0, completed: 0, remaining: 0};
}
return {
total: menuData.length,
completed: progress.length,
remaining: menuData.length - progress.length
};
const completed = progress.length;
const total = menuData.length;
const remaining = total - completed;
// 验证数据一致性
if (completed > total) {
console.error('错误: 完成数超过总数');
return {total, completed: total, remaining: 0};
}
return {total, completed, remaining};
} catch (error) {
console.error('获取测试进度失败:', error);
throw error;

View File

@ -0,0 +1,108 @@
const FileUtils = require('../utils/FileUtils');
/**
* 性能数据服务
* 负责记录和管理页面性能数据
*/
class PerformanceService {
constructor() {
this.performanceData = [];
this.performanceDataPath = process.env.PERFORMANCE_DATA_FILE_PATH || './test-results/performance-data.json';
}
/**
* 记录成功的页面加载性能数据
* @param {string} pageName 页面名称
* @param {number} startTime 开始时间
* @param {number} endTime 结束时间
*/
async recordSuccess(pageName, startTime, endTime) {
await this._recordPerformance({
pageName,
startTime,
endTime,
duration: endTime - startTime,
success: true,
errorMessage: null
});
}
/**
* 记录失败的页面加载性能数据
* @param {string} pageName 页面名称
* @param {number} startTime 开始时间
* @param {number} endTime 结束时间
* @param {string} errorMessage 错误信息
*/
async recordFailure(pageName, startTime, endTime, errorMessage) {
await this._recordPerformance({
pageName,
startTime,
endTime,
duration: endTime - startTime,
success: false,
errorMessage
});
}
/**
* 记录异常的页面加载性能数据
* @param {string} pageName 页面名称
* @param {number} startTime 开始时间
* @param {number} endTime 结束时间
* @param {Error} error 错误对象
*/
async recordError(pageName, startTime, endTime, error) {
await this._recordPerformance({
pageName,
startTime,
endTime,
duration: endTime - startTime,
success: false,
errorMessage: error.message
});
}
/**
* 内部方法记录性能数据
* @private
*/
async _recordPerformance(data) {
this.performanceData.push({
...data,
timestamp: new Date().toISOString()
});
await this.savePerformanceData();
}
/**
* 保存性能数据到文件
*/
async savePerformanceData() {
try {
await FileUtils.writeJsonFile(this.performanceDataPath, this.performanceData);
} catch (error) {
console.error('保存性能数据失败:', error);
}
}
/**
* 清空性能数据
*/
async clearPerformanceData() {
this.performanceData = [];
await this.savePerformanceData();
}
/**
* 获取性能数据
* @returns {Array} 性能数据数组
*/
getPerformanceData() {
return this.performanceData;
}
}
// 创建单例实例
const performanceService = new PerformanceService();
module.exports = performanceService;

View File

@ -2,7 +2,7 @@
* 文件操作工具类
* 提供文件读写目录创建等常用操作
*/
const fs = require('fs');
const fs = require('fs').promises;
const path = require('path');
class FileUtils {
@ -25,17 +25,72 @@ class FileUtils {
/**
* 确保目录存在如果不存在则创建
* @param {string} dirPath 目录路径
* @returns {boolean} 操作是否成功
*/
static ensureDirectoryExists(dirPath) {
static async ensureDirectoryExists(dirPath) {
try {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, {recursive: true});
console.log(`目录已创建: ${dirPath}`);
}
return true;
await fs.access(dirPath);
} catch {
await fs.mkdir(dirPath, { recursive: true });
}
}
/**
* 写入JSON文件
* @param {string} filePath 文件路径
* @param {Object} data 要写入的数据
*/
static async writeJsonFile(filePath, data) {
try {
// 确保目录存在
await this.ensureDirectoryExists(path.dirname(filePath));
// 写入文件
await fs.writeFile(filePath, JSON.stringify(data, null, 2), 'utf8');
} catch (error) {
console.error(`创建目录失败: ${dirPath}`, error);
console.error(`写入JSON文件失败 [${filePath}]:`, error);
throw error;
}
}
/**
* 读取JSON文件
* @param {string} filePath 文件路径
* @returns {Promise<Object>} 读取的数据
*/
static async readJsonFile(filePath) {
try {
const content = await fs.readFile(filePath, 'utf8');
return JSON.parse(content);
} catch (error) {
console.error(`读取JSON文件失败 [${filePath}]:`, error);
throw error;
}
}
/**
* 删除文件
* @param {string} filePath 文件路径
*/
static async deleteFile(filePath) {
try {
await fs.unlink(filePath);
} catch (error) {
if (error.code !== 'ENOENT') { // 如果错误不是"文件不存在"
console.error(`删除文件失败 [${filePath}]:`, error);
throw error;
}
}
}
/**
* 检查文件是否存在
* @param {string} filePath 文件路径
* @returns {Promise<boolean>} 文件是否存在
*/
static async fileExists(filePath) {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}

View File

@ -1 +1,7 @@
[]
[
1,
2,
3,
4,
5
]

View File

@ -1,34 +1,37 @@
// 加载环境变量
require('../../config/env');
const { test } = require('@playwright/test');
const {test} = require('@playwright/test');
const TestController = require('../../src/controllers/LongiTestController');
test.describe('测试所有隆基需求计划是否可用', () => {
test.describe('IBP系统菜单可访问性测试', () => {
let controller;
test.beforeAll(async () => {
controller = new TestController();
});
test('获取最新菜单数据', async () => {
test('应能成功获取系统所有可测试的菜单项', async () => {
const menuData = await controller.fetchAndSaveMenuData();
test.expect(menuData).toBeTruthy();
test.expect(menuData.length).toBeGreaterThan(0);
console.log(`✓ 成功收集 ${menuData.length} 个菜单项`);
});
test('访问并点击所有页面', async () => {
// 执行所有测试
await controller.runAllTests();
test('应能成功访问所有菜单页面', async () => {
// 执行所有测试并获取进度
const progress = await controller.runAllTests();
// 验证测试结果
const progress = controller.getTestProgress();
test.expect(progress).toBeTruthy();
test.expect(progress.total).toBeGreaterThan(0);
test.expect(progress.completed).toBe(progress.total);
test.expect(progress.remaining).toBe(0);
// 输出测试统计
console.log('\n测试完成');
console.log(`✓ 总计测试: ${progress.total} 个菜单`);
console.log(`✓ 成功完成: ${progress.completed} 个菜单`);
console.log(`✓ 成功率: ${((progress.completed / progress.total) * 100).toFixed(2)}%`);
console.log(`总菜单数: ${progress.total}`);
console.log(`完成数量: ${progress.completed}`);
console.log(`剩余数量: ${progress.remaining}`);
});
});