第一次

This commit is contained in:
dengqichen 2025-03-04 17:13:49 +08:00
commit 0874f9f6cd
16 changed files with 1451 additions and 0 deletions

5
.env.dev Normal file
View File

@ -0,0 +1,5 @@
# 基础URL
BASE_URL=https://ibp-dev.longi.com
MENU_DATA_FILE_PATH=data/longi/menu-data.json
MENU_TIME_OUT=30000

2
.env.uat Normal file
View File

@ -0,0 +1,2 @@
# 基础URL
BASE_URL=https://ibp-uat.longi.com

41
.gitignore vendored Normal file
View File

@ -0,0 +1,41 @@
# Node.js
node_modules/
npm-debug.log
yarn-debug.log
yarn-error.log
package-lock.json
yarn.lock
# Playwright
/test-results/
/playwright-report/
/playwright/.cache/
/screenshots/
/videos/
/reports/
/allure-results/
/allure-report/
# 环境变量
.env
.env.local
.env.*.local
# 编辑器
.vscode/
.idea/
*.swp
*.swo
.DS_Store
# 日志
logs/
*.log
# 临时文件
tmp/
temp/
/node_modules/
/playwright-report/
/data/
/test-results/

0
README.md Normal file
View File

10
config/env.js Normal file
View File

@ -0,0 +1,10 @@
const path = require('path');
require('dotenv-flow').config({
node_env: process.env.NODE_ENV || 'dev',
path: path.resolve(process.cwd()),
default_node_env: 'dev'
});
console.log('Current NODE_ENV:', process.env.NODE_ENV);
// console.log('Loaded BASE_URL:', process.env.BASE_URL);
console.log('Environment files path:', path.resolve(process.cwd()));

36
package.json Normal file
View File

@ -0,0 +1,36 @@
{
"name": "playwright-automation",
"version": "1.0.0",
"description": "基于Playwright的自动化测试工具",
"main": "index.js",
"scripts": {
"test": "playwright test",
"test:ui": "playwright test --ui",
"report": "playwright show-report",
"codegen": "playwright codegen",
"debug": "playwright test --debug",
"test:longi:check-normal:dev": "cross-env NODE_ENV=dev playwright test tests/longi-ibp/check-page-normal.test.js --headed --project=chromium"
},
"keywords": [
"playwright",
"automation",
"testing",
"e2e"
],
"author": "",
"license": "MIT",
"dependencies": {
"@playwright/test": "^1.40.0",
"chalk": "^4.1.2",
"commander": "^11.1.0",
"dotenv": "^16.3.1",
"faker": "^5.5.3"
},
"devDependencies": {
"allure-playwright": "^2.9.2",
"cross-env": "^7.0.3",
"dotenv-flow": "^4.1.0",
"eslint": "^8.54.0",
"eslint-plugin-playwright": "^0.18.0"
}
}

71
playwright.config.js Normal file
View File

@ -0,0 +1,71 @@
// @ts-check
const {defineConfig, devices} = require('@playwright/test');
/**
* @see https://playwright.dev/docs/test-configuration
*/
module.exports = defineConfig({
testDir: './tests',
/* 测试超时时间 */
timeout: 120 * 1000,
/* 每个测试的预期状态 */
expect: {
/**
* 断言超时时间
*/
timeout: 15000
},
/* 测试运行并发数 */
fullyParallel: false,
/* 失败重试次数 */
retries: 0,
/* 测试报告相关 */
reporter: [
['html', {open: 'never'}],
['list']
],
/* 共享设置 */
use: {
/* 基础URL */
// baseURL: 'http://localhost:3000',
/* 收集测试追踪信息 */
trace: 'on-first-retry',
/* 自动截图 */
screenshot: 'only-on-failure',
/* 录制视频 */
video: 'on-first-retry',
},
/* 配置不同的浏览器项目 */
projects: [
{
name: 'chromium',
use: {...devices['Desktop Chrome']},
},
{
name: 'firefox',
use: {...devices['Desktop Firefox']},
},
{
name: 'webkit',
use: {...devices['Desktop Safari']},
},
/* 移动设备测试 */
{
name: 'Mobile Chrome',
use: {...devices['Pixel 5']},
},
{
name: 'Mobile Safari',
use: {...devices['iPhone 12']},
},
],
/* 本地开发服务器配置 */
// webServer: {
// command: 'npm run start',
// port: 3000,
// reuseExistingServer: !process.env.CI,
// },
});

View File

@ -0,0 +1,26 @@
// #!/usr/bin/env node
//
// /**
// * 龙蛟IBP系统测试运行脚本
// */
// const {execSync} = require('child_process');
// const chalk = require('chalk');
//
// console.log(chalk.blue('开始运行龙蛟IBP系统测试...'));
//
// // 默认运行最简单的测试
// const testFile = process.argv[2] || 'tests/longi-ibp/simple-login.test.js';
//
// // 构建命令 - 使用--project而不是--browser
// const command = `npx playwright test ${testFile} --headed --project=chromium`;
//
// console.log(chalk.yellow(`运行命令: ${command}`));
//
// try {
// // 执行命令
// execSync(command, {stdio: 'inherit'});
// console.log(chalk.green('测试完成!'));
// } catch (error) {
// console.error(chalk.red('测试执行失败:'), error.message);
// process.exit(1);
// }

91
scripts/run-tests.js Normal file
View File

@ -0,0 +1,91 @@
// #!/usr/bin/env node
//
// /**
// * 测试运行脚本
// * 提供命令行界面来运行测试
// */
// const {program} = require('commander');
// const {execSync} = require('child_process');
// const chalk = require('chalk');
// const path = require('path');
// const fs = require('fs');
//
// // 设置命令行选项
// program
// .version('1.0.0')
// .description('Playwright 自动化测试运行工具')
// .option('-b, --browser <browser>', '指定浏览器 (chromium, firefox, webkit)', 'chromium')
// .option('-t, --test <pattern>', '指定测试文件或目录')
// .option('-h, --headless', '无头模式运行', false)
// .option('-r, --reporter <reporter>', '指定报告格式 (list, html, json)', 'list')
// .option('-p, --parallel <number>', '并行运行数量', '3')
// .option('-s, --screenshot', '失败时截图', false)
// .option('-v, --video', '录制视频', false)
// .option('-d, --debug', '调试模式', false)
// .option('-u, --ui', '使用UI模式', false)
// .option('-c, --config <path>', '指定配置文件路径')
// .parse(process.argv);
//
// const options = program.opts();
//
// // 构建命令
// let command = 'npx playwright test';
//
// // 添加测试文件或目录
// if (options.test) {
// command += ` "${options.test}"`;
// }
//
// // 添加浏览器
// command += ` --project=${options.browser}`;
//
// // 添加报告格式
// if (options.reporter) {
// command += ` --reporter=${options.reporter}`;
// }
//
// // 添加并行数量
// if (options.parallel) {
// command += ` --workers=${options.parallel}`;
// }
//
// // 添加截图选项
// if (options.screenshot) {
// command += ' --screenshot=on';
// }
//
// // 添加视频选项
// if (options.video) {
// command += ' --video=on';
// }
//
// // 添加无头模式选项
// if (!options.headless) {
// command += ' --headed';
// }
//
// // 添加调试模式
// if (options.debug) {
// command += ' --debug';
// }
//
// // 添加UI模式
// if (options.ui) {
// command = 'npx playwright test --ui';
// }
//
// // 添加配置文件
// if (options.config) {
// command += ` --config="${options.config}"`;
// }
//
// console.log(chalk.blue('运行命令:'), chalk.yellow(command));
//
// try {
// // 执行命令
// execSync(command, {stdio: 'inherit'});
// console.log(chalk.green('测试完成!'));
// } catch (error) {
// console.error(chalk.red('测试执行失败:'), error.message);
// process.exit(1);
// }

View File

@ -0,0 +1,33 @@
require('../../config/env');
const {test, expect} = require('@playwright/test');
const LongiLoginPage = require('../pages/LongiLoginPage');
const LongiMainPage = require('../pages/LongiMainPage');
test('隆基登录', async ({page}) => {
// 1. 创建页面对象
const loginPage = new LongiLoginPage(page);
const mainPage = new LongiMainPage(page);
// 2. 导航到登录页面
await loginPage.navigateToLoginPage();
// 3. 等待页面加载完成
await page.waitForLoadState('networkidle');
// 4. 点击登录按钮 - 使用页面对象模型
const clickSuccess = await loginPage.clickLoginButton();
// 5. 验证点击是否成功
expect(clickSuccess, '验证是否登录成功').toBeTruthy();
let loginSuccess = await loginPage.isLoginSuccessful();
console.log(`登录状态: ${loginSuccess ? '成功' : '失败'}`);
// 10. 检查菜单数据文件是否存在
let menuItems = await mainPage.checkAndLoadMenuItems();
// 11. 使用菜单数据进行后续操作
console.log(`共有 ${menuItems.length} 个菜单项可用于测试`);
await mainPage.handleAllMenus(menuItems);
});

View File

@ -0,0 +1,85 @@
/**
* 龙蛟IBP系统登录页面对象模型
*/
const BasePage = require('../utils/BasePage');
const { getEnv } = require('../utils/env');
class LongiLoginPage extends BasePage {
constructor(page) {
super(page);
// 页面元素选择器
this.selectors = {
usernameInput: 'input[name="username"]', // 用户名输入框
passwordInput: 'input[name="passWord"]', // 密码输入框
captchaInput: 'input[placeholder*="验证码"]', // 验证码输入框
captchaImage: 'img[style*="width: 78px"]', // 验证码图片
loginButton: 'button.el-button--large.container-button', // 登录按钮
loginButtonByText: 'button:text("登录"):not(:text("SSO登录"))', // 通过文本定位登录按钮
loginButtonFirst: 'button:has-text("登录")', // 通过文本定位登录按钮(使用first()方法)
errorMessage: '.el-form-item__error, .el-form-item.is-error', // 错误消息
ssoLoginButton: 'button:has-text("SSO登录")' // SSO登录按钮
};
// 设置超时时间
this.timeout = 10000;
}
/**
* 导航到登录页面
*/
async navigateToLoginPage() {
console.log('当前使用的 BASE_URL:', process.env.BASE_URL);
await this.navigate(process.env.BASE_URL);
}
/**
* 点击登录按钮
* @returns {Promise<boolean>} 是否成功点击登录按钮
*/
async clickLoginButton() {
try {
// 方法1: 使用更精确的选择器,指定包含特定类的按钮
await this.click(this.selectors.loginButton);
console.log('使用 container-button 类选择器成功点击登录按钮');
await this.waitForPageLoad();
return true;
} catch (error) {
console.log('第一种方法失败,尝试备用方法...');
try {
// 方法2: 使用精确文本匹配
await this.page.locator(this.selectors.loginButtonByText).click();
console.log('使用精确文本匹配成功点击登录按钮');
await this.waitForPageLoad();
return true;
} catch (secondError) {
console.log('第二种方法也失败,尝试第三种方法...');
try {
// 方法3: 使用first()选择第一个匹配的按钮
await this.page.locator(this.selectors.loginButtonFirst).first().click();
console.log('使用first()方法成功点击登录按钮');
await this.waitForPageLoad();
return true;
} catch (thirdError) {
console.error('所有方法都失败,无法点击登录按钮', thirdError);
return false;
}
}
}
}
/**
* 检查登录是否成功
* @returns {Promise<boolean>} 登录是否成功
*/
async isLoginSuccessful() {
// 登录成功后通常会重定向到其他页面
const currentUrl = await this.getCurrentUrl();
return currentUrl.includes('/dashboard');
}
}
module.exports = LongiLoginPage;

View File

@ -0,0 +1,625 @@
/**
* 龙蛟IBP系统主页面对象模型
*/
const BasePage = require('../utils/BasePage');
const FileUtils = require('../utils/FileUtils');
class LongiMainPage extends BasePage {
/**
* 创建主页面对象
* @param {import('@playwright/test').Page} page Playwright页面对象
*/
constructor(page) {
super(page);
// 页面元素选择器
this.selectors = {
// 侧边导航菜单 - 使用更精确的选择器
sideNav: '.ly-side-nav, .el-menu', // 主菜单
menuToggle: '.hamburger-container, .fold-btn, button.hamburger, .vab-content .toggle-icon', // 菜单展开/收起按钮,提供多个可能的选择器
menuContainer: '.el-scrollbar__view > .el-menu', // 菜单容器
menuItems: '.el-sub-menu__title, .el-menu-item', // 菜单项
menuItemText: '.titleSpan', // 菜单项文本
firstLevelIndicator: '.menuIcon', // 一级菜单指示器
thirdLevelMenu: '.el-popper.is-light.el-popover .menuTitle.canClick', // 三级菜单项
thirdLevelIndicator: '.el-icon-arrow-right', // 三级菜单指示器(箭头图标)
subMenuIndicator: '.el-sub-menu__icon-arrow' // 子菜单指示器
};
// 设置超时时间
this.timeout = 10000;
}
/**
* 检查菜单是否已展开
* @returns {Promise<boolean>} 菜单是否已展开
*/
async isMenuExpanded() {
// 获取菜单元素
const sideNav = this.page.locator(this.selectors.sideNav).first();
// 检查菜单的位置
const leftValue = await sideNav.evaluate(el => {
const style = window.getComputedStyle(el);
console.log(`菜单现在的偏移量是:${style.left}`);
return style.left;
});
// 如果left是0px说明菜单已展开
return leftValue === '0px';
}
/**
* 点击展开菜单
* @returns {Promise<void>}
*/
async clickExpandMenu() {
try {
// 尝试查找菜单切换按钮
const toggleButton = this.page.locator(this.selectors.menuToggle).first();
// 检查按钮是否可见
const isVisible = await toggleButton.isVisible().catch(() => false);
if (!isVisible) {
console.log('菜单切换按钮不可见,尝试其他方法');
// 如果按钮不可见可以尝试其他方法比如键盘快捷键或直接修改DOM
return;
}
// 点击菜单切换按钮
await toggleButton.click();
console.log('已点击展开菜单');
} catch (error) {
console.error('点击展开菜单时出错:', error);
}
}
/**
* 检查并展开侧边菜单
* 如果菜单已收起则点击展开
* @returns {Promise<boolean>} 是否执行了展开操作
*/
async expandSideMenu() {
try {
// 检查菜单是否已展开
const isExpanded = await this.isMenuExpanded();
if (!isExpanded) {
console.log('菜单未展开,点击展开');
await this.clickExpandMenu();
return true;
} else {
console.log('菜单已经处于展开状态');
return false;
}
} catch (error) {
console.error('展开菜单时出错:', error);
return false;
}
}
/**
* 检查菜单数据文件是否存在并加载数据
*/
async checkAndLoadMenuItems() {
try {
// 加载JSON文件
const menuItems = FileUtils.loadFromJsonFile(process.env.MENU_DATA_FILE_PATH);
// 检查是否成功加载数据
if (menuItems && Array.isArray(menuItems) && menuItems.length > 0) {
console.log(`从文件 ${process.env.BASE_URL} 成功加载了 ${menuItems.length} 个菜单项`);
return menuItems;
} else {
await this.expandSideMenu();
return await this.findAndSaveMenuItems();
}
} catch (error) {
console.error(`检查并加载菜单项时出错: ${error}`);
return null;
}
}
/**
* 查找菜单项并保存到文件
*/
async findAndSaveMenuItems() {
try {
// 查找菜单项
const menuItems = await this.findMenuItems();
// 如果没有找到菜单项,则返回空数组
if (!menuItems || menuItems.length === 0) {
console.warn('未找到任何菜单项,无法保存到文件');
return [];
}
// 过滤掉不能序列化的element属性
const menuItemsForSave = menuItems.map(({element, ...rest}) => rest);
// 保存到文件
FileUtils.saveToJsonFile(menuItemsForSave, process.env.MENU_DATA_FILE_PATH);
console.log(`已找到并保存 ${menuItems.length} 个菜单项到文件: ${process.env.MENU_DATA_FILE_PATH}`);
return menuItems;
} catch (error) {
console.error('查找并保存菜单项时出错:', error);
return [];
}
}
/**
* 查找所有菜单项
* @returns {Promise<Array>} 菜单项数组
*/
async findMenuItems() {
try {
console.log('开始查找菜单项...');
// 等待菜单加载完成
await this.page.waitForSelector(this.selectors.sideNav, {
timeout: parseInt(process.env.MENU_TIME_OUT || '30000', 10)
});
// 获取所有菜单项
const items = await this.page.locator(this.selectors.menuItems).all();
console.log(`找到 ${items.length} 个菜单项元素`);
const menuItems = [];
// 处理每个菜单项
for (let i = 0; i < items.length; i++) {
const item = items[i];
//过滤一级菜单
let isTopMenu = (await item.locator(this.selectors.firstLevelIndicator).count()) > 0;
if (isTopMenu) {
continue;
}
// 获取菜单项文本
const text = await item.textContent();
// 检查是否是可见的菜单项
const isVisible = await item.isVisible();
if (isVisible && text.trim()) {
// 检查是否有子菜单指示器
const hasSubMenuIndicator = await item.evaluate(el => {
return el.querySelector('.el-submenu__icon-arrow') !== null ||
el.querySelector('.el-icon-arrow-down') !== null;
});
// 检查是否有三级菜单指示器
const hasThirdLevelIndicator = await item.evaluate(el => {
// 检查是否有特定的三级菜单指示器
return el.classList.contains('is-opened') ||
el.querySelector('.third-level-menu') !== null ||
el.querySelector('.el-menu--inline') !== null;
});
// 检查是否是子菜单标题
const isSubMenuTitle = await item.evaluate(el => el.classList.contains('el-sub-menu__title'));
// 综合判断是否有三级菜单
const hasThirdMenu = isSubMenuTitle || hasSubMenuIndicator || hasThirdLevelIndicator;
console.log(`菜单项 "${text.trim()}" ${hasThirdMenu ? '有' : '没有'}三级菜单 (通过DOM结构判断)`);
// 生成唯一标识符,结合索引和文本
const uniqueId = `menu_${i}_${text.trim().replace(/\s+/g, '_')}`;
// 获取菜单路径
const menuPath = await this.getMenuPath(item);
menuItems.push({
index: i,
text: text.trim(),
element: item,
hasThirdMenu: hasThirdMenu,
uniqueId: uniqueId,
// 添加路径信息,帮助识别菜单层级
path: menuPath
});
}
}
console.log(`🔍 找到 ${menuItems.length} 个可测试的菜单项`);
return menuItems;
} catch (error) {
console.error('查找菜单项时出错:', error);
return [];
}
}
/**
* 获取菜单项的路径信息
* @param {Object} menuItem 菜单项元素
* @returns {Promise<string>} 菜单路径
*/
async getMenuPath(menuItem) {
try {
// 尝试获取父级菜单的文本
const parentText = await menuItem.evaluate(el => {
// 查找最近的父级菜单项
const parent = el.closest('.el-submenu');
if (parent) {
const parentTitle = parent.querySelector('.el-submenu__title');
if (parentTitle) {
const titleSpan = parentTitle.querySelector('.titleSpan');
return titleSpan ? titleSpan.textContent.trim() : parentTitle.textContent.trim();
}
}
return '';
});
const itemText = await menuItem.textContent();
if (parentText) {
return `${parentText} > ${itemText}`;
}
return itemText;
} catch (error) {
console.error('获取菜单路径时出错:', error);
return '';
}
}
/**
* 通过索引查找菜单项
* @param {number} index 菜单项索引
* @param {Array} menuItems 菜单项数组如果未提供则会调用findMenuItems获取
* @returns {Promise<Object|null>} 找到的菜单项或null
*/
async findMenuItemByIndex(index, menuItems = null) {
try {
// 如果未提供菜单项数组,则获取
if (!menuItems) {
menuItems = await this.findMenuItems();
}
// 查找指定索引的菜单项
const menuItem = menuItems.find(item => item.index === index);
if (menuItem) {
console.log(`通过索引 ${index} 找到菜单项: "${menuItem.text}"`);
return menuItem;
} else {
console.log(`未找到索引为 ${index} 的菜单项`);
return null;
}
} catch (error) {
console.error(`通过索引查找菜单项时出错: ${error}`);
return null;
}
}
/**
* 通过文本和可选的索引查找菜单项
* 当有多个同名菜单项时可以通过指定occurrence来选择第几个匹配项
* @param {string} text 菜单项文本
* @param {number} occurrence 第几个匹配项从0开始计数默认为0第一个
* @param {Array} menuItems 菜单项数组如果未提供则会调用findMenuItems获取
* @returns {Promise<Object|null>} 找到的菜单项或null
*/
async findMenuItemByText(text, occurrence = 0, menuItems = null) {
try {
// 如果未提供菜单项数组,则获取
if (!menuItems) {
menuItems = await this.findMenuItems();
}
// 查找所有匹配文本的菜单项
const matchingItems = menuItems.filter(item => item.text === text);
if (matchingItems.length > 0) {
if (occurrence < matchingItems.length) {
const menuItem = matchingItems[occurrence];
console.log(`通过文本 "${text}" 找到第 ${occurrence + 1} 个菜单项,索引为 ${menuItem.index}`);
return menuItem;
} else {
console.log(`未找到第 ${occurrence + 1} 个文本为 "${text}" 的菜单项,只有 ${matchingItems.length} 个匹配项`);
return null;
}
} else {
console.log(`未找到文本为 "${text}" 的菜单项`);
return null;
}
} catch (error) {
console.error(`通过文本查找菜单项时出错: ${error}`);
return null;
}
}
/**
* 通过唯一ID查找菜单项
* @param {string} uniqueId 菜单项的唯一ID
* @param {Array} menuItems 菜单项数组如果未提供则会调用findMenuItems获取
* @returns {Promise<Object|null>} 找到的菜单项或null
*/
async findMenuItemByUniqueId(uniqueId, menuItems = null) {
try {
// 如果未提供菜单项数组,则获取
if (!menuItems) {
menuItems = await this.findMenuItems();
}
// 查找指定唯一ID的菜单项
const menuItem = menuItems.find(item => item.uniqueId === uniqueId);
if (menuItem) {
console.log(`通过唯一ID ${uniqueId} 找到菜单项: "${menuItem.text}"`);
return menuItem;
} else {
console.log(`未找到唯一ID为 ${uniqueId} 的菜单项`);
return null;
}
} catch (error) {
console.error(`通过唯一ID查找菜单项时出错: ${error}`);
return null;
}
}
/**
* 检查是否存在三级菜单并进行相应处理
* @param {Object} menuItem 菜单项对象包含索引文本和元素
* @param {number} timeout 超时时间毫秒默认30秒
* @returns {Promise<{hasThirdMenu: boolean, thirdMenuItems: Array}>} 是否有三级菜单及三级菜单项数组
*/
async checkForThirdLevelMenu(menuItem, timeout = 30000) {
try {
console.log(`点击菜单项 "${menuItem.text}" 检查三级菜单...`);
// 点击菜单项,触发三级菜单弹出
await menuItem.element.click();
// 等待弹出动画完成,增加等待时间
await this.page.waitForTimeout(2000);
// 检查三级菜单
const thirdMenuSelector = this.selectors.thirdLevelMenu;
// 尝试等待三级菜单出现,但不抛出错误
const hasThirdMenu = await this.page.locator(thirdMenuSelector).count().then(count => count > 0).catch(() => false);
// 如果没有立即找到,再等待一段时间再次检查
let thirdMenuCount = 0;
if (hasThirdMenu) {
thirdMenuCount = await this.page.locator(thirdMenuSelector).count();
} else {
// 再等待一段时间,有些菜单可能加载较慢
await this.page.waitForTimeout(1000);
thirdMenuCount = await this.page.locator(thirdMenuSelector).count();
}
console.log(`菜单项 "${menuItem.text}" ${thirdMenuCount > 0 ? '有' : '没有'}三级菜单,找到 ${thirdMenuCount} 个三级菜单项`);
// 如果没有三级菜单,直接返回
if (thirdMenuCount === 0) {
return {hasThirdMenu: false, thirdMenuItems: []};
}
// 收集三级菜单项
const thirdMenuItems = [];
for (let i = 0; i < thirdMenuCount; i++) {
const item = this.page.locator(thirdMenuSelector).nth(i);
const text = await item.textContent();
thirdMenuItems.push({
index: i,
text: text.trim(),
element: item
});
}
// 输出三级菜单项文本
const menuTexts = thirdMenuItems.map(item => item.text).join(', ');
console.log(`三级菜单项: ${menuTexts}`);
return {hasThirdMenu: true, thirdMenuItems};
} catch (error) {
console.error(`检查三级菜单时出错: ${error}`);
return {hasThirdMenu: false, thirdMenuItems: []};
}
}
/**
* 处理菜单项包括检查和处理可能的三级菜单
* @param {Object} menuItem 菜单项对象包含索引文本和元素以及hasThirdMenu标志
* @param {Function} processWithThirdMenu 处理有三级菜单情况的回调函数
* @param {Function} processWithoutThirdMenu 处理没有三级菜单情况的回调函数
* @returns {Promise<{hasThirdMenu: boolean, thirdMenuItems: Array}>} 处理结果
*/
async processMenuItem(menuItem, processWithThirdMenu = null, processWithoutThirdMenu = null) {
try {
console.log(`处理菜单项: "${menuItem.text}"`);
// 使用菜单项中的hasThirdMenu字段
const hasThirdMenu = menuItem.hasThirdMenu;
// 点击菜单项
await menuItem.element.click();
await this.page.waitForTimeout(1000); // 等待可能的动画完成
let thirdMenuItems = [];
// 如果有三级菜单,获取三级菜单项
if (hasThirdMenu) {
const thirdMenuSelector = this.selectors.thirdLevelMenu;
const thirdMenuCount = await this.page.locator(thirdMenuSelector).count();
console.log(`菜单项 "${menuItem.text}" 有三级菜单,找到 ${thirdMenuCount} 个三级菜单项`);
// 收集三级菜单项
for (let i = 0; i < thirdMenuCount; i++) {
const item = this.page.locator(thirdMenuSelector).nth(i);
const text = await item.textContent();
thirdMenuItems.push({
index: i,
text: text.trim(),
element: item
});
}
// 输出三级菜单项文本
const menuTexts = thirdMenuItems.map(item => item.text).join(', ');
console.log(`三级菜单项: ${menuTexts}`);
} else {
console.log(`菜单项 "${menuItem.text}" 没有三级菜单`);
}
// 根据是否有三级菜单调用相应的处理函数
if (hasThirdMenu && processWithThirdMenu) {
await processWithThirdMenu(menuItem, thirdMenuItems);
} else if (!hasThirdMenu && processWithoutThirdMenu) {
await processWithoutThirdMenu(menuItem);
}
return {hasThirdMenu, thirdMenuItems};
} catch (error) {
console.error(`处理菜单项时出错: ${error}`);
return {hasThirdMenu: false, thirdMenuItems: []};
}
}
async handleAllMenus(menuItems) {
for (let i = 0; i < menuItems.length; i++) {
await this.handleSingleMenu(menuItems[i]);
}
}
async handleSingleMenu(menu) {
await this.expandSideMenu();
if (menu.hasThirdMenu) {
await this.handleThreeLevelMenu(menu);
}
}
/**
* 等待页面加载完成包括检查加载遮罩和错误提示
* @param {Object} menu 菜单对象包含text等信息
* @param {string} [subMenuText] 子菜单文本可选
* @returns {Promise<{success: boolean, error: string|null}>}
*/
async waitForPageLoadWithRetry(menu, subMenuText = '') {
const pageName = subMenuText ? `${menu.text} > ${subMenuText}` : menu.text;
console.log(`等待页面 ${pageName} 数据加载...`);
let retryCount = 0;
const maxRetries = 30;
const retryInterval = 500;
while (retryCount < maxRetries) {
try {
// 检查是否存在加载遮罩
const hasLoadingMask = await this.page.locator('.el-loading-mask').count() > 0;
// 检查是否存在错误提示
const hasErrorBox = await this.page.locator('.el-message-box__message').count() > 0;
const hasErrorMessage = await this.page.locator('.el-message--error').count() > 0;
// 如果存在错误提示,立即返回错误状态
if (hasErrorBox || hasErrorMessage) {
console.log('页面加载出现错误');
let errorMessage = '';
if (hasErrorBox) {
errorMessage = await this.page.locator('.el-message-box__message').textContent();
} else if (hasErrorMessage) {
errorMessage = await this.page.locator('.el-message--error').textContent();
}
return {
success: false,
error: `页面 ${pageName} 加载出现错误: ${errorMessage}`
};
}
// 如果还有加载遮罩,继续等待
if (hasLoadingMask) {
retryCount++;
console.log(`等待加载中... (${retryCount}/${maxRetries})`);
await this.page.waitForTimeout(retryInterval);
continue;
}
// 如果没有加载遮罩也没有错误,说明加载完成
console.log('页面数据加载完成');
await this.page.waitForTimeout(1000); // 额外等待一秒确保页面稳定
return {
success: true,
error: null
};
} catch (error) {
console.error(`检查页面加载状态时出错: ${error}`);
retryCount++;
await this.page.waitForTimeout(retryInterval);
}
}
// 超过最大重试次数
console.log('页面加载超时,继续执行...');
return {
success: false,
error: `页面 ${pageName} 加载超时 (${maxRetries} 次重试)`
};
}
async handleThreeLevelMenu(menu) {
try {
// 使用下标定位菜单元素
const menuElement = await this.page.locator(this.selectors.menuItems).nth(menu.index);
// 首次点击菜单项显示三级菜单
await menuElement.click();
await this.page.waitForTimeout(1000);
// 获取所有三级菜单项的文本和数量信息
const thirdMenuItems = await this.page.locator(this.selectors.thirdLevelMenu).all();
const totalItems = thirdMenuItems.length;
console.log(`${menu.text} 包含 ${totalItems} 个三级菜单`);
// 存储所有三级菜单的文本,因为后续重新获取元素时会需要
const thirdMenuTexts = [];
for (const item of thirdMenuItems) {
thirdMenuTexts.push(await item.textContent());
}
// 处理每个三级菜单项
for (let i = 0; i < totalItems; i++) {
const progress = (((i + 1) / totalItems) * 100).toFixed(1);
const currentMenuText = thirdMenuTexts[i];
console.log(`\n🔸 处理三级菜单 [${i + 1}/${totalItems}] (${progress}%): ${menu.text} > ${currentMenuText}`);
// 只有在处理第二个及以后的菜单项时,才需要重新点击二级菜单
if (i > 0) {
await menuElement.click();
await this.page.waitForTimeout(1000);
}
// 重新获取当前要点击的三级菜单项
const currentThirdMenuItem = await this.page.locator(this.selectors.thirdLevelMenu)
.filter({ hasText: currentMenuText })
.first();
// 点击当前三级菜单项
await currentThirdMenuItem.click();
// 使用新的等待页面加载方法传入menu对象和当前三级菜单文本
const loadResult = await this.waitForPageLoadWithRetry(menu, currentMenuText);
if (!loadResult.success) {
console.warn(loadResult.error);
}
// 等待一段时间后继续处理下一个三级菜单
await this.page.waitForTimeout(1000);
}
console.log(`✅ 完成菜单 "${menu.text}" 的所有三级菜单处理 (100%)`);
} catch (error) {
console.error(`处理三级菜单时出错: ${error}`);
}
}
}
module.exports = LongiMainPage;

150
tests/utils/BasePage.js Normal file
View File

@ -0,0 +1,150 @@
/**
* 页面对象模型基类
* 提供所有页面共用的方法和属性
*/
class BasePage {
/**
* 创建页面对象
* @param {import('@playwright/test').Page} page Playwright页面对象
*/
constructor(page) {
this.page = page;
this.timeout = 30000; // 默认使用配置中的元素超时时间
}
/**
* 导航到指定URL
* @param {string} url 目标URL
*/
async navigate(url) {
await this.page.goto(url, {
waitUntil: 'networkidle',
timeout: 30000
});
}
/**
* 等待元素可见
* @param {string} selector 元素选择器
* @param {Object} options 选项
* @returns {Promise<import('@playwright/test').Locator>} 元素定位器
*/
async waitForElement(selector, options = {}) {
const element = this.page.locator(selector);
await element.waitFor({state: 'visible', timeout: options.timeout || this.timeout});
return element;
}
/**
* 点击元素
* @param {string} selector 元素选择器
* @param {Object} options 选项
*/
async click(selector, options = {}) {
const element = await this.waitForElement(selector, options);
await element.click(options);
}
/**
* 填写表单字段
* @param {string} selector 元素选择器
* @param {string} value 要填写的值
* @param {Object} options 选项
*/
async fill(selector, value, options = {}) {
const element = await this.waitForElement(selector, options);
await element.fill(value);
}
/**
* 获取元素文本
* @param {string} selector 元素选择器
* @param {Object} options 选项
* @returns {Promise<string>} 元素文本
*/
async getText(selector, options = {}) {
const element = await this.waitForElement(selector, options);
return element.textContent();
}
/**
* 检查元素是否可见
* @param {string} selector 元素选择器
* @param {Object} options 选项
* @returns {Promise<boolean>} 元素是否可见
*/
async isVisible(selector, options = {}) {
try {
await this.waitForElement(selector, {
timeout: options.timeout || 1000
});
return true;
} catch (error) {
return false;
}
}
/**
* 等待页面加载完成
*/
async waitForPageLoad() {
await this.page.waitForLoadState('networkidle', {
timeout: 30000
});
}
/**
* 获取页面标题
* @returns {Promise<string>} 页面标题
*/
async getTitle() {
return this.page.title();
}
/**
* 获取当前URL
* @returns {Promise<string>} 当前URL
*/
async getCurrentUrl() {
return this.page.url();
}
/**
* 截取页面截图
* @param {string} name 截图名称
* @param {Object} options 截图选项
*/
async takeScreenshot(name, options = {}) {
const screenshotPath = options.path || `./screenshots/${name}_${Date.now()}.png`;
await this.page.screenshot({
path: screenshotPath,
fullPage: options.fullPage || false,
...options
});
return screenshotPath;
}
/**
* 选择下拉菜单选项
* @param {string} selector 下拉菜单选择器
* @param {string} value 要选择的值
*/
async selectOption(selector, value) {
const element = await this.waitForElement(selector);
await element.selectOption(value);
}
/**
* 获取元素属性值
* @param {string} selector 元素选择器
* @param {string} attributeName 属性名
* @returns {Promise<string>} 属性值
*/
async getAttribute(selector, attributeName) {
const element = await this.waitForElement(selector);
return element.getAttribute(attributeName);
}
}
module.exports = BasePage;

145
tests/utils/FileUtils.js Normal file
View File

@ -0,0 +1,145 @@
/**
* 文件操作工具类
* 提供文件读写目录创建等常用操作
*/
const fs = require('fs');
const path = require('path');
class FileUtils {
/**
* 获取数据文件的完整路径
* @param {string} fileName 文件名
* @param {string} baseDir 基础目录 (默认为 'data')
* @param {string} subDir 子目录名
* @returns {string} 完整的文件路径
*/
static getDataFilePath(fileName, baseDir = 'data', subDir = '') {
const parts = [process.cwd(), baseDir];
if (subDir) {
parts.push(subDir);
}
parts.push(fileName);
return path.join(...parts);
}
/**
* 确保目录存在如果不存在则创建
* @param {string} dirPath 目录路径
* @returns {boolean} 操作是否成功
*/
static ensureDirectoryExists(dirPath) {
try {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
console.log(`目录已创建: ${dirPath}`);
}
return true;
} catch (error) {
console.error(`创建目录失败: ${dirPath}`, error);
return false;
}
}
/**
* 保存对象到JSON文件
* @param {Object} data 要保存的数据对象
* @param {string} filePath 文件路径
* @param {Object} options 选项
* @param {boolean} options.pretty 是否美化JSON (默认: true)
* @param {string} options.encoding 文件编码 (默认: 'utf8')
* @returns {boolean} 操作是否成功
*/
static saveToJsonFile(data, filePath, options = {}) {
try {
const { pretty = true, encoding = 'utf8' } = options;
const indent = pretty ? 2 : 0;
// 确保目录存在
const dirPath = path.dirname(filePath);
this.ensureDirectoryExists(dirPath);
// 将对象转换为JSON字符串并写入文件
fs.writeFileSync(filePath, JSON.stringify(data, null, indent), encoding);
console.log(`数据已保存到: ${filePath}`);
return true;
} catch (error) {
console.error(`保存JSON文件失败: ${filePath}`, error);
return false;
}
}
/**
* 从JSON文件加载对象
* @param {string} filePath 文件路径
* @param {Object} options 选项
* @param {string} options.encoding 文件编码 (默认: 'utf8')
* @returns {Object|null} 加载的对象如果失败则返回null
*/
static loadFromJsonFile(filePath, options = {}) {
try {
const { encoding = 'utf8' } = options;
if (!fs.existsSync(filePath)) {
console.error(`文件不存在: ${filePath}`);
return null;
}
const data = fs.readFileSync(filePath, encoding);
return JSON.parse(data);
} catch (error) {
console.error(`加载JSON文件失败: ${filePath}`, error);
return null;
}
}
/**
* 保存文本到文件
* @param {string} text 要保存的文本
* @param {string} filePath 文件路径
* @param {Object} options 选项
* @param {string} options.encoding 文件编码 (默认: 'utf8')
* @returns {boolean} 操作是否成功
*/
static saveTextToFile(text, filePath, options = {}) {
try {
const { encoding = 'utf8' } = options;
// 确保目录存在
const dirPath = path.dirname(filePath);
this.ensureDirectoryExists(dirPath);
// 写入文件
fs.writeFileSync(filePath, text, encoding);
console.log(`文本已保存到: ${filePath}`);
return true;
} catch (error) {
console.error(`保存文本文件失败: ${filePath}`, error);
return false;
}
}
/**
* 从文件加载文本
* @param {string} filePath 文件路径
* @param {Object} options 选项
* @param {string} options.encoding 文件编码 (默认: 'utf8')
* @returns {string|null} 加载的文本如果失败则返回null
*/
static loadTextFromFile(filePath, options = {}) {
try {
const { encoding = 'utf8' } = options;
if (!fs.existsSync(filePath)) {
console.error(`文件不存在: ${filePath}`);
return null;
}
return fs.readFileSync(filePath, encoding);
} catch (error) {
console.error(`加载文本文件失败: ${filePath}`, error);
return null;
}
}
}
module.exports = FileUtils;

45
tests/utils/env.js Normal file
View File

@ -0,0 +1,45 @@
/**
* 环境变量工具类
* 提供环境变量的读取和类型转换功能
*/
/**
* 获取环境变量值
* @param {string} key 环境变量名
* @param {string} defaultValue 默认值
* @returns {string} 环境变量值或默认值
*/
function getEnv(key, defaultValue = '') {
return process.env[key] || defaultValue;
}
/**
* 获取布尔类型的环境变量值
* @param {string} key 环境变量名
* @param {boolean} defaultValue 默认值
* @returns {boolean} 环境变量的布尔值
*/
function getBoolEnv(key, defaultValue = false) {
const value = process.env[key];
if (value === undefined) return defaultValue;
return value.toLowerCase() === 'true';
}
/**
* 获取数字类型的环境变量值
* @param {string} key 环境变量名
* @param {number} defaultValue 默认值
* @returns {number} 环境变量的数字值
*/
function getNumEnv(key, defaultValue = 0) {
const value = process.env[key];
if (value === undefined) return defaultValue;
const num = Number(value);
return isNaN(num) ? defaultValue : num;
}
module.exports = {
getEnv,
getBoolEnv,
getNumEnv
};

86
tests/utils/helpers.js Normal file
View File

@ -0,0 +1,86 @@
/**
* 测试辅助函数集合
*/
const fs = require('fs');
const path = require('path');
const faker = require('faker');
/**
* 生成随机用户数据
* @returns {Object} 包含随机用户信息的对象
*/
function generateRandomUser() {
return {
username: faker.internet.userName(),
email: faker.internet.email(),
password: faker.internet.password(10),
firstName: faker.name.firstName(),
lastName: faker.name.lastName(),
phone: faker.phone.phoneNumber(),
address: {
street: faker.address.streetAddress(),
city: faker.address.city(),
zipCode: faker.address.zipCode(),
country: faker.address.country()
}
};
}
/**
* 等待指定时间
* @param {number} ms 等待的毫秒数
* @returns {Promise<void>}
*/
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* 从文件加载测试数据
* @param {string} filePath 数据文件路径
* @returns {Object} 测试数据对象
*/
function loadTestData(filePath) {
const fullPath = path.resolve(__dirname, '../../data', filePath);
try {
const data = fs.readFileSync(fullPath, 'utf8');
return JSON.parse(data);
} catch (error) {
console.error(`Error loading test data from ${fullPath}:`, error);
return {};
}
}
/**
* 生成唯一ID
* @param {string} prefix ID前缀
* @returns {string} 唯一ID
*/
function generateUniqueId(prefix = 'test') {
return `${prefix}_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
}
/**
* 格式化日期
* @param {Date} date 日期对象
* @param {string} format 格式字符串
* @returns {string} 格式化后的日期字符串
*/
function formatDate(date = new Date(), format = 'YYYY-MM-DD') {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return format
.replace('YYYY', year)
.replace('MM', month)
.replace('DD', day);
}
module.exports = {
generateRandomUser,
sleep,
loadTestData,
generateUniqueId,
formatDate
};