commit 0874f9f6cd921686e7a6538dfaada6a4d4b10a47 Author: dengqichen Date: Tue Mar 4 17:13:49 2025 +0800 第一次 diff --git a/.env.dev b/.env.dev new file mode 100644 index 0000000..02bf555 --- /dev/null +++ b/.env.dev @@ -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 \ No newline at end of file diff --git a/.env.uat b/.env.uat new file mode 100644 index 0000000..bc164eb --- /dev/null +++ b/.env.uat @@ -0,0 +1,2 @@ +# 基础URL +BASE_URL=https://ibp-uat.longi.com \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..75b8032 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/config/env.js b/config/env.js new file mode 100644 index 0000000..380a851 --- /dev/null +++ b/config/env.js @@ -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())); \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..e47dbff --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 0000000..280fc06 --- /dev/null +++ b/playwright.config.js @@ -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, + // }, +}); \ No newline at end of file diff --git a/scripts/run-longi-tests.js b/scripts/run-longi-tests.js new file mode 100644 index 0000000..07f1725 --- /dev/null +++ b/scripts/run-longi-tests.js @@ -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); +// } \ No newline at end of file diff --git a/scripts/run-tests.js b/scripts/run-tests.js new file mode 100644 index 0000000..5722f79 --- /dev/null +++ b/scripts/run-tests.js @@ -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 ', '指定浏览器 (chromium, firefox, webkit)', 'chromium') +// .option('-t, --test ', '指定测试文件或目录') +// .option('-h, --headless', '无头模式运行', false) +// .option('-r, --reporter ', '指定报告格式 (list, html, json)', 'list') +// .option('-p, --parallel ', '并行运行数量', '3') +// .option('-s, --screenshot', '失败时截图', false) +// .option('-v, --video', '录制视频', false) +// .option('-d, --debug', '调试模式', false) +// .option('-u, --ui', '使用UI模式', false) +// .option('-c, --config ', '指定配置文件路径') +// .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); +// } \ No newline at end of file diff --git a/tests/longi-ibp/check-page-normal.test.js b/tests/longi-ibp/check-page-normal.test.js new file mode 100644 index 0000000..2dfaf18 --- /dev/null +++ b/tests/longi-ibp/check-page-normal.test.js @@ -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); +}); diff --git a/tests/pages/LongiLoginPage.js b/tests/pages/LongiLoginPage.js new file mode 100644 index 0000000..91b6174 --- /dev/null +++ b/tests/pages/LongiLoginPage.js @@ -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} 是否成功点击登录按钮 + */ + 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} 登录是否成功 + */ + async isLoginSuccessful() { + // 登录成功后通常会重定向到其他页面 + const currentUrl = await this.getCurrentUrl(); + return currentUrl.includes('/dashboard'); + } + +} + +module.exports = LongiLoginPage; \ No newline at end of file diff --git a/tests/pages/LongiMainPage.js b/tests/pages/LongiMainPage.js new file mode 100644 index 0000000..c53658f --- /dev/null +++ b/tests/pages/LongiMainPage.js @@ -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} 菜单是否已展开 + */ + 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} + */ + 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} 是否执行了展开操作 + */ + 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} 菜单项数组 + */ + 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} 菜单路径 + */ + 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} 找到的菜单项或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} 找到的菜单项或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} 找到的菜单项或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; \ No newline at end of file diff --git a/tests/utils/BasePage.js b/tests/utils/BasePage.js new file mode 100644 index 0000000..18e46fa --- /dev/null +++ b/tests/utils/BasePage.js @@ -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} 元素定位器 + */ + 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} 元素文本 + */ + async getText(selector, options = {}) { + const element = await this.waitForElement(selector, options); + return element.textContent(); + } + + /** + * 检查元素是否可见 + * @param {string} selector 元素选择器 + * @param {Object} options 选项 + * @returns {Promise} 元素是否可见 + */ + 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} 页面标题 + */ + async getTitle() { + return this.page.title(); + } + + /** + * 获取当前URL + * @returns {Promise} 当前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} 属性值 + */ + async getAttribute(selector, attributeName) { + const element = await this.waitForElement(selector); + return element.getAttribute(attributeName); + } +} + +module.exports = BasePage; \ No newline at end of file diff --git a/tests/utils/FileUtils.js b/tests/utils/FileUtils.js new file mode 100644 index 0000000..a24dd30 --- /dev/null +++ b/tests/utils/FileUtils.js @@ -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; \ No newline at end of file diff --git a/tests/utils/env.js b/tests/utils/env.js new file mode 100644 index 0000000..955cfe8 --- /dev/null +++ b/tests/utils/env.js @@ -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 +}; \ No newline at end of file diff --git a/tests/utils/helpers.js b/tests/utils/helpers.js new file mode 100644 index 0000000..08ff8df --- /dev/null +++ b/tests/utils/helpers.js @@ -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} + */ +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 +}; \ No newline at end of file