feat(admin): 添加用户管理相关文件
添加用户管理视图、API和状态管理文件
This commit is contained in:
@@ -0,0 +1,733 @@
|
||||
# E2E测试工具使用指南
|
||||
|
||||
## 概述
|
||||
|
||||
本E2E测试工具是一个基于Playwright的可复用端到端测试框架,提供了模块化的测试用例编写能力、统一的测试环境配置、常用测试操作的封装与复用,以及清晰的测试报告与日志输出能力。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
e2e/
|
||||
├── core/ # 核心模块
|
||||
│ ├── test-config.ts # 测试配置管理
|
||||
│ ├── test-data.ts # 测试数据生成器
|
||||
│ ├── test-logger.ts # 测试日志记录器
|
||||
│ └── test-reporter.ts # 测试报告生成器
|
||||
├── pages/ # 页面对象模型
|
||||
│ ├── base-page.ts # 基础页面类
|
||||
│ ├── login-page.ts # 登录页面
|
||||
│ ├── dashboard-page.ts # 仪表盘页面
|
||||
│ ├── user-management-page.ts # 用户管理页面
|
||||
│ ├── role-management-page.ts # 角色管理页面
|
||||
│ └── menu-management-page.ts # 菜单管理页面
|
||||
├── helpers/ # 测试辅助工具
|
||||
│ ├── screenshot-helper.ts # 截图辅助工具
|
||||
│ ├── form-helper.ts # 表单辅助工具
|
||||
│ └── table-helper.ts # 表格辅助工具
|
||||
├── fixtures/ # 测试夹具
|
||||
├── utils/ # 工具函数
|
||||
│ └── common-utils.ts # 通用工具函数
|
||||
├── constants/ # 常量定义
|
||||
│ └── index.ts # 常量集合
|
||||
├── examples/ # 示例测试
|
||||
│ └── complete-example.spec.ts
|
||||
├── test-fixtures.ts # Playwright测试夹具
|
||||
└── mock-manager.ts # Mock服务管理器
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 安装依赖
|
||||
|
||||
```bash
|
||||
npm install --save-dev @playwright/test
|
||||
```
|
||||
|
||||
### 2. 配置测试环境
|
||||
|
||||
在项目根目录创建 `.env.e2e` 文件:
|
||||
|
||||
```env
|
||||
# E2E测试环境配置
|
||||
E2E_ENV=local
|
||||
E2E_BASE_URL=http://localhost:5173
|
||||
E2E_MOCK_ENABLED=true
|
||||
E2E_MOCK_MODE=full
|
||||
```
|
||||
|
||||
### 3. 编写测试用例
|
||||
|
||||
使用提供的测试夹具编写测试用例:
|
||||
|
||||
```typescript
|
||||
import { test, expect } from './test-fixtures';
|
||||
|
||||
test.describe('登录功能测试', () => {
|
||||
test('成功登录', async ({ pageObjects, testData, testLogger }) => {
|
||||
testLogger.startTest('成功登录');
|
||||
|
||||
try {
|
||||
await pageObjects.loginPage.navigate();
|
||||
await pageObjects.loginPage.login(testData.admin.username, testData.admin.password);
|
||||
|
||||
await pageObjects.dashboardPage.waitForLoad();
|
||||
|
||||
const pageTitle = await pageObjects.dashboardPage.getPageTitle();
|
||||
expect(pageTitle).toContain('仪表盘');
|
||||
|
||||
testLogger.endTest('成功登录', 'passed');
|
||||
} catch (error) {
|
||||
testLogger.endTest('成功登录', 'failed', error as Error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 4. 运行测试
|
||||
|
||||
```bash
|
||||
# 运行所有测试
|
||||
npm run test:e2e
|
||||
|
||||
# 运行特定测试文件
|
||||
npx playwright test e2e/examples/complete-example.spec.ts
|
||||
|
||||
# 运行特定测试用例
|
||||
npx playwright test -g "成功登录"
|
||||
|
||||
# 调试模式运行
|
||||
npx playwright test --debug
|
||||
```
|
||||
|
||||
## 核心功能
|
||||
|
||||
### 1. 测试配置管理
|
||||
|
||||
`test-config.ts` 提供了统一的测试环境配置管理:
|
||||
|
||||
```typescript
|
||||
import { testConfig } from './core/test-config';
|
||||
|
||||
// 获取当前环境配置
|
||||
const env = testConfig.getEnvironment();
|
||||
console.log(env.name); // 环境名称
|
||||
console.log(env.baseURL); // 基础URL
|
||||
console.log(env.mockEnabled); // Mock是否启用
|
||||
console.log(env.timeout); // 超时配置
|
||||
|
||||
// 切换环境
|
||||
testConfig.setEnvironment('dev');
|
||||
|
||||
// 获取特定环境配置
|
||||
const devConfig = testConfig.getEnvironment('dev');
|
||||
```
|
||||
|
||||
### 2. 测试数据生成
|
||||
|
||||
`test-data.ts` 提供了测试数据生成器:
|
||||
|
||||
```typescript
|
||||
import { testDataGenerator } from './core/test-data';
|
||||
|
||||
// 生成用户数据
|
||||
const userData = testDataGenerator.generateUserData({
|
||||
username: 'testuser',
|
||||
email: 'test@example.com',
|
||||
status: 'active'
|
||||
});
|
||||
|
||||
// 生成角色数据
|
||||
const roleData = testDataGenerator.generateRoleData({
|
||||
roleName: '测试角色',
|
||||
roleCode: 'test_role',
|
||||
status: 1
|
||||
});
|
||||
|
||||
// 生成菜单数据
|
||||
const menuData = testDataGenerator.generateMenuData({
|
||||
menuName: '测试菜单',
|
||||
menuType: 1,
|
||||
path: '/test',
|
||||
status: 0
|
||||
});
|
||||
|
||||
// 生成权限数据
|
||||
const permissionData = testDataGenerator.generatePermissionData({
|
||||
permissionName: '测试权限',
|
||||
permissionCode: 'test:permission',
|
||||
permissionType: 'button'
|
||||
});
|
||||
```
|
||||
|
||||
### 3. 测试日志记录
|
||||
|
||||
`test-logger.ts` 提供了结构化的测试日志记录:
|
||||
|
||||
```typescript
|
||||
import { testLogger } from './core/test-logger';
|
||||
|
||||
// 开始测试
|
||||
testLogger.startTest('测试名称');
|
||||
|
||||
// 开始测试步骤
|
||||
testLogger.startStep('步骤名称');
|
||||
|
||||
// 记录不同级别的日志
|
||||
testLogger.debug('调试信息');
|
||||
testLogger.info('普通信息');
|
||||
testLogger.warn('警告信息');
|
||||
testLogger.error('错误信息', error);
|
||||
|
||||
// 结束测试步骤
|
||||
testLogger.endStep('步骤名称', 'passed');
|
||||
|
||||
// 结束测试
|
||||
testLogger.endTest('测试名称', 'passed');
|
||||
```
|
||||
|
||||
### 4. 测试报告生成
|
||||
|
||||
`test-reporter.ts` 提供了测试报告生成功能:
|
||||
|
||||
```typescript
|
||||
import { testReporter } from './core/test-reporter';
|
||||
|
||||
// 开始测试报告
|
||||
testReporter.startReport();
|
||||
|
||||
// 记录测试结果
|
||||
testReporter.recordTestResult({
|
||||
testName: '测试名称',
|
||||
status: 'passed',
|
||||
duration: 1000,
|
||||
steps: [],
|
||||
logs: [],
|
||||
screenshots: [],
|
||||
errors: []
|
||||
});
|
||||
|
||||
// 生成所有报告
|
||||
await testReporter.generateAllReports('./test-results/reports');
|
||||
|
||||
// 生成JSON报告
|
||||
await testReporter.generateJSONReport('./test-results/reports/e2e-report.json');
|
||||
|
||||
// 生成HTML报告
|
||||
await testReporter.generateHTMLReport('./test-results/reports/e2e-report.html');
|
||||
```
|
||||
|
||||
### 5. 页面对象模型
|
||||
|
||||
所有页面类都继承自 `BasePage`,提供统一的页面操作接口:
|
||||
|
||||
```typescript
|
||||
import { BasePage } from './pages/base-page';
|
||||
import { LoginPage } from './pages/login-page';
|
||||
import { DashboardPage } from './pages/dashboard-page';
|
||||
|
||||
// 使用页面对象
|
||||
const loginPage = new LoginPage(page);
|
||||
await loginPage.navigate();
|
||||
await loginPage.login('admin', 'password');
|
||||
|
||||
const dashboardPage = new DashboardPage(page);
|
||||
await dashboardPage.waitForLoad();
|
||||
const title = await dashboardPage.getPageTitle();
|
||||
```
|
||||
|
||||
### 6. 测试辅助工具
|
||||
|
||||
#### 截图辅助工具
|
||||
|
||||
```typescript
|
||||
import { ScreenshotHelper } from './helpers/screenshot-helper';
|
||||
|
||||
const screenshotHelper = new ScreenshotHelper(page);
|
||||
|
||||
// 截取当前页面
|
||||
await screenshotHelper.takeScreenshot('page-screenshot');
|
||||
|
||||
// 截取整个页面
|
||||
await screenshotHelper.takeFullPageScreenshot('full-page');
|
||||
|
||||
// 截取特定元素
|
||||
await screenshotHelper.takeElementScreenshot('element-screenshot', '.selector');
|
||||
|
||||
// 在测试失败时自动截图
|
||||
await screenshotHelper.takeScreenshotOnFailure('test-failure');
|
||||
```
|
||||
|
||||
#### 表单辅助工具
|
||||
|
||||
```typescript
|
||||
import { FormHelper } from './helpers/form-helper';
|
||||
|
||||
const formHelper = new FormHelper(page);
|
||||
|
||||
// 填写表单字段
|
||||
await formHelper.fillField('input[name="username"]', 'testuser');
|
||||
await formHelper.fillField('input[type="password"]', 'password', 'password');
|
||||
await formHelper.fillField('select[name="role"]', 'admin', 'select');
|
||||
await formHelper.fillField('input[type="checkbox"]', true, 'checkbox');
|
||||
|
||||
// 填写整个表单
|
||||
await formHelper.fillForm({
|
||||
username: 'testuser',
|
||||
password: 'password',
|
||||
email: 'test@example.com',
|
||||
role: 'admin'
|
||||
});
|
||||
|
||||
// 提交表单
|
||||
await formHelper.submitForm();
|
||||
|
||||
// 重置表单
|
||||
await formHelper.resetForm();
|
||||
|
||||
// 验证表单
|
||||
const isValid = await formHelper.validateForm();
|
||||
```
|
||||
|
||||
#### 表格辅助工具
|
||||
|
||||
```typescript
|
||||
import { TableHelper } from './helpers/table-helper';
|
||||
|
||||
const tableHelper = new TableHelper(page);
|
||||
|
||||
// 获取表格行数
|
||||
const rowCount = await tableHelper.getRowCount('.ant-table');
|
||||
|
||||
// 获取表格列数
|
||||
const columnCount = await tableHelper.getColumnCount('.ant-table');
|
||||
|
||||
// 获取单元格文本
|
||||
const cellText = await tableHelper.getCellText('.ant-table', 0, 0);
|
||||
|
||||
// 获取整行数据
|
||||
const rowData = await tableHelper.getRowData('.ant-table', 0);
|
||||
|
||||
// 获取整列数据
|
||||
const columnData = await tableHelper.getColumnData('.ant-table', 0);
|
||||
|
||||
// 点击表格行
|
||||
await tableHelper.clickRow('.ant-table', 0);
|
||||
|
||||
// 点击表格单元格
|
||||
await tableHelper.clickCell('.ant-table', 0, 0);
|
||||
|
||||
// 等待表格加载
|
||||
await tableHelper.waitForTableLoad('.ant-table');
|
||||
|
||||
// 验证表格数据
|
||||
const isValid = await tableHelper.validateTableData('.ant-table', expectedData);
|
||||
```
|
||||
|
||||
### 7. Mock服务集成
|
||||
|
||||
```typescript
|
||||
import { MockManager } from './mock-manager';
|
||||
|
||||
const mockConfig = {
|
||||
enabled: true,
|
||||
mode: 'full',
|
||||
mockPaths: [],
|
||||
delay: 0,
|
||||
logCalls: true,
|
||||
validateResponses: true,
|
||||
dataSource: 'memory'
|
||||
};
|
||||
|
||||
const mockManager = new MockManager(mockConfig);
|
||||
|
||||
// 拦截API请求
|
||||
await mockManager.interceptAPIRequest(page);
|
||||
|
||||
// 添加Mock响应
|
||||
mockManager.addMockResponse({
|
||||
url: '/api/login',
|
||||
method: 'POST',
|
||||
response: {
|
||||
code: 200,
|
||||
data: {
|
||||
token: 'mock-token',
|
||||
userInfo: {
|
||||
id: 1,
|
||||
username: 'admin'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 清除Mock响应
|
||||
mockManager.clearMockResponses();
|
||||
```
|
||||
|
||||
## 测试夹具
|
||||
|
||||
本工具提供了以下测试夹具:
|
||||
|
||||
### pageObjects
|
||||
|
||||
提供所有页面对象的实例:
|
||||
|
||||
```typescript
|
||||
test('使用页面对象', async ({ pageObjects }) => {
|
||||
await pageObjects.loginPage.navigate();
|
||||
await pageObjects.loginPage.login('admin', 'password');
|
||||
await pageObjects.dashboardPage.waitForLoad();
|
||||
});
|
||||
```
|
||||
|
||||
### helpers
|
||||
|
||||
提供所有辅助工具的实例:
|
||||
|
||||
```typescript
|
||||
test('使用辅助工具', async ({ helpers }) => {
|
||||
await helpers.screenshot.takeScreenshot('test');
|
||||
await helpers.form.fillField('input[name="username"]', 'test');
|
||||
const rowCount = await helpers.table.getRowCount('.ant-table');
|
||||
});
|
||||
```
|
||||
|
||||
### testData
|
||||
|
||||
提供预定义的测试数据:
|
||||
|
||||
```typescript
|
||||
test('使用测试数据', async ({ testData }) => {
|
||||
console.log(testData.user); // 普通用户数据
|
||||
console.log(testData.admin); // 管理员数据
|
||||
console.log(testData.role); // 角色数据
|
||||
console.log(testData.menu); // 菜单数据
|
||||
console.log(testData.permission); // 权限数据
|
||||
});
|
||||
```
|
||||
|
||||
### mockManager
|
||||
|
||||
提供Mock服务管理器:
|
||||
|
||||
```typescript
|
||||
test('使用Mock服务', async ({ mockManager }) => {
|
||||
mockManager.addMockResponse({
|
||||
url: '/api/test',
|
||||
method: 'GET',
|
||||
response: { code: 200, data: 'mock data' }
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### testConfig
|
||||
|
||||
提供测试配置:
|
||||
|
||||
```typescript
|
||||
test('使用测试配置', async ({ testConfig }) => {
|
||||
console.log(testConfig.name); // 环境名称
|
||||
console.log(testConfig.baseURL); // 基础URL
|
||||
console.log(testConfig.mockEnabled); // Mock是否启用
|
||||
});
|
||||
```
|
||||
|
||||
### testLogger
|
||||
|
||||
提供测试日志记录器:
|
||||
|
||||
```typescript
|
||||
test('使用测试日志', async ({ testLogger }) => {
|
||||
testLogger.info('测试信息');
|
||||
testLogger.error('测试错误', error);
|
||||
});
|
||||
```
|
||||
|
||||
### testReporter
|
||||
|
||||
提供测试报告生成器:
|
||||
|
||||
```typescript
|
||||
test('使用测试报告', async ({ testReporter }) => {
|
||||
testReporter.recordTestResult({
|
||||
testName: '测试名称',
|
||||
status: 'passed',
|
||||
duration: 1000
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### 1. 测试用例组织
|
||||
|
||||
- 使用 `test.describe` 组织相关的测试用例
|
||||
- 使用 `test.beforeEach` 和 `test.afterEach` 设置测试前置和后置条件
|
||||
- 为每个测试用例提供清晰的描述
|
||||
|
||||
```typescript
|
||||
test.describe('用户管理功能', () => {
|
||||
test.beforeEach(async ({ pageObjects, testData }) => {
|
||||
await pageObjects.loginPage.navigate();
|
||||
await pageObjects.loginPage.login(testData.admin.username, testData.admin.password);
|
||||
});
|
||||
|
||||
test.afterEach(async ({ helpers }) => {
|
||||
await helpers.screenshot.takeScreenshot('after-test');
|
||||
});
|
||||
|
||||
test('创建用户', async ({ pageObjects }) => {
|
||||
// 测试逻辑
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 2. 页面对象使用
|
||||
|
||||
- 始终使用页面对象而不是直接操作页面元素
|
||||
- 将页面选择器封装在页面对象中
|
||||
- 在页面对象中实现业务逻辑方法
|
||||
|
||||
```typescript
|
||||
// 好的做法
|
||||
await pageObjects.loginPage.login('admin', 'password');
|
||||
|
||||
// 不好的做法
|
||||
await page.fill('input[placeholder="请输入用户名"]', 'admin');
|
||||
await page.fill('input[placeholder="请输入密码"]', 'password');
|
||||
await page.click('button[type="submit"]');
|
||||
```
|
||||
|
||||
### 3. 测试数据管理
|
||||
|
||||
- 使用测试数据生成器创建测试数据
|
||||
- 避免硬编码测试数据
|
||||
- 使用测试夹具提供的预定义数据
|
||||
|
||||
```typescript
|
||||
// 好的做法
|
||||
const userData = testDataGenerator.generateUserData({
|
||||
username: 'testuser',
|
||||
status: 'active'
|
||||
});
|
||||
|
||||
// 不好的做法
|
||||
const userData = {
|
||||
username: 'testuser',
|
||||
password: 'password',
|
||||
email: 'test@example.com',
|
||||
// ... 硬编码的数据
|
||||
};
|
||||
```
|
||||
|
||||
### 4. 错误处理
|
||||
|
||||
- 在测试用例中使用 try-catch 捕获错误
|
||||
- 使用测试日志记录错误信息
|
||||
- 在测试失败时截图
|
||||
|
||||
```typescript
|
||||
test('测试用例', async ({ testLogger, helpers }) => {
|
||||
testLogger.startTest('测试用例');
|
||||
|
||||
try {
|
||||
// 测试逻辑
|
||||
testLogger.endTest('测试用例', 'passed');
|
||||
} catch (error) {
|
||||
testLogger.endTest('测试用例', 'failed', error as Error);
|
||||
await helpers.screenshot.takeScreenshot('test-failure');
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### 5. 等待策略
|
||||
|
||||
- 使用页面对象提供的等待方法
|
||||
- 避免使用固定的等待时间
|
||||
- 使用 Playwright 的自动等待机制
|
||||
|
||||
```typescript
|
||||
// 好的做法
|
||||
await pageObjects.dashboardPage.waitForLoad();
|
||||
await page.waitForSelector('.element', { state: 'visible' });
|
||||
|
||||
// 不好的做法
|
||||
await page.waitForTimeout(5000);
|
||||
```
|
||||
|
||||
### 6. 断言使用
|
||||
|
||||
- 使用 Playwright 的 expect 断言
|
||||
- 提供有意义的断言消息
|
||||
- 验证关键的业务逻辑
|
||||
|
||||
```typescript
|
||||
// 好的做法
|
||||
expect(pageTitle).toContain('仪表盘');
|
||||
expect(successMessage).toBeTruthy();
|
||||
expect(rowCount).toBeGreaterThan(0);
|
||||
|
||||
// 不好的做法
|
||||
expect(pageTitle).toBeTruthy();
|
||||
```
|
||||
|
||||
## 故障排查
|
||||
|
||||
### 测试失败时的调试
|
||||
|
||||
1. 查看测试日志:`test-results/logs/`
|
||||
2. 查看截图:`test-results/screenshots/`
|
||||
3. 查看测试报告:`test-results/reports/`
|
||||
4. 使用调试模式运行:`npx playwright test --debug`
|
||||
|
||||
### 常见问题
|
||||
|
||||
1. **元素未找到**
|
||||
- 检查选择器是否正确
|
||||
- 确保元素已加载
|
||||
- 使用适当的等待策略
|
||||
|
||||
2. **测试超时**
|
||||
- 增加超时配置
|
||||
- 检查网络请求是否正常
|
||||
- 优化测试等待策略
|
||||
|
||||
3. **Mock服务不工作**
|
||||
- 确认Mock服务已启用
|
||||
- 检查Mock配置是否正确
|
||||
- 验证Mock响应格式
|
||||
|
||||
## 扩展开发
|
||||
|
||||
### 添加新的页面对象
|
||||
|
||||
1. 在 `pages/` 目录下创建新的页面类
|
||||
2. 继承 `BasePage` 类
|
||||
3. 实现页面特定的方法和选择器
|
||||
|
||||
```typescript
|
||||
import { BasePage } from './base-page';
|
||||
|
||||
export class NewPage extends BasePage {
|
||||
private readonly selectors = {
|
||||
// 页面选择器
|
||||
};
|
||||
|
||||
constructor(page: Page) {
|
||||
super(page);
|
||||
}
|
||||
|
||||
// 页面方法
|
||||
}
|
||||
```
|
||||
|
||||
### 添加新的辅助工具
|
||||
|
||||
1. 在 `helpers/` 目录下创建新的辅助工具类
|
||||
2. 实现辅助工具方法
|
||||
3. 在测试夹具中注册新的辅助工具
|
||||
|
||||
```typescript
|
||||
export class NewHelper {
|
||||
private page: Page;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
}
|
||||
|
||||
// 辅助工具方法
|
||||
}
|
||||
```
|
||||
|
||||
### 添加新的测试数据生成器
|
||||
|
||||
1. 在 `test-data.ts` 中添加新的数据生成方法
|
||||
2. 定义数据接口
|
||||
3. 实现数据生成逻辑
|
||||
|
||||
```typescript
|
||||
export interface NewData {
|
||||
// 数据接口
|
||||
}
|
||||
|
||||
generateNewData(overrides: Partial<NewData> = {}): NewData {
|
||||
// 数据生成逻辑
|
||||
}
|
||||
```
|
||||
|
||||
## 性能优化
|
||||
|
||||
### 并行执行
|
||||
|
||||
Playwright 默认支持并行执行测试,可以通过配置文件调整:
|
||||
|
||||
```typescript
|
||||
export default defineConfig({
|
||||
workers: 4, // 并发工作进程数
|
||||
fullyParallel: true, // 完全并行执行
|
||||
});
|
||||
```
|
||||
|
||||
### 测试隔离
|
||||
|
||||
确保每个测试用例都是独立的,避免测试之间的依赖:
|
||||
|
||||
```typescript
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// 清理测试数据
|
||||
// 重置测试状态
|
||||
});
|
||||
```
|
||||
|
||||
### 重试机制
|
||||
|
||||
配置测试失败时的重试次数:
|
||||
|
||||
```typescript
|
||||
export default defineConfig({
|
||||
retries: 2, // 失败时重试次数
|
||||
});
|
||||
```
|
||||
|
||||
## 持续集成
|
||||
|
||||
### CI/CD集成
|
||||
|
||||
在CI/CD流程中集成E2E测试:
|
||||
|
||||
```yaml
|
||||
# .github/workflows/e2e-tests.yml
|
||||
name: E2E Tests
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v2
|
||||
- run: npm ci
|
||||
- run: npm run test:e2e
|
||||
- uses: actions/upload-artifact@v2
|
||||
if: failure()
|
||||
with:
|
||||
name: test-results
|
||||
path: test-results/
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
本E2E测试工具提供了完整的端到端测试解决方案,包括:
|
||||
|
||||
- ✅ 模块化的测试用例编写
|
||||
- ✅ 统一的测试环境配置
|
||||
- ✅ 常用测试操作的封装与复用
|
||||
- ✅ 清晰的测试报告与日志输出
|
||||
- ✅ 页面对象模型(POM)
|
||||
- ✅ 测试辅助工具
|
||||
- ✅ Mock服务集成
|
||||
- ✅ 测试数据生成
|
||||
|
||||
通过使用本工具,可以高效地编写、执行和维护E2E测试,确保应用的质量和稳定性。
|
||||
@@ -0,0 +1,181 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { MockManager } from './mock-manager';
|
||||
|
||||
test.describe('用户认证', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
const mockManager = new MockManager({
|
||||
enabled: true,
|
||||
mode: 'full',
|
||||
mockPaths: [],
|
||||
delay: 0,
|
||||
logCalls: true,
|
||||
validateResponses: true,
|
||||
dataSource: 'memory'
|
||||
});
|
||||
|
||||
mockManager.presetTestData({
|
||||
menus: [
|
||||
{
|
||||
id: 1,
|
||||
name: '仪表盘',
|
||||
code: 'dashboard',
|
||||
path: '/dashboard',
|
||||
icon: 'DashboardOutlined',
|
||||
sortOrder: 1,
|
||||
status: 'active',
|
||||
parentId: 0,
|
||||
component: 'views/Dashboard.vue',
|
||||
createBy: 'system',
|
||||
updateBy: 'system',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
children: []
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: '用户管理',
|
||||
code: 'user',
|
||||
path: '/users',
|
||||
icon: 'UserOutlined',
|
||||
sortOrder: 2,
|
||||
status: 'active',
|
||||
parentId: 0,
|
||||
component: 'views/User.vue',
|
||||
createBy: 'system',
|
||||
updateBy: 'system',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
children: []
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: '角色管理',
|
||||
code: 'role',
|
||||
path: '/roles',
|
||||
icon: 'LockOutlined',
|
||||
sortOrder: 3,
|
||||
status: 'active',
|
||||
parentId: 0,
|
||||
component: 'views/Role.vue',
|
||||
createBy: 'system',
|
||||
updateBy: 'system',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
children: []
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: '菜单管理',
|
||||
code: 'menu',
|
||||
path: '/menus',
|
||||
icon: 'MenuOutlined',
|
||||
sortOrder: 4,
|
||||
status: 'active',
|
||||
parentId: 0,
|
||||
component: 'views/Menu.vue',
|
||||
createBy: 'system',
|
||||
updateBy: 'system',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
children: []
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
await mockManager.interceptAPIRequest(page);
|
||||
await page.goto('/login');
|
||||
});
|
||||
|
||||
test('应该显示登录页面', async ({ page }) => {
|
||||
await expect(page).toHaveTitle(/管理系统/);
|
||||
|
||||
const usernameInput = page.locator('input[placeholder="请输入用户名"]');
|
||||
const passwordInput = page.locator('input[placeholder="请输入密码"]');
|
||||
const loginButton = page.locator('button[type="submit"]');
|
||||
|
||||
await usernameInput.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await passwordInput.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await loginButton.waitFor({ state: 'visible', timeout: 10000 });
|
||||
|
||||
await expect(usernameInput).toBeVisible();
|
||||
await expect(passwordInput).toBeVisible();
|
||||
await expect(loginButton).toBeVisible();
|
||||
});
|
||||
|
||||
test('应该成功登录', async ({ page }) => {
|
||||
const usernameInput = page.locator('input[placeholder="请输入用户名"]');
|
||||
const passwordInput = page.locator('input[placeholder="请输入密码"]');
|
||||
const loginButton = page.locator('button[type="submit"]');
|
||||
|
||||
await usernameInput.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await passwordInput.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await loginButton.waitFor({ state: 'visible', timeout: 10000 });
|
||||
|
||||
await usernameInput.fill('admin');
|
||||
await passwordInput.fill('admin123');
|
||||
|
||||
await loginButton.click();
|
||||
|
||||
await page.waitForURL(/.*dashboard/, { timeout: 15000 });
|
||||
await expect(page.locator('.ant-breadcrumb-link').filter({ hasText: '仪表盘' })).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test('登录失败应该显示错误信息', async ({ page }) => {
|
||||
const usernameInput = page.locator('input[placeholder="请输入用户名"]');
|
||||
const passwordInput = page.locator('input[placeholder="请输入密码"]');
|
||||
const loginButton = page.locator('button[type="submit"]');
|
||||
|
||||
await usernameInput.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await passwordInput.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await loginButton.waitFor({ state: 'visible', timeout: 10000 });
|
||||
|
||||
await usernameInput.fill('wronguser');
|
||||
await passwordInput.fill('wrongpassword');
|
||||
await loginButton.click();
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const errorMessage = page.locator('.ant-message-error');
|
||||
await expect(errorMessage).toBeVisible({ timeout: 5000 });
|
||||
await expect(errorMessage).toContainText(/登录失败|用户名或密码错误/i);
|
||||
});
|
||||
|
||||
test('表单验证应该工作', async ({ page }) => {
|
||||
const loginButton = page.locator('button[type="submit"]');
|
||||
await loginButton.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await loginButton.click();
|
||||
|
||||
const usernameError = page.locator('.ant-form-item-explain-error').filter({ hasText: /请输入用户名/ });
|
||||
const passwordError = page.locator('.ant-form-item-explain-error').filter({ hasText: /请输入密码/ });
|
||||
|
||||
await expect(usernameError).toBeVisible({ timeout: 5000 });
|
||||
await expect(passwordError).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test('应该能够登出', async ({ page }) => {
|
||||
const usernameInput = page.locator('input[placeholder="请输入用户名"]');
|
||||
const passwordInput = page.locator('input[placeholder="请输入密码"]');
|
||||
const loginButton = page.locator('button[type="submit"]');
|
||||
|
||||
await usernameInput.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await passwordInput.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await loginButton.waitFor({ state: 'visible', timeout: 10000 });
|
||||
|
||||
await usernameInput.fill('admin');
|
||||
await passwordInput.fill('admin123');
|
||||
await loginButton.click();
|
||||
|
||||
await page.waitForURL(/.*dashboard/, { timeout: 10000 });
|
||||
|
||||
const dropdownTrigger = page.locator('.ant-dropdown-link');
|
||||
await dropdownTrigger.click();
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const logoutMenuItem = page.locator('.ant-dropdown-menu-item').filter({ hasText: /退出/i });
|
||||
await logoutMenuItem.click();
|
||||
|
||||
await page.waitForURL(/.*login/, { timeout: 10000 });
|
||||
await expect(page.locator('input[placeholder="请输入用户名"]')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,458 @@
|
||||
export const SELECTORS = {
|
||||
LOGIN: {
|
||||
USERNAME_INPUT: 'input[placeholder="请输入用户名"]',
|
||||
PASSWORD_INPUT: 'input[placeholder="请输入密码"]',
|
||||
LOGIN_BUTTON: 'button[type="submit"]',
|
||||
REMEMBER_ME_CHECKBOX: 'input[type="checkbox"]',
|
||||
FORGOT_PASSWORD_LINK: 'text=忘记密码',
|
||||
REGISTER_LINK: 'text=注册账号',
|
||||
ERROR_MESSAGE: '.ant-message-error',
|
||||
SUCCESS_MESSAGE: '.ant-message-success',
|
||||
LOGIN_FORM: '.login-form'
|
||||
},
|
||||
|
||||
HEADER: {
|
||||
USER_MENU: '.user-menu',
|
||||
LOGOUT_BUTTON: 'text=退出登录',
|
||||
USER_INFO: '.user-info',
|
||||
NOTIFICATION: '.notification',
|
||||
SETTINGS: '.settings'
|
||||
},
|
||||
|
||||
MENU: {
|
||||
SIDEBAR: '.sidebar',
|
||||
MENU_ITEM: '.menu-item',
|
||||
SUBMENU: '.submenu',
|
||||
ACTIVE_MENU: '.menu-item.active',
|
||||
MENU_TOGGLE: '.menu-toggle'
|
||||
},
|
||||
|
||||
DASHBOARD: {
|
||||
STATISTICS_CARD: '.statistics-card',
|
||||
CHART: '.chart',
|
||||
TABLE: '.dashboard-table',
|
||||
FILTER: '.filter',
|
||||
SEARCH: '.search'
|
||||
},
|
||||
|
||||
TABLE: {
|
||||
CONTAINER: '.table-container',
|
||||
HEADER: 'thead',
|
||||
BODY: 'tbody',
|
||||
ROW: 'tr',
|
||||
CELL: 'td',
|
||||
CHECKBOX: 'input[type="checkbox"]',
|
||||
PAGINATION: '.pagination',
|
||||
SEARCH: '.table-search input',
|
||||
EXPORT: '.export-button',
|
||||
SORT: 'th.sortable'
|
||||
},
|
||||
|
||||
FORM: {
|
||||
CONTAINER: '.form-container',
|
||||
INPUT: 'input[type="text"], input[type="email"], input[type="password"], input[type="number"]',
|
||||
SELECT: 'select',
|
||||
CHECKBOX: 'input[type="checkbox"]',
|
||||
RADIO: 'input[type="radio"]',
|
||||
TEXTAREA: 'textarea',
|
||||
DATE_PICKER: 'input[type="date"]',
|
||||
TIME_PICKER: 'input[type="time"]',
|
||||
FILE_INPUT: 'input[type="file"]',
|
||||
SUBMIT_BUTTON: 'button[type="submit"], .submit-button',
|
||||
CANCEL_BUTTON: 'button[type="button"], .cancel-button',
|
||||
RESET_BUTTON: '.reset-button',
|
||||
ERROR_MESSAGE: '.error-message',
|
||||
VALIDATION_MESSAGE: '.validation-message'
|
||||
},
|
||||
|
||||
MODAL: {
|
||||
CONTAINER: '.modal, .ant-modal',
|
||||
TITLE: '.modal-title, .ant-modal-title',
|
||||
CONTENT: '.modal-content, .ant-modal-body',
|
||||
FOOTER: '.modal-footer, .ant-modal-footer',
|
||||
CLOSE_BUTTON: '.close-button, .ant-modal-close',
|
||||
CONFIRM_BUTTON: '.confirm-button, .ant-btn-primary',
|
||||
CANCEL_BUTTON: '.cancel-button, .ant-btn-default'
|
||||
},
|
||||
|
||||
TOAST: {
|
||||
SUCCESS: '.ant-message-success',
|
||||
ERROR: '.ant-message-error',
|
||||
WARNING: '.ant-message-warning',
|
||||
INFO: '.ant-message-info',
|
||||
LOADING: '.ant-message-loading'
|
||||
},
|
||||
|
||||
LOADING: {
|
||||
SPINNER: '.ant-spin, .loading-spinner',
|
||||
OVERLAY: '.loading-overlay',
|
||||
PROGRESS_BAR: '.progress-bar'
|
||||
},
|
||||
|
||||
NAVIGATION: {
|
||||
BREADCRUMB: '.breadcrumb',
|
||||
TABS: '.tabs',
|
||||
TAB_ITEM: '.tab-item',
|
||||
ACTIVE_TAB: '.tab-item.active',
|
||||
BACK_BUTTON: '.back-button',
|
||||
FORWARD_BUTTON: '.forward-button'
|
||||
},
|
||||
|
||||
USER_MANAGEMENT: {
|
||||
USER_LIST: '.user-list',
|
||||
USER_CARD: '.user-card',
|
||||
USER_AVATAR: '.user-avatar',
|
||||
USER_NAME: '.user-name',
|
||||
USER_EMAIL: '.user-email',
|
||||
USER_STATUS: '.user-status',
|
||||
USER_ROLE: '.user-role',
|
||||
ADD_USER_BUTTON: '.add-user-button',
|
||||
EDIT_USER_BUTTON: '.edit-user-button',
|
||||
DELETE_USER_BUTTON: '.delete-user-button',
|
||||
USER_SEARCH: '.user-search'
|
||||
},
|
||||
|
||||
ROLE_MANAGEMENT: {
|
||||
ROLE_LIST: '.role-list',
|
||||
ROLE_NAME: '.role-name',
|
||||
ROLE_DESCRIPTION: '.role-description',
|
||||
ROLE_PERMISSIONS: '.role-permissions',
|
||||
ADD_ROLE_BUTTON: '.add-role-button',
|
||||
EDIT_ROLE_BUTTON: '.edit-role-button',
|
||||
DELETE_ROLE_BUTTON: '.delete-role-button',
|
||||
PERMISSION_CHECKBOX: '.permission-checkbox'
|
||||
},
|
||||
|
||||
PERMISSION_MANAGEMENT: {
|
||||
PERMISSION_LIST: '.permission-list',
|
||||
PERMISSION_NAME: '.permission-name',
|
||||
PERMISSION_CODE: '.permission-code',
|
||||
PERMISSION_TYPE: '.permission-type',
|
||||
PERMISSION_RESOURCE: '.permission-resource',
|
||||
ADD_PERMISSION_BUTTON: '.add-permission-button',
|
||||
EDIT_PERMISSION_BUTTON: '.edit-permission-button',
|
||||
DELETE_PERMISSION_BUTTON: '.delete-permission-button'
|
||||
},
|
||||
|
||||
SETTINGS: {
|
||||
SETTINGS_PAGE: '.settings-page',
|
||||
SETTINGS_SECTION: '.settings-section',
|
||||
SETTINGS_ITEM: '.settings-item',
|
||||
SAVE_BUTTON: '.save-button',
|
||||
RESET_BUTTON: '.reset-button'
|
||||
}
|
||||
} as const;
|
||||
|
||||
export const TIMEOUTS = {
|
||||
SHORT: 5000,
|
||||
MEDIUM: 10000,
|
||||
LONG: 30000,
|
||||
VERY_LONG: 60000,
|
||||
NETWORK_IDLE: 30000,
|
||||
ELEMENT_VISIBLE: 10000,
|
||||
ELEMENT_HIDDEN: 10000,
|
||||
NAVIGATION: 30000,
|
||||
PAGE_LOAD: 30000,
|
||||
API_REQUEST: 30000,
|
||||
ANIMATION: 1000
|
||||
} as const;
|
||||
|
||||
export const MESSAGES = {
|
||||
LOGIN: {
|
||||
SUCCESS: '登录成功',
|
||||
INVALID_CREDENTIALS: '用户名或密码错误',
|
||||
ACCOUNT_LOCKED: '账号已被锁定',
|
||||
ACCOUNT_DISABLED: '账号已被禁用',
|
||||
SESSION_EXPIRED: '会话已过期,请重新登录',
|
||||
NETWORK_ERROR: '网络错误,请稍后重试'
|
||||
},
|
||||
|
||||
LOGOUT: {
|
||||
SUCCESS: '退出登录成功',
|
||||
ERROR: '退出登录失败'
|
||||
},
|
||||
|
||||
VALIDATION: {
|
||||
REQUIRED: '此字段为必填项',
|
||||
INVALID_EMAIL: '邮箱格式不正确',
|
||||
INVALID_PHONE: '手机号格式不正确',
|
||||
INVALID_PASSWORD: '密码格式不正确',
|
||||
PASSWORD_MISMATCH: '两次输入的密码不一致',
|
||||
MIN_LENGTH: '输入长度不能少于 {min} 个字符',
|
||||
MAX_LENGTH: '输入长度不能超过 {max} 个字符',
|
||||
INVALID_NUMBER: '请输入有效的数字',
|
||||
INVALID_DATE: '日期格式不正确'
|
||||
},
|
||||
|
||||
OPERATION: {
|
||||
SUCCESS: '操作成功',
|
||||
FAILED: '操作失败',
|
||||
CONFIRM_DELETE: '确定要删除吗?',
|
||||
CONFIRM_SAVE: '确定要保存吗?',
|
||||
CONFIRM_CANCEL: '确定要取消吗?',
|
||||
UNSAVED_CHANGES: '您有未保存的更改,确定要离开吗?'
|
||||
},
|
||||
|
||||
NETWORK: {
|
||||
REQUEST_FAILED: '请求失败',
|
||||
TIMEOUT: '请求超时',
|
||||
SERVER_ERROR: '服务器错误',
|
||||
NETWORK_ERROR: '网络错误',
|
||||
UNAUTHORIZED: '未授权,请先登录',
|
||||
FORBIDDEN: '无权限访问',
|
||||
NOT_FOUND: '资源不存在'
|
||||
},
|
||||
|
||||
UPLOAD: {
|
||||
SUCCESS: '上传成功',
|
||||
FAILED: '上传失败',
|
||||
INVALID_FILE_TYPE: '文件类型不支持',
|
||||
FILE_TOO_LARGE: '文件大小超出限制',
|
||||
UPLOADING: '上传中...'
|
||||
},
|
||||
|
||||
DOWNLOAD: {
|
||||
SUCCESS: '下载成功',
|
||||
FAILED: '下载失败',
|
||||
PREPARING: '准备下载...'
|
||||
}
|
||||
} as const;
|
||||
|
||||
export const ROLES = {
|
||||
ADMIN: 'admin',
|
||||
USER: 'user',
|
||||
GUEST: 'guest',
|
||||
SUPER_ADMIN: 'super_admin',
|
||||
MODERATOR: 'moderator'
|
||||
} as const;
|
||||
|
||||
export const PERMISSIONS = {
|
||||
DASHBOARD: {
|
||||
VIEW: 'dashboard:view',
|
||||
EDIT: 'dashboard:edit',
|
||||
DELETE: 'dashboard:delete'
|
||||
},
|
||||
|
||||
USER: {
|
||||
VIEW: 'user:view',
|
||||
CREATE: 'user:create',
|
||||
EDIT: 'user:edit',
|
||||
DELETE: 'user:delete',
|
||||
EXPORT: 'user:export'
|
||||
},
|
||||
|
||||
ROLE: {
|
||||
VIEW: 'role:view',
|
||||
CREATE: 'role:create',
|
||||
EDIT: 'role:edit',
|
||||
DELETE: 'role:delete',
|
||||
ASSIGN_PERMISSIONS: 'role:assign_permissions'
|
||||
},
|
||||
|
||||
PERMISSION: {
|
||||
VIEW: 'permission:view',
|
||||
CREATE: 'permission:create',
|
||||
EDIT: 'permission:edit',
|
||||
DELETE: 'permission:delete'
|
||||
},
|
||||
|
||||
SETTINGS: {
|
||||
VIEW: 'settings:view',
|
||||
EDIT: 'settings:edit',
|
||||
SYSTEM: 'settings:system',
|
||||
PROFILE: 'settings:profile'
|
||||
}
|
||||
} as const;
|
||||
|
||||
export const STATUS = {
|
||||
ACTIVE: 'active',
|
||||
INACTIVE: 'inactive',
|
||||
PENDING: 'pending',
|
||||
LOCKED: 'locked',
|
||||
DELETED: 'deleted',
|
||||
SUSPENDED: 'suspended'
|
||||
} as const;
|
||||
|
||||
export const HTTP_STATUS = {
|
||||
OK: 200,
|
||||
CREATED: 201,
|
||||
NO_CONTENT: 204,
|
||||
BAD_REQUEST: 400,
|
||||
UNAUTHORIZED: 401,
|
||||
FORBIDDEN: 403,
|
||||
NOT_FOUND: 404,
|
||||
METHOD_NOT_ALLOWED: 405,
|
||||
CONFLICT: 409,
|
||||
UNPROCESSABLE_ENTITY: 422,
|
||||
INTERNAL_SERVER_ERROR: 500,
|
||||
NOT_IMPLEMENTED: 501,
|
||||
BAD_GATEWAY: 502,
|
||||
SERVICE_UNAVAILABLE: 503,
|
||||
GATEWAY_TIMEOUT: 504
|
||||
} as const;
|
||||
|
||||
export const TEST_DATA = {
|
||||
USERS: {
|
||||
ADMIN: {
|
||||
username: 'admin',
|
||||
password: 'Admin@123',
|
||||
email: 'admin@example.com',
|
||||
phone: '13800138000',
|
||||
realName: '管理员'
|
||||
},
|
||||
USER: {
|
||||
username: 'testuser',
|
||||
password: 'User@123',
|
||||
email: 'user@example.com',
|
||||
phone: '13900139000',
|
||||
realName: '测试用户'
|
||||
},
|
||||
INVALID: {
|
||||
username: 'invalid',
|
||||
password: 'invalid'
|
||||
}
|
||||
},
|
||||
|
||||
ROLES: {
|
||||
ADMIN: {
|
||||
name: '管理员',
|
||||
code: 'admin',
|
||||
description: '系统管理员角色'
|
||||
},
|
||||
USER: {
|
||||
name: '普通用户',
|
||||
code: 'user',
|
||||
description: '普通用户角色'
|
||||
}
|
||||
},
|
||||
|
||||
PERMISSIONS: [
|
||||
{
|
||||
name: '查看仪表盘',
|
||||
code: 'dashboard:view',
|
||||
type: 'menu',
|
||||
resource: '/dashboard'
|
||||
},
|
||||
{
|
||||
name: '查看用户',
|
||||
code: 'user:view',
|
||||
type: 'menu',
|
||||
resource: '/user'
|
||||
},
|
||||
{
|
||||
name: '创建用户',
|
||||
code: 'user:create',
|
||||
type: 'button',
|
||||
resource: '/user/create'
|
||||
},
|
||||
{
|
||||
name: '编辑用户',
|
||||
code: 'user:edit',
|
||||
type: 'button',
|
||||
resource: '/user/edit'
|
||||
},
|
||||
{
|
||||
name: '删除用户',
|
||||
code: 'user:delete',
|
||||
type: 'button',
|
||||
resource: '/user/delete'
|
||||
}
|
||||
]
|
||||
} as const;
|
||||
|
||||
export const API_ENDPOINTS = {
|
||||
AUTH: {
|
||||
LOGIN: '/sys/auth/login',
|
||||
LOGOUT: '/sys/auth/logout',
|
||||
REFRESH_TOKEN: '/sys/auth/refresh',
|
||||
GET_USER_INFO: '/sys/auth/userinfo'
|
||||
},
|
||||
|
||||
USER: {
|
||||
LIST: '/sys/user/list',
|
||||
DETAIL: '/sys/user/detail',
|
||||
CREATE: '/sys/user/create',
|
||||
UPDATE: '/sys/user/update',
|
||||
DELETE: '/sys/user/delete',
|
||||
EXPORT: '/sys/user/export'
|
||||
},
|
||||
|
||||
ROLE: {
|
||||
LIST: '/sys/role/list',
|
||||
DETAIL: '/sys/role/detail',
|
||||
CREATE: '/sys/role/create',
|
||||
UPDATE: '/sys/role/update',
|
||||
DELETE: '/sys/role/delete',
|
||||
ASSIGN_PERMISSIONS: '/sys/role/assign-permissions'
|
||||
},
|
||||
|
||||
PERMISSION: {
|
||||
LIST: '/sys/permission/list',
|
||||
DETAIL: '/sys/permission/detail',
|
||||
CREATE: '/sys/permission/create',
|
||||
UPDATE: '/sys/permission/update',
|
||||
DELETE: '/sys/permission/delete'
|
||||
},
|
||||
|
||||
MENU: {
|
||||
LIST: '/sys/menu/list',
|
||||
TREE: '/sys/menu/tree',
|
||||
CREATE: '/sys/menu/create',
|
||||
UPDATE: '/sys/menu/update',
|
||||
DELETE: '/sys/menu/delete'
|
||||
}
|
||||
} as const;
|
||||
|
||||
export const ENVIRONMENTS = {
|
||||
LOCAL: {
|
||||
name: 'local',
|
||||
baseURL: 'http://localhost:5173',
|
||||
mockEnabled: true,
|
||||
mockMode: 'full' as const
|
||||
},
|
||||
DEV: {
|
||||
name: 'dev',
|
||||
baseURL: 'https://dev.example.com',
|
||||
mockEnabled: false,
|
||||
mockMode: 'none' as const
|
||||
},
|
||||
TEST: {
|
||||
name: 'test',
|
||||
baseURL: 'https://test.example.com',
|
||||
mockEnabled: true,
|
||||
mockMode: 'partial' as const
|
||||
},
|
||||
PROD: {
|
||||
name: 'prod',
|
||||
baseURL: 'https://prod.example.com',
|
||||
mockEnabled: false,
|
||||
mockMode: 'none' as const
|
||||
}
|
||||
} as const;
|
||||
|
||||
export const SCREENSHOT_CONFIG = {
|
||||
DIR: 'test-results/screenshots',
|
||||
ON_FAILURE: true,
|
||||
ON_SUCCESS: false,
|
||||
FULL_PAGE: false,
|
||||
RETRY_COUNT: 3
|
||||
} as const;
|
||||
|
||||
export const REPORT_CONFIG = {
|
||||
DIR: 'test-results/reports',
|
||||
JSON: true,
|
||||
HTML: true,
|
||||
ALLURE: true,
|
||||
INCLUDE_SCREENSHOTS: true,
|
||||
INCLUDE_LOGS: true,
|
||||
INCLUDE_VIDEO: true
|
||||
} as const;
|
||||
|
||||
export const MOCK_CONFIG = {
|
||||
ENABLED: true,
|
||||
MODE: 'full' as const,
|
||||
DELAY: 0,
|
||||
LOG_CALLS: true,
|
||||
VALIDATE_RESPONSES: true,
|
||||
DATA_SOURCE: 'memory' as const
|
||||
} as const;
|
||||
@@ -0,0 +1,104 @@
|
||||
import { ENVIRONMENTS } from '../constants';
|
||||
|
||||
export interface TestEnvironment {
|
||||
name: string;
|
||||
baseURL: string;
|
||||
mockEnabled: boolean;
|
||||
mockMode: 'full' | 'partial' | 'none';
|
||||
timeout: {
|
||||
default: number;
|
||||
navigation: number;
|
||||
element: number;
|
||||
network: number;
|
||||
};
|
||||
}
|
||||
|
||||
class TestConfig {
|
||||
private static instance: TestConfig;
|
||||
private currentEnvironment: string;
|
||||
private environments: Record<string, TestEnvironment>;
|
||||
|
||||
private constructor() {
|
||||
this.currentEnvironment = process.env.E2E_ENV || 'local';
|
||||
|
||||
this.environments = {
|
||||
local: {
|
||||
...ENVIRONMENTS.LOCAL,
|
||||
timeout: {
|
||||
default: 30000,
|
||||
navigation: 30000,
|
||||
element: 10000,
|
||||
network: 30000
|
||||
}
|
||||
},
|
||||
dev: {
|
||||
...ENVIRONMENTS.DEV,
|
||||
timeout: {
|
||||
default: 30000,
|
||||
navigation: 30000,
|
||||
element: 10000,
|
||||
network: 30000
|
||||
}
|
||||
},
|
||||
test: {
|
||||
...ENVIRONMENTS.TEST,
|
||||
timeout: {
|
||||
default: 30000,
|
||||
navigation: 30000,
|
||||
element: 10000,
|
||||
network: 30000
|
||||
}
|
||||
},
|
||||
prod: {
|
||||
...ENVIRONMENTS.PROD,
|
||||
timeout: {
|
||||
default: 30000,
|
||||
navigation: 30000,
|
||||
element: 10000,
|
||||
network: 30000
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
static getInstance(): TestConfig {
|
||||
if (!TestConfig.instance) {
|
||||
TestConfig.instance = new TestConfig();
|
||||
}
|
||||
return TestConfig.instance;
|
||||
}
|
||||
|
||||
getEnvironment(): TestEnvironment {
|
||||
return this.environments[this.currentEnvironment];
|
||||
}
|
||||
|
||||
getBaseURL(): string {
|
||||
return this.environments[this.currentEnvironment].baseURL;
|
||||
}
|
||||
|
||||
isMockEnabled(): boolean {
|
||||
return this.environments[this.currentEnvironment].mockEnabled;
|
||||
}
|
||||
|
||||
getMockMode(): string {
|
||||
return this.environments[this.currentEnvironment].mockMode;
|
||||
}
|
||||
|
||||
getCurrentEnvironmentName(): string {
|
||||
return this.currentEnvironment;
|
||||
}
|
||||
|
||||
setEnvironment(envName: string): void {
|
||||
if (this.environments[envName]) {
|
||||
this.currentEnvironment = envName;
|
||||
} else {
|
||||
throw new Error(`Unknown environment: ${envName}`);
|
||||
}
|
||||
}
|
||||
|
||||
getTimeout(): TestEnvironment['timeout'] {
|
||||
return this.environments[this.currentEnvironment].timeout;
|
||||
}
|
||||
}
|
||||
|
||||
export const testConfig = TestConfig.getInstance();
|
||||
@@ -0,0 +1,210 @@
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
export interface UserData {
|
||||
username: string;
|
||||
password: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
realName: string;
|
||||
status: 'active' | 'inactive' | 'locked';
|
||||
roleIds: number[];
|
||||
}
|
||||
|
||||
export interface RoleData {
|
||||
name: string;
|
||||
code: string;
|
||||
description: string;
|
||||
status: 'active' | 'inactive';
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
export interface MenuData {
|
||||
name: string;
|
||||
code: string;
|
||||
path: string;
|
||||
icon: string;
|
||||
parentId: number;
|
||||
sortOrder: number;
|
||||
status: 'active' | 'inactive';
|
||||
}
|
||||
|
||||
export interface PermissionData {
|
||||
name: string;
|
||||
code: string;
|
||||
description: string;
|
||||
type: 'menu' | 'button' | 'api';
|
||||
parentId: number;
|
||||
}
|
||||
|
||||
class TestDataGenerator {
|
||||
private static instance: TestDataGenerator;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): TestDataGenerator {
|
||||
if (!TestDataGenerator.instance) {
|
||||
TestDataGenerator.instance = new TestDataGenerator();
|
||||
}
|
||||
return TestDataGenerator.instance;
|
||||
}
|
||||
|
||||
randomString(length: number = 10): string {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let result = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
randomEmail(): string {
|
||||
const domains = ['example.com', 'test.com', 'demo.com'];
|
||||
const username = this.randomString(8).toLowerCase();
|
||||
const domain = domains[Math.floor(Math.random() * domains.length)];
|
||||
return `${username}@${domain}`;
|
||||
}
|
||||
|
||||
randomPhone(): string {
|
||||
const prefix = ['138', '139', '150', '186', '188'];
|
||||
const selectedPrefix = prefix[Math.floor(Math.random() * prefix.length)];
|
||||
const suffix = Math.floor(Math.random() * 100000000).toString().padStart(8, '0');
|
||||
return `${selectedPrefix}${suffix}`;
|
||||
}
|
||||
|
||||
randomPassword(length: number = 12): string {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*';
|
||||
let result = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
randomInt(min: number, max: number): number {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
}
|
||||
|
||||
randomBoolean(): boolean {
|
||||
return Math.random() < 0.5;
|
||||
}
|
||||
|
||||
randomDate(startYear: number = 2020, endYear: number = 2024): Date {
|
||||
const start = new Date(startYear, 0, 1);
|
||||
const end = new Date(endYear, 11, 31);
|
||||
return new Date(start.getTime() + Math.random() * (end.getTime() - start.getTime()));
|
||||
}
|
||||
|
||||
randomItem<T>(items: T[]): T {
|
||||
return items[Math.floor(Math.random() * items.length)];
|
||||
}
|
||||
|
||||
randomItems<T>(items: T[], count: number): T[] {
|
||||
const shuffled = [...items].sort(() => Math.random() - 0.5);
|
||||
return shuffled.slice(0, Math.min(count, items.length));
|
||||
}
|
||||
|
||||
generateUserData(overrides: Partial<UserData> = {}): UserData {
|
||||
const username = overrides.username || `user_${this.randomString(6).toLowerCase()}`;
|
||||
|
||||
return {
|
||||
username,
|
||||
password: overrides.password || 'Admin@123',
|
||||
email: overrides.email || this.randomEmail(),
|
||||
phone: overrides.phone || this.randomPhone(),
|
||||
realName: overrides.realName || `测试用户${this.randomInt(1, 100)}`,
|
||||
status: overrides.status || this.randomItem(['active', 'inactive', 'locked']),
|
||||
roleIds: overrides.roleIds || [1]
|
||||
};
|
||||
}
|
||||
|
||||
generateRoleData(overrides: Partial<RoleData> = {}): RoleData {
|
||||
const code = overrides.code || `role_${this.randomString(6).toLowerCase()}`;
|
||||
|
||||
return {
|
||||
name: overrides.name || `测试角色${this.randomInt(1, 100)}`,
|
||||
code,
|
||||
description: overrides.description || `角色${code}的描述`,
|
||||
status: overrides.status || this.randomItem(['active', 'inactive']),
|
||||
permissions: overrides.permissions || ['dashboard:view']
|
||||
};
|
||||
}
|
||||
|
||||
generateMenuData(overrides: Partial<MenuData> = {}): MenuData {
|
||||
const code = overrides.code || `menu_${this.randomString(6).toLowerCase()}`;
|
||||
|
||||
return {
|
||||
name: overrides.name || `测试菜单${this.randomInt(1, 100)}`,
|
||||
code,
|
||||
path: overrides.path || `/${code}`,
|
||||
icon: overrides.icon || 'MenuOutlined',
|
||||
parentId: overrides.parentId || 0,
|
||||
sortOrder: overrides.sortOrder || this.randomInt(1, 100),
|
||||
status: overrides.status || this.randomItem(['active', 'inactive'])
|
||||
};
|
||||
}
|
||||
|
||||
generatePermissionData(overrides: Partial<PermissionData> = {}): PermissionData {
|
||||
const code = overrides.code || `perm_${this.randomString(6).toLowerCase()}`;
|
||||
|
||||
return {
|
||||
name: overrides.name || `测试权限${this.randomInt(1, 100)}`,
|
||||
code,
|
||||
description: overrides.description || `权限${code}的描述`,
|
||||
type: overrides.type || this.randomItem(['menu', 'button', 'api']),
|
||||
parentId: overrides.parentId || 0
|
||||
};
|
||||
}
|
||||
|
||||
generateUserList(count: number): UserData[] {
|
||||
return Array.from({ length: count }, () => this.generateUserData());
|
||||
}
|
||||
|
||||
generateRoleList(count: number): RoleData[] {
|
||||
return Array.from({ length: count }, () => this.generateRoleData());
|
||||
}
|
||||
|
||||
generateMenuList(count: number): MenuData[] {
|
||||
return Array.from({ length: count }, () => this.generateMenuData());
|
||||
}
|
||||
|
||||
generatePermissionList(count: number): PermissionData[] {
|
||||
return Array.from({ length: count }, () => this.generatePermissionData());
|
||||
}
|
||||
|
||||
generatePaginationData<T>(data: T[], page: number = 1, pageSize: number = 10) {
|
||||
const start = (page - 1) * pageSize;
|
||||
const end = start + pageSize;
|
||||
|
||||
return {
|
||||
records: data.slice(start, end),
|
||||
total: data.length,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.ceil(data.length / pageSize)
|
||||
};
|
||||
}
|
||||
|
||||
generateSearchQuery(keyword: string): Record<string, any> {
|
||||
return {
|
||||
keyword,
|
||||
page: 1,
|
||||
pageSize: 10
|
||||
};
|
||||
}
|
||||
|
||||
generateFormData(fields: Record<string, any>): Record<string, any> {
|
||||
const formData: Record<string, any> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
if (typeof value === 'function') {
|
||||
formData[key] = value();
|
||||
} else {
|
||||
formData[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return formData;
|
||||
}
|
||||
}
|
||||
|
||||
export const testDataGenerator = TestDataGenerator.getInstance();
|
||||
@@ -0,0 +1,86 @@
|
||||
export enum LogLevel {
|
||||
INFO = 'INFO',
|
||||
WARN = 'WARN',
|
||||
ERROR = 'ERROR',
|
||||
DEBUG = 'DEBUG',
|
||||
SUCCESS = 'SUCCESS',
|
||||
FAILURE = 'FAILURE'
|
||||
}
|
||||
|
||||
export interface TestLog {
|
||||
testName: string;
|
||||
status: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
duration: number;
|
||||
steps: Array<{
|
||||
name: string;
|
||||
status: string;
|
||||
duration: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export class TestLogger {
|
||||
private prefix: string
|
||||
private logs: Array<{ level: LogLevel; message: string; timestamp: string; test?: string }> = []
|
||||
private testLogs: TestLog[] = []
|
||||
private currentTest: TestLog | null = null
|
||||
|
||||
constructor(prefix: string = 'Test') {
|
||||
this.prefix = prefix
|
||||
}
|
||||
|
||||
info(message: string, ...args: any[]) {
|
||||
console.log(`[${this.prefix}] INFO:`, message, ...args)
|
||||
this.logs.push({ level: LogLevel.INFO, message: JSON.stringify({ message, args }), timestamp: new Date().toISOString() })
|
||||
}
|
||||
|
||||
warn(message: string, ...args: any[]) {
|
||||
console.warn(`[${this.prefix}] WARN:`, message, ...args)
|
||||
this.logs.push({ level: LogLevel.WARN, message: JSON.stringify({ message, args }), timestamp: new Date().toISOString() })
|
||||
}
|
||||
|
||||
error(message: string, ...args: any[]) {
|
||||
console.error(`[${this.prefix}] ERROR:`, message, ...args)
|
||||
this.logs.push({ level: LogLevel.ERROR, message: JSON.stringify({ message, args }), timestamp: new Date().toISOString() })
|
||||
}
|
||||
|
||||
debug(message: string, ...args: any[]) {
|
||||
console.debug(`[${this.prefix}] DEBUG:`, message, ...args)
|
||||
this.logs.push({ level: LogLevel.DEBUG, message: JSON.stringify({ message, args }), timestamp: new Date().toISOString() })
|
||||
}
|
||||
|
||||
step(stepName: string) {
|
||||
console.log(`[${this.prefix}] STEP: ${stepName}`)
|
||||
}
|
||||
|
||||
success(message: string, ...args: any[]) {
|
||||
console.log(`[${this.prefix}] ✅ SUCCESS:`, message, ...args)
|
||||
this.logs.push({ level: LogLevel.SUCCESS, message: JSON.stringify({ message, args }), timestamp: new Date().toISOString() })
|
||||
}
|
||||
|
||||
failure(message: string, ...args: any[]) {
|
||||
console.error(`[${this.prefix}] ❌ FAILURE:`, message, ...args)
|
||||
this.logs.push({ level: LogLevel.FAILURE, message: JSON.stringify({ message, args }), timestamp: new Date().toISOString() })
|
||||
}
|
||||
|
||||
startStep(stepName: string) {
|
||||
console.log(`[${this.prefix}] START STEP: ${stepName}`)
|
||||
}
|
||||
|
||||
endStep(stepName: string, status: string, error?: Error) {
|
||||
console.log(`[${this.prefix}] END STEP: ${stepName} - ${status}`)
|
||||
}
|
||||
|
||||
getAllTestLogs(): TestLog[] {
|
||||
return this.testLogs
|
||||
}
|
||||
|
||||
getLogsByLevel(level: LogLevel): Array<{ level: LogLevel; message: string; timestamp: string; test?: string }> {
|
||||
return this.logs.filter(log => log.level === level)
|
||||
}
|
||||
}
|
||||
|
||||
export const testLogger = new TestLogger('E2E')
|
||||
|
||||
export default TestLogger
|
||||
@@ -0,0 +1,593 @@
|
||||
import { FullResult } from '@playwright/test';
|
||||
import { testLogger, TestLog, LogLevel } from './test-logger';
|
||||
import { testConfig } from './test-config';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
|
||||
export interface TestSummary {
|
||||
total: number;
|
||||
passed: number;
|
||||
failed: number;
|
||||
skipped: number;
|
||||
duration: number;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
}
|
||||
|
||||
export interface TestReport {
|
||||
summary: TestSummary;
|
||||
testLogs: TestLog[];
|
||||
environment: {
|
||||
name: string;
|
||||
baseURL: string;
|
||||
mockEnabled: boolean;
|
||||
mockMode: string;
|
||||
};
|
||||
errors: Array<{
|
||||
testName: string;
|
||||
error: Error;
|
||||
timestamp: string;
|
||||
}>;
|
||||
screenshots: string[];
|
||||
}
|
||||
|
||||
class TestReporter {
|
||||
private static instance: TestReporter;
|
||||
private report: TestReport;
|
||||
private startTime: string = '';
|
||||
|
||||
private constructor() {
|
||||
this.report = this.initializeReport();
|
||||
}
|
||||
|
||||
static getInstance(): TestReporter {
|
||||
if (!TestReporter.instance) {
|
||||
TestReporter.instance = new TestReporter();
|
||||
}
|
||||
return TestReporter.instance;
|
||||
}
|
||||
|
||||
private initializeReport(): TestReport {
|
||||
return {
|
||||
summary: {
|
||||
total: 0,
|
||||
passed: 0,
|
||||
failed: 0,
|
||||
skipped: 0,
|
||||
duration: 0,
|
||||
startTime: new Date().toISOString(),
|
||||
endTime: ''
|
||||
},
|
||||
testLogs: [],
|
||||
environment: {
|
||||
name: testConfig.getEnvironment().name,
|
||||
baseURL: testConfig.getBaseURL(),
|
||||
mockEnabled: testConfig.isMockEnabled(),
|
||||
mockMode: testConfig.getMockMode()
|
||||
},
|
||||
errors: [],
|
||||
screenshots: []
|
||||
};
|
||||
}
|
||||
|
||||
startReport(): void {
|
||||
this.startTime = new Date().toISOString();
|
||||
this.report.summary.startTime = this.startTime;
|
||||
testLogger.info('开始生成测试报告');
|
||||
}
|
||||
|
||||
endReport(): void {
|
||||
const endTime = new Date().toISOString();
|
||||
this.report.summary.endTime = endTime;
|
||||
this.report.summary.duration = new Date(endTime).getTime() - new Date(this.startTime).getTime();
|
||||
this.report.testLogs = testLogger.getAllTestLogs();
|
||||
|
||||
const errorLogs = testLogger.getLogsByLevel(LogLevel.ERROR);
|
||||
this.report.errors = errorLogs.map(log => ({
|
||||
testName: log.test || 'unknown',
|
||||
error: new Error(log.message),
|
||||
timestamp: log.timestamp
|
||||
}));
|
||||
|
||||
testLogger.info('测试报告生成完成', {
|
||||
total: this.report.summary.total,
|
||||
passed: this.report.summary.passed,
|
||||
failed: this.report.summary.failed,
|
||||
skipped: this.report.summary.skipped,
|
||||
duration: this.report.summary.duration
|
||||
});
|
||||
}
|
||||
|
||||
updateSummary(results: FullResult): void {
|
||||
this.report.summary.total = results.expected;
|
||||
this.report.summary.passed = results.expected - results.failed - results.skipped;
|
||||
this.report.summary.failed = results.failed;
|
||||
this.report.summary.skipped = results.skipped;
|
||||
}
|
||||
|
||||
addScreenshot(screenshotPath: string): void {
|
||||
this.report.screenshots.push(screenshotPath);
|
||||
}
|
||||
|
||||
getReport(): TestReport {
|
||||
return this.report;
|
||||
}
|
||||
|
||||
getSummary(): TestSummary {
|
||||
return this.report.summary;
|
||||
}
|
||||
|
||||
async generateJSONReport(outputPath: string): Promise<void> {
|
||||
const dir = path.dirname(outputPath);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
|
||||
const jsonContent = JSON.stringify(this.report, null, 2);
|
||||
await fs.writeFile(outputPath, jsonContent, 'utf-8');
|
||||
|
||||
testLogger.info(`JSON报告已生成: ${outputPath}`);
|
||||
}
|
||||
|
||||
async generateHTMLReport(outputPath: string): Promise<void> {
|
||||
const dir = path.dirname(outputPath);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
|
||||
const htmlContent = this.generateHTMLContent();
|
||||
await fs.writeFile(outputPath, htmlContent, 'utf-8');
|
||||
|
||||
testLogger.info(`HTML报告已生成: ${outputPath}`);
|
||||
}
|
||||
|
||||
private generateHTMLContent(): string {
|
||||
const { summary, testLogs, environment, errors, screenshots } = this.report;
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>E2E测试报告</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica', 'Arial', sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
background-color: #f5f5f5;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 28px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.header .meta {
|
||||
font-size: 14px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
padding: 30px;
|
||||
background: #f9fafb;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.summary-card h3 {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
margin-bottom: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.summary-card .value {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.summary-card.passed .value {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.summary-card.failed .value {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.summary-card.skipped .value {
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.environment {
|
||||
padding: 20px 30px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.environment h2 {
|
||||
font-size: 18px;
|
||||
margin-bottom: 15px;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.environment-info {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.environment-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.environment-item label {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.environment-item span {
|
||||
font-size: 14px;
|
||||
color: #111827;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.test-results {
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
.test-results h2 {
|
||||
font-size: 18px;
|
||||
margin-bottom: 20px;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.test-item {
|
||||
background: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 15px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.test-header {
|
||||
padding: 15px 20px;
|
||||
background: #f9fafb;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.test-header .name {
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.test-header .status {
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.test-header .status.passed {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.test-header .status.failed {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.test-header .status.skipped {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.test-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.test-meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 15px;
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.test-steps {
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.test-steps h4 {
|
||||
font-size: 14px;
|
||||
margin-bottom: 10px;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.step-item {
|
||||
padding: 10px;
|
||||
margin-bottom: 8px;
|
||||
background: #f9fafb;
|
||||
border-radius: 4px;
|
||||
border-left: 3px solid #d1d5db;
|
||||
}
|
||||
|
||||
.step-item.passed {
|
||||
border-left-color: #10b981;
|
||||
}
|
||||
|
||||
.step-item.failed {
|
||||
border-left-color: #ef4444;
|
||||
}
|
||||
|
||||
.step-item.skipped {
|
||||
border-left-color: #f59e0b;
|
||||
}
|
||||
|
||||
.step-item .name {
|
||||
font-weight: 500;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.step-item .duration {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.errors {
|
||||
padding: 30px;
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.errors h2 {
|
||||
font-size: 18px;
|
||||
margin-bottom: 20px;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.error-item {
|
||||
background: white;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.error-item .test-name {
|
||||
font-weight: 600;
|
||||
color: #991b1b;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.error-item .message {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
color: #7f1d1d;
|
||||
background: #fef2f2;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.error-item .timestamp {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.screenshots {
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
.screenshots h2 {
|
||||
font-size: 18px;
|
||||
margin-bottom: 20px;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.screenshot-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.screenshot-item {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.screenshot-item img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.screenshot-item .path {
|
||||
padding: 10px;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
background: #f9fafb;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding: 20px 30px;
|
||||
background: #f9fafb;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>E2E测试报告</h1>
|
||||
<div class="meta">
|
||||
生成时间: ${new Date().toLocaleString('zh-CN')} |
|
||||
测试环境: ${environment.name} |
|
||||
Mock模式: ${environment.mockMode}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="summary">
|
||||
<div class="summary-card">
|
||||
<h3>总测试数</h3>
|
||||
<div class="value">${summary.total}</div>
|
||||
</div>
|
||||
<div class="summary-card passed">
|
||||
<h3>通过</h3>
|
||||
<div class="value">${summary.passed}</div>
|
||||
</div>
|
||||
<div class="summary-card failed">
|
||||
<h3>失败</h3>
|
||||
<div class="value">${summary.failed}</div>
|
||||
</div>
|
||||
<div class="summary-card skipped">
|
||||
<h3>跳过</h3>
|
||||
<div class="value">${summary.skipped}</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<h3>总耗时</h3>
|
||||
<div class="value">${(summary.duration / 1000).toFixed(2)}s</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="environment">
|
||||
<h2>测试环境</h2>
|
||||
<div class="environment-info">
|
||||
<div class="environment-item">
|
||||
<label>环境名称</label>
|
||||
<span>${environment.name}</span>
|
||||
</div>
|
||||
<div class="environment-item">
|
||||
<label>基础URL</label>
|
||||
<span>${environment.baseURL}</span>
|
||||
</div>
|
||||
<div class="environment-item">
|
||||
<label>Mock启用</label>
|
||||
<span>${environment.mockEnabled ? '是' : '否'}</span>
|
||||
</div>
|
||||
<div class="environment-item">
|
||||
<label>Mock模式</label>
|
||||
<span>${environment.mockMode}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="test-results">
|
||||
<h2>测试结果</h2>
|
||||
${testLogs.map(log => `
|
||||
<div class="test-item">
|
||||
<div class="test-header">
|
||||
<span class="name">${log.testName}</span>
|
||||
<span class="status ${log.status}">${log.status}</span>
|
||||
</div>
|
||||
<div class="test-body">
|
||||
<div class="test-meta">
|
||||
<div>开始时间: ${new Date(log.startTime).toLocaleString('zh-CN')}</div>
|
||||
<div>结束时间: ${new Date(log.endTime).toLocaleString('zh-CN')}</div>
|
||||
<div>耗时: ${(log.duration / 1000).toFixed(2)}s</div>
|
||||
</div>
|
||||
${log.steps.length > 0 ? `
|
||||
<div class="test-steps">
|
||||
<h4>测试步骤</h4>
|
||||
${log.steps.map(step => `
|
||||
<div class="step-item ${step.status}">
|
||||
<div class="name">${step.name}</div>
|
||||
<div class="duration">耗时: ${(step.duration / 1000).toFixed(2)}s</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
|
||||
${errors.length > 0 ? `
|
||||
<div class="errors">
|
||||
<h2>错误详情 (${errors.length})</h2>
|
||||
${errors.map(error => `
|
||||
<div class="error-item">
|
||||
<div class="test-name">${error.testName}</div>
|
||||
<div class="message">${error.error.message}</div>
|
||||
<div class="timestamp">${new Date(error.timestamp).toLocaleString('zh-CN')}</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${screenshots.length > 0 ? `
|
||||
<div class="screenshots">
|
||||
<h2>截图 (${screenshots.length})</h2>
|
||||
<div class="screenshot-grid">
|
||||
${screenshots.map(screenshot => `
|
||||
<div class="screenshot-item">
|
||||
<img src="${screenshot}" alt="Screenshot">
|
||||
<div class="path">${screenshot}</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="footer">
|
||||
E2E测试报告 - 由Playwright生成
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
async generateAllReports(outputDir: string): Promise<void> {
|
||||
await fs.mkdir(outputDir, { recursive: true });
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
|
||||
await this.generateJSONReport(path.join(outputDir, `e2e-report-${timestamp}.json`));
|
||||
await this.generateHTMLReport(path.join(outputDir, `e2e-report-${timestamp}.html`));
|
||||
|
||||
testLogger.info(`所有报告已生成到目录: ${outputDir}`);
|
||||
}
|
||||
}
|
||||
|
||||
export const testReporter = TestReporter.getInstance();
|
||||
@@ -0,0 +1,88 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { MockManager } from './mock-manager';
|
||||
|
||||
test('调试登录功能', async ({ page }) => {
|
||||
const mockManager = new MockManager({
|
||||
enabled: true,
|
||||
mode: 'full',
|
||||
mockPaths: [],
|
||||
delay: 0,
|
||||
logCalls: true,
|
||||
validateResponses: true,
|
||||
dataSource: 'memory'
|
||||
});
|
||||
|
||||
mockManager.presetTestData({
|
||||
menus: [
|
||||
{
|
||||
id: 1,
|
||||
name: '仪表盘',
|
||||
code: 'dashboard',
|
||||
path: '/dashboard',
|
||||
icon: 'DashboardOutlined',
|
||||
sortOrder: 1,
|
||||
status: 'active',
|
||||
parentId: 0,
|
||||
createBy: 'system',
|
||||
updateBy: 'system',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
children: []
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
await mockManager.interceptAPIRequest(page);
|
||||
|
||||
page.on('console', msg => {
|
||||
console.log('Browser Console:', msg.type(), msg.text());
|
||||
});
|
||||
|
||||
page.on('pageerror', error => {
|
||||
console.log('Browser Error:', error.message);
|
||||
});
|
||||
|
||||
page.on('request', request => {
|
||||
console.log('Request:', request.method(), request.url());
|
||||
});
|
||||
|
||||
page.on('response', async response => {
|
||||
const url = response.url();
|
||||
if (url.includes('/sys/auth/login')) {
|
||||
console.log('Login Response Status:', response.status());
|
||||
console.log('Login Response Headers:', response.headers());
|
||||
const body = await response.text();
|
||||
console.log('Login Response Body:', body);
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const usernameInput = page.locator('input[placeholder="请输入用户名"]');
|
||||
const passwordInput = page.locator('input[placeholder="请输入密码"]');
|
||||
const loginButton = page.locator('button[type="submit"]');
|
||||
|
||||
await usernameInput.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await passwordInput.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await loginButton.waitFor({ state: 'visible', timeout: 10000 });
|
||||
|
||||
await usernameInput.fill('admin');
|
||||
await passwordInput.fill('admin123');
|
||||
|
||||
await loginButton.click();
|
||||
|
||||
await page.waitForTimeout(5000);
|
||||
|
||||
const currentUrl = page.url();
|
||||
console.log('Current URL after login:', currentUrl);
|
||||
|
||||
const errorMessage = page.locator('.ant-message-error');
|
||||
const hasError = await errorMessage.count() > 0;
|
||||
if (hasError) {
|
||||
const errorText = await errorMessage.textContent();
|
||||
console.log('Error message found:', errorText);
|
||||
}
|
||||
|
||||
await page.screenshot({ path: 'debug-login.png' });
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { MockManager } from './mock-manager';
|
||||
|
||||
test.describe('调试登出功能', () => {
|
||||
test('详细调试登出流程', async ({ page }) => {
|
||||
const mockManager = new MockManager({
|
||||
enabled: true,
|
||||
mode: 'full',
|
||||
mockPaths: [],
|
||||
delay: 0,
|
||||
logCalls: true,
|
||||
validateResponses: true,
|
||||
dataSource: 'memory'
|
||||
});
|
||||
|
||||
mockManager.presetTestData({
|
||||
menus: [
|
||||
{
|
||||
id: 1,
|
||||
name: '仪表盘',
|
||||
code: 'dashboard',
|
||||
path: '/dashboard',
|
||||
icon: 'DashboardOutlined',
|
||||
sortOrder: 1,
|
||||
status: 'active',
|
||||
parentId: 0,
|
||||
createBy: 'system',
|
||||
updateBy: 'system',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
children: []
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
await mockManager.interceptAPIRequest(page);
|
||||
|
||||
page.on('console', msg => {
|
||||
console.log('Browser Console:', msg.type(), msg.text());
|
||||
});
|
||||
|
||||
page.on('pageerror', error => {
|
||||
console.log('Browser Error:', error.message);
|
||||
});
|
||||
|
||||
await page.goto('/login');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const usernameInput = page.locator('input[placeholder="请输入用户名"]');
|
||||
const passwordInput = page.locator('input[placeholder="请输入密码"]');
|
||||
const loginButton = page.locator('button[type="submit"]');
|
||||
|
||||
await usernameInput.fill('admin');
|
||||
await passwordInput.fill('admin123');
|
||||
await loginButton.click();
|
||||
|
||||
await page.waitForURL(/.*dashboard/, { timeout: 15000 });
|
||||
console.log('Successfully navigated to dashboard');
|
||||
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const pageContent = await page.content();
|
||||
console.log('Page HTML length:', pageContent.length);
|
||||
|
||||
const hasDropdownLink = await page.locator('.ant-dropdown-link').count();
|
||||
console.log('Found .ant-dropdown-link elements:', hasDropdownLink);
|
||||
|
||||
const hasHeaderRight = await page.locator('.header-right').count();
|
||||
console.log('Found .header-right elements:', hasHeaderRight);
|
||||
|
||||
const hasUserIcon = await page.locator('.anticon-user').count();
|
||||
console.log('Found user icon elements:', hasUserIcon);
|
||||
|
||||
const allButtons = await page.locator('button').all();
|
||||
console.log('Total buttons on page:', allButtons.length);
|
||||
|
||||
const allAnchors = await page.locator('a').all();
|
||||
console.log('Total anchors on page:', allAnchors.length);
|
||||
|
||||
for (let i = 0; i < allAnchors.length; i++) {
|
||||
const anchor = allAnchors[i];
|
||||
const text = await anchor.textContent();
|
||||
const className = await anchor.getAttribute('class');
|
||||
console.log(`Anchor ${i}: text="${text}", class="${className}"`);
|
||||
}
|
||||
|
||||
const allMenuItems = await page.locator('.ant-dropdown-menu-item').all();
|
||||
console.log('Total dropdown menu items:', allMenuItems.length);
|
||||
|
||||
const logoutMenuItems = await page.locator('.ant-dropdown-menu-item').filter({ hasText: /退出/i }).all();
|
||||
console.log('Found logout menu items:', logoutMenuItems.length);
|
||||
|
||||
await page.screenshot({ path: 'debug-logout-dashboard.png', fullPage: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('debug-white-screen', async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
const consoleMessages: string[] = [];
|
||||
|
||||
page.on('pageerror', error => {
|
||||
errors.push(`Page Error: ${error.message}\nStack: ${error.stack}`);
|
||||
});
|
||||
|
||||
page.on('console', msg => {
|
||||
consoleMessages.push(`[${msg.type()}] ${msg.text()}`);
|
||||
});
|
||||
|
||||
page.on('requestfailed', request => {
|
||||
consoleMessages.push(`[REQUEST FAILED] ${request.url()} - ${request.failure()?.errorText}`);
|
||||
});
|
||||
|
||||
await page.goto('http://localhost:5173', { waitUntil: 'networkidle' });
|
||||
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
console.log('\n=== Console Messages ===');
|
||||
consoleMessages.forEach(msg => console.log(msg));
|
||||
|
||||
console.log('\n=== Page Errors ===');
|
||||
errors.forEach(error => console.log(error));
|
||||
|
||||
const pageContent = await page.content();
|
||||
console.log('\n=== Page Content (first 500 chars) ===');
|
||||
console.log(pageContent.substring(0, 500));
|
||||
|
||||
const bodyText = await page.locator('body').textContent();
|
||||
console.log('\n=== Body Text ===');
|
||||
console.log(bodyText);
|
||||
|
||||
await page.screenshot({ path: 'test-results/debug-white-screen.png', fullPage: true });
|
||||
|
||||
expect(errors.length).toBe(0);
|
||||
});
|
||||
@@ -0,0 +1,360 @@
|
||||
import { test, expect } from '../test-fixtures';
|
||||
|
||||
test.describe('登录功能测试', () => {
|
||||
test.beforeEach(async ({ testLogger }) => {
|
||||
testLogger.info('开始登录功能测试套件');
|
||||
});
|
||||
|
||||
test.afterEach(async ({ testLogger, helpers }) => {
|
||||
testLogger.info('登录功能测试用例完成');
|
||||
await helpers.screenshot.takeScreenshot('after-test');
|
||||
});
|
||||
|
||||
test('成功登录', async ({ pageObjects, testData, testLogger, helpers }) => {
|
||||
testLogger.startTest('成功登录');
|
||||
|
||||
try {
|
||||
await pageObjects.loginPage.navigate();
|
||||
await pageObjects.loginPage.login(testData.admin.username, testData.admin.password);
|
||||
|
||||
await pageObjects.dashboardPage.waitForLoad();
|
||||
|
||||
const pageTitle = await pageObjects.dashboardPage.getPageTitle();
|
||||
expect(pageTitle).toContain('仪表盘');
|
||||
|
||||
testLogger.endTest('成功登录', 'passed');
|
||||
} catch (error) {
|
||||
testLogger.endTest('成功登录', 'failed', error as Error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
test('登录失败 - 用户名错误', async ({ pageObjects, testData, testLogger }) => {
|
||||
testLogger.startTest('登录失败 - 用户名错误');
|
||||
|
||||
try {
|
||||
await pageObjects.loginPage.navigate();
|
||||
await pageObjects.loginPage.login('wronguser', testData.admin.password);
|
||||
|
||||
const errorMessage = await pageObjects.loginPage.getErrorMessage();
|
||||
expect(errorMessage).toBeTruthy();
|
||||
|
||||
testLogger.endTest('登录失败 - 用户名错误', 'passed');
|
||||
} catch (error) {
|
||||
testLogger.endTest('登录失败 - 用户名错误', 'failed', error as Error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
test('登录失败 - 密码错误', async ({ pageObjects, testData, testLogger }) => {
|
||||
testLogger.startTest('登录失败 - 密码错误');
|
||||
|
||||
try {
|
||||
await pageObjects.loginPage.navigate();
|
||||
await pageObjects.loginPage.login(testData.admin.username, 'wrongpassword');
|
||||
|
||||
const errorMessage = await pageObjects.loginPage.getErrorMessage();
|
||||
expect(errorMessage).toBeTruthy();
|
||||
|
||||
testLogger.endTest('登录失败 - 密码错误', 'passed');
|
||||
} catch (error) {
|
||||
testLogger.endTest('登录失败 - 密码错误', 'failed', error as Error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('用户管理功能测试', () => {
|
||||
test.beforeEach(async ({ pageObjects, testData, testLogger }) => {
|
||||
testLogger.info('开始用户管理功能测试套件');
|
||||
|
||||
await pageObjects.loginPage.navigate();
|
||||
await pageObjects.loginPage.login(testData.admin.username, testData.admin.password);
|
||||
await pageObjects.dashboardPage.waitForLoad();
|
||||
});
|
||||
|
||||
test.afterEach(async ({ testLogger, helpers }) => {
|
||||
testLogger.info('用户管理功能测试用例完成');
|
||||
await helpers.screenshot.takeScreenshot('after-test');
|
||||
});
|
||||
|
||||
test('创建新用户', async ({ pageObjects, testData, testLogger, helpers }) => {
|
||||
testLogger.startTest('创建新用户');
|
||||
|
||||
try {
|
||||
await pageObjects.userManagementPage.navigate();
|
||||
await pageObjects.userManagementPage.waitForLoad();
|
||||
|
||||
await pageObjects.userManagementPage.clickAddUser();
|
||||
|
||||
await helpers.form.fillForm({
|
||||
username: testData.user.username,
|
||||
password: testData.user.password,
|
||||
email: testData.user.email,
|
||||
phone: testData.user.phone,
|
||||
realName: testData.user.realName,
|
||||
status: testData.user.status
|
||||
});
|
||||
|
||||
await helpers.form.submitForm();
|
||||
|
||||
const successMessage = await pageObjects.userManagementPage.getSuccessMessage();
|
||||
expect(successMessage).toBeTruthy();
|
||||
|
||||
testLogger.endTest('创建新用户', 'passed');
|
||||
} catch (error) {
|
||||
testLogger.endTest('创建新用户', 'failed', error as Error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
test('搜索用户', async ({ pageObjects, testData, testLogger }) => {
|
||||
testLogger.startTest('搜索用户');
|
||||
|
||||
try {
|
||||
await pageObjects.userManagementPage.navigate();
|
||||
await pageObjects.userManagementPage.waitForLoad();
|
||||
|
||||
await pageObjects.userManagementPage.searchUser(testData.user.username);
|
||||
|
||||
const rowCount = await helpers.table.getRowCount('.ant-table');
|
||||
expect(rowCount).toBeGreaterThan(0);
|
||||
|
||||
testLogger.endTest('搜索用户', 'passed');
|
||||
} catch (error) {
|
||||
testLogger.endTest('搜索用户', 'failed', error as Error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
test('编辑用户', async ({ pageObjects, testData, testLogger, helpers }) => {
|
||||
testLogger.startTest('编辑用户');
|
||||
|
||||
try {
|
||||
await pageObjects.userManagementPage.navigate();
|
||||
await pageObjects.userManagementPage.waitForLoad();
|
||||
|
||||
await pageObjects.userManagementPage.searchUser(testData.user.username);
|
||||
|
||||
await pageObjects.userManagementPage.clickEditUser(0);
|
||||
|
||||
const updatedEmail = 'updated@example.com';
|
||||
await helpers.form.fillField('input[type="email"]', updatedEmail);
|
||||
|
||||
await helpers.form.submitForm();
|
||||
|
||||
const successMessage = await pageObjects.userManagementPage.getSuccessMessage();
|
||||
expect(successMessage).toBeTruthy();
|
||||
|
||||
testLogger.endTest('编辑用户', 'passed');
|
||||
} catch (error) {
|
||||
testLogger.endTest('编辑用户', 'failed', error as Error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
test('删除用户', async ({ pageObjects, testData, testLogger }) => {
|
||||
testLogger.startTest('删除用户');
|
||||
|
||||
try {
|
||||
await pageObjects.userManagementPage.navigate();
|
||||
await pageObjects.userManagementPage.waitForLoad();
|
||||
|
||||
await pageObjects.userManagementPage.searchUser(testData.user.username);
|
||||
|
||||
await pageObjects.userManagementPage.clickDeleteUser(0);
|
||||
|
||||
await pageObjects.userManagementPage.confirmDelete();
|
||||
|
||||
const successMessage = await pageObjects.userManagementPage.getSuccessMessage();
|
||||
expect(successMessage).toBeTruthy();
|
||||
|
||||
testLogger.endTest('删除用户', 'passed');
|
||||
} catch (error) {
|
||||
testLogger.endTest('删除用户', 'failed', error as Error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('角色管理功能测试', () => {
|
||||
test.beforeEach(async ({ pageObjects, testData, testLogger }) => {
|
||||
testLogger.info('开始角色管理功能测试套件');
|
||||
|
||||
await pageObjects.loginPage.navigate();
|
||||
await pageObjects.loginPage.login(testData.admin.username, testData.admin.password);
|
||||
await pageObjects.dashboardPage.waitForLoad();
|
||||
});
|
||||
|
||||
test.afterEach(async ({ testLogger, helpers }) => {
|
||||
testLogger.info('角色管理功能测试用例完成');
|
||||
await helpers.screenshot.takeScreenshot('after-test');
|
||||
});
|
||||
|
||||
test('创建新角色', async ({ pageObjects, testData, testLogger, helpers }) => {
|
||||
testLogger.startTest('创建新角色');
|
||||
|
||||
try {
|
||||
await pageObjects.roleManagementPage.navigate();
|
||||
await pageObjects.roleManagementPage.waitForLoad();
|
||||
|
||||
await pageObjects.roleManagementPage.clickAddRole();
|
||||
|
||||
await helpers.form.fillForm({
|
||||
roleName: testData.role.roleName,
|
||||
roleCode: testData.role.roleCode,
|
||||
status: testData.role.status
|
||||
});
|
||||
|
||||
await helpers.form.submitForm();
|
||||
|
||||
const successMessage = await pageObjects.roleManagementPage.getSuccessMessage();
|
||||
expect(successMessage).toBeTruthy();
|
||||
|
||||
testLogger.endTest('创建新角色', 'passed');
|
||||
} catch (error) {
|
||||
testLogger.endTest('创建新角色', 'failed', error as Error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
test('分配权限给角色', async ({ pageObjects, testData, testLogger }) => {
|
||||
testLogger.startTest('分配权限给角色');
|
||||
|
||||
try {
|
||||
await pageObjects.roleManagementPage.navigate();
|
||||
await pageObjects.roleManagementPage.waitForLoad();
|
||||
|
||||
await pageObjects.roleManagementPage.clickAssignPermissions(testData.role.roleCode);
|
||||
|
||||
await pageObjects.roleManagementPage.selectPermissions(['user:view', 'user:add']);
|
||||
|
||||
await pageObjects.roleManagementPage.savePermissions();
|
||||
|
||||
const successMessage = await pageObjects.roleManagementPage.getSuccessMessage();
|
||||
expect(successMessage).toBeTruthy();
|
||||
|
||||
testLogger.endTest('分配权限给角色', 'passed');
|
||||
} catch (error) {
|
||||
testLogger.endTest('分配权限给角色', 'failed', error as Error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('菜单管理功能测试', () => {
|
||||
test.beforeEach(async ({ pageObjects, testData, testLogger }) => {
|
||||
testLogger.info('开始菜单管理功能测试套件');
|
||||
|
||||
await pageObjects.loginPage.navigate();
|
||||
await pageObjects.loginPage.login(testData.admin.username, testData.admin.password);
|
||||
await pageObjects.dashboardPage.waitForLoad();
|
||||
});
|
||||
|
||||
test.afterEach(async ({ testLogger, helpers }) => {
|
||||
testLogger.info('菜单管理功能测试用例完成');
|
||||
await helpers.screenshot.takeScreenshot('after-test');
|
||||
});
|
||||
|
||||
test('创建新菜单', async ({ pageObjects, testData, testLogger, helpers }) => {
|
||||
testLogger.startTest('创建新菜单');
|
||||
|
||||
try {
|
||||
await pageObjects.menuManagementPage.navigate();
|
||||
await pageObjects.menuManagementPage.waitForLoad();
|
||||
|
||||
await pageObjects.menuManagementPage.clickAddMenu();
|
||||
|
||||
await helpers.form.fillForm({
|
||||
menuName: testData.menu.menuName,
|
||||
menuType: testData.menu.menuType,
|
||||
path: testData.menu.path,
|
||||
status: testData.menu.status
|
||||
});
|
||||
|
||||
await helpers.form.submitForm();
|
||||
|
||||
const successMessage = await pageObjects.menuManagementPage.getSuccessMessage();
|
||||
expect(successMessage).toBeTruthy();
|
||||
|
||||
testLogger.endTest('创建新菜单', 'passed');
|
||||
} catch (error) {
|
||||
testLogger.endTest('创建新菜单', 'failed', error as Error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
test('菜单排序', async ({ pageObjects, testLogger }) => {
|
||||
testLogger.startTest('菜单排序');
|
||||
|
||||
try {
|
||||
await pageObjects.menuManagementPage.navigate();
|
||||
await pageObjects.menuManagementPage.waitForLoad();
|
||||
|
||||
await pageObjects.menuManagementPage.dragMenu(0, 1);
|
||||
|
||||
const successMessage = await pageObjects.menuManagementPage.getSuccessMessage();
|
||||
expect(successMessage).toBeTruthy();
|
||||
|
||||
testLogger.endTest('菜单排序', 'passed');
|
||||
} catch (error) {
|
||||
testLogger.endTest('菜单排序', 'failed', error as Error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('端到端测试', () => {
|
||||
test('完整的用户管理流程', async ({ pageObjects, testData, testLogger, helpers }) => {
|
||||
testLogger.startTest('完整的用户管理流程');
|
||||
|
||||
try {
|
||||
testLogger.startStep('用户登录');
|
||||
await pageObjects.loginPage.navigate();
|
||||
await pageObjects.loginPage.login(testData.admin.username, testData.admin.password);
|
||||
await pageObjects.dashboardPage.waitForLoad();
|
||||
testLogger.endStep('用户登录', 'passed');
|
||||
|
||||
testLogger.startStep('创建用户');
|
||||
await pageObjects.userManagementPage.navigate();
|
||||
await pageObjects.userManagementPage.waitForLoad();
|
||||
await pageObjects.userManagementPage.clickAddUser();
|
||||
|
||||
await helpers.form.fillForm({
|
||||
username: testData.user.username,
|
||||
password: testData.user.password,
|
||||
email: testData.user.email,
|
||||
phone: testData.user.phone,
|
||||
realName: testData.user.realName,
|
||||
status: testData.user.status
|
||||
});
|
||||
|
||||
await helpers.form.submitForm();
|
||||
testLogger.endStep('创建用户', 'passed');
|
||||
|
||||
testLogger.startStep('搜索用户');
|
||||
await pageObjects.userManagementPage.searchUser(testData.user.username);
|
||||
const rowCount = await helpers.table.getRowCount('.ant-table');
|
||||
expect(rowCount).toBeGreaterThan(0);
|
||||
testLogger.endStep('搜索用户', 'passed');
|
||||
|
||||
testLogger.startStep('编辑用户');
|
||||
await pageObjects.userManagementPage.clickEditUser(0);
|
||||
const updatedEmail = 'updated@example.com';
|
||||
await helpers.form.fillField('input[type="email"]', updatedEmail);
|
||||
await helpers.form.submitForm();
|
||||
testLogger.endStep('编辑用户', 'passed');
|
||||
|
||||
testLogger.startStep('删除用户');
|
||||
await pageObjects.userManagementPage.searchUser(testData.user.username);
|
||||
await pageObjects.userManagementPage.clickDeleteUser(0);
|
||||
await pageObjects.userManagementPage.confirmDelete();
|
||||
testLogger.endStep('删除用户', 'passed');
|
||||
|
||||
testLogger.endTest('完整的用户管理流程', 'passed');
|
||||
} catch (error) {
|
||||
testLogger.endTest('完整的用户管理流程', 'failed', error as Error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { FullConfig } from '@playwright/test';
|
||||
|
||||
async function globalSetup(config: FullConfig) {
|
||||
console.log('🚀 E2E测试全局设置开始...');
|
||||
|
||||
const mockEnabled = process.env.E2E_MOCK_ENABLED === 'true';
|
||||
const mockMode = process.env.E2E_MOCK_MODE || 'none';
|
||||
|
||||
// 设置 E2E 测试标记,用于 request.ts 检测 E2E 测试环境
|
||||
process.env.VITE_E2E_TEST = 'true';
|
||||
|
||||
if (mockEnabled) {
|
||||
// 禁用应用内部的 mock-interceptor,只使用 Playwright 的 mock 拦截
|
||||
process.env.VITE_MOCK_ENABLED = 'false';
|
||||
process.env.E2E_MOCK_MODE = mockMode;
|
||||
console.log(`✅ Playwright Mock服务已启用 (模式: ${mockMode})`);
|
||||
console.log(`ℹ️ 应用内部 Mock 拦截器已禁用`);
|
||||
} else {
|
||||
process.env.VITE_MOCK_ENABLED = 'false';
|
||||
console.log('ℹ️ 所有 Mock 服务已禁用');
|
||||
}
|
||||
|
||||
console.log('✅ E2E测试全局设置完成');
|
||||
}
|
||||
|
||||
export default globalSetup;
|
||||
@@ -0,0 +1,15 @@
|
||||
import { FullConfig } from '@playwright/test';
|
||||
|
||||
async function globalTeardown(config: FullConfig) {
|
||||
console.log('🧹 E2E测试全局清理开始...');
|
||||
|
||||
const mockEnabled = process.env.E2E_MOCK_ENABLED === 'true';
|
||||
|
||||
if (mockEnabled) {
|
||||
console.log('✅ Mock数据已清理');
|
||||
}
|
||||
|
||||
console.log('✅ E2E测试全局清理完成');
|
||||
}
|
||||
|
||||
export default globalTeardown;
|
||||
@@ -0,0 +1,96 @@
|
||||
import { Page, Locator } from '@playwright/test'
|
||||
|
||||
export class FormHelper {
|
||||
private page: Page
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page
|
||||
}
|
||||
|
||||
async fillInput(selector: string, value: string, options?: { clear?: boolean; delay?: number }) {
|
||||
const input = this.page.locator(selector)
|
||||
|
||||
if (options?.clear) {
|
||||
await input.clear()
|
||||
}
|
||||
|
||||
await input.fill(value, { delay: options?.delay })
|
||||
}
|
||||
|
||||
async selectOption(selector: string, value: string) {
|
||||
const select = this.page.locator(selector)
|
||||
await select.selectOption(value)
|
||||
}
|
||||
|
||||
async checkCheckbox(selector: string) {
|
||||
const checkbox = this.page.locator(selector)
|
||||
await checkbox.check()
|
||||
}
|
||||
|
||||
async uncheckCheckbox(selector: string) {
|
||||
const checkbox = this.page.locator(selector)
|
||||
await checkbox.uncheck()
|
||||
}
|
||||
|
||||
async selectRadio(selector: string, value: string) {
|
||||
const radio = this.page.locator(`${selector}[value="${value}"]`)
|
||||
await radio.check()
|
||||
}
|
||||
|
||||
async uploadFile(selector: string, filePath: string) {
|
||||
const input = this.page.locator(selector)
|
||||
await input.setInputFiles(filePath)
|
||||
}
|
||||
|
||||
async submitForm(selector: string) {
|
||||
const form = this.page.locator(selector)
|
||||
await form.evaluate((form: HTMLFormElement) => form.submit())
|
||||
}
|
||||
|
||||
async resetForm(selector: string) {
|
||||
const form = this.page.locator(selector)
|
||||
await form.evaluate((form: HTMLFormElement) => form.reset())
|
||||
}
|
||||
|
||||
async getFieldValue(selector: string): Promise<string> {
|
||||
const field = this.page.locator(selector)
|
||||
return await field.inputValue()
|
||||
}
|
||||
|
||||
async isFieldValid(selector: string): Promise<boolean> {
|
||||
const field = this.page.locator(selector)
|
||||
const isValid = await field.evaluate((el: HTMLInputElement) => el.checkValidity())
|
||||
return isValid
|
||||
}
|
||||
|
||||
async getValidationMessage(selector: string): Promise<string> {
|
||||
const field = this.page.locator(selector)
|
||||
return await field.evaluate((el: HTMLInputElement) => el.validationMessage)
|
||||
}
|
||||
|
||||
async waitForFormToBeReady(selector: string, timeout: number = 5000) {
|
||||
await this.page.waitForSelector(selector, { state: 'visible', timeout })
|
||||
await this.page.waitForLoadState('networkidle', { timeout })
|
||||
}
|
||||
|
||||
async fillForm(fields: Array<{ selector: string; value: string; type?: 'input' | 'select' | 'checkbox' }>) {
|
||||
for (const field of fields) {
|
||||
switch (field.type) {
|
||||
case 'select':
|
||||
await this.selectOption(field.selector, field.value)
|
||||
break
|
||||
case 'checkbox':
|
||||
if (field.value === 'true' || field.value === 'checked') {
|
||||
await this.checkCheckbox(field.selector)
|
||||
} else {
|
||||
await this.uncheckCheckbox(field.selector)
|
||||
}
|
||||
break
|
||||
default:
|
||||
await this.fillInput(field.selector, field.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default FormHelper
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Page } from '@playwright/test'
|
||||
|
||||
export class PerformanceMetrics {
|
||||
private metrics: Map<string, number[]> = new Map()
|
||||
|
||||
recordMetric(name: string, value: number) {
|
||||
if (!this.metrics.has(name)) {
|
||||
this.metrics.set(name, [])
|
||||
}
|
||||
this.metrics.get(name)!.push(value)
|
||||
}
|
||||
|
||||
getAverage(name: string): number {
|
||||
const values = this.metrics.get(name) || []
|
||||
if (values.length === 0) return 0
|
||||
const sum = values.reduce((a, b) => a + b, 0)
|
||||
return sum / values.length
|
||||
}
|
||||
|
||||
getP95(name: string): number {
|
||||
const values = this.metrics.get(name) || []
|
||||
if (values.length === 0) return 0
|
||||
const sorted = [...values].sort((a, b) => a - b)
|
||||
const index = Math.floor(sorted.length * 0.95)
|
||||
return sorted[index]
|
||||
}
|
||||
|
||||
getP99(name: string): number {
|
||||
const values = this.metrics.get(name) || []
|
||||
if (values.length === 0) return 0
|
||||
const sorted = [...values].sort((a, b) => a - b)
|
||||
const index = Math.floor(sorted.length * 0.99)
|
||||
return sorted[index]
|
||||
}
|
||||
|
||||
getMax(name: string): number {
|
||||
const values = this.metrics.get(name) || []
|
||||
if (values.length === 0) return 0
|
||||
return Math.max(...values)
|
||||
}
|
||||
|
||||
getMin(name: string): number {
|
||||
const values = this.metrics.get(name) || []
|
||||
if (values.length === 0) return 0
|
||||
return Math.min(...values)
|
||||
}
|
||||
|
||||
printReport() {
|
||||
console.log('\n=== 性能测试报告 ===')
|
||||
for (const [name, values] of this.metrics.entries()) {
|
||||
console.log(`\n${name}:`)
|
||||
console.log(` 平均值: ${this.getAverage(name).toFixed(2)}ms`)
|
||||
console.log(` P95: ${this.getP95(name).toFixed(2)}ms`)
|
||||
console.log(` P99: ${this.getP99(name).toFixed(2)}ms`)
|
||||
console.log(` 最大值: ${this.getMax(name).toFixed(2)}ms`)
|
||||
console.log(` 最小值: ${this.getMin(name).toFixed(2)}ms`)
|
||||
console.log(` 样本数: ${values.length}`)
|
||||
}
|
||||
console.log('\n====================\n')
|
||||
}
|
||||
}
|
||||
|
||||
export class PerformanceTestHelper {
|
||||
async clearCacheAndCookies(page: Page) {
|
||||
const context = page.context()
|
||||
await context.clearCookies()
|
||||
await page.evaluate(() => {
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
})
|
||||
}
|
||||
|
||||
async measurePageLoad(page: Page, url: string): Promise<number> {
|
||||
const startTime = Date.now()
|
||||
await page.goto(url, { waitUntil: 'networkidle' })
|
||||
const endTime = Date.now()
|
||||
return endTime - startTime
|
||||
}
|
||||
|
||||
async measureApiCall(page: Page, apiCall: () => Promise<void>): Promise<number> {
|
||||
const startTime = Date.now()
|
||||
await apiCall()
|
||||
const endTime = Date.now()
|
||||
return endTime - startTime
|
||||
}
|
||||
|
||||
async measureElementInteraction(
|
||||
page: Page,
|
||||
selector: string,
|
||||
action: () => Promise<void>
|
||||
): Promise<number> {
|
||||
await page.waitForSelector(selector, { state: 'visible' })
|
||||
const startTime = Date.now()
|
||||
await action()
|
||||
const endTime = Date.now()
|
||||
return endTime - startTime
|
||||
}
|
||||
|
||||
async measurePageNavigation(
|
||||
page: Page,
|
||||
fromUrl: string,
|
||||
toUrl: string
|
||||
): Promise<number> {
|
||||
await page.goto(fromUrl, { waitUntil: 'networkidle' })
|
||||
const startTime = Date.now()
|
||||
await page.goto(toUrl, { waitUntil: 'networkidle' })
|
||||
const endTime = Date.now()
|
||||
return endTime - startTime
|
||||
}
|
||||
}
|
||||
|
||||
export default PerformanceTestHelper
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Page } from '@playwright/test'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
|
||||
export class ScreenshotHelper {
|
||||
private page: Page
|
||||
private screenshotDir: string
|
||||
|
||||
constructor(page: Page, screenshotDir: string = './test-results/screenshots') {
|
||||
this.page = page
|
||||
this.screenshotDir = screenshotDir
|
||||
this.ensureDirectoryExists(screenshotDir)
|
||||
}
|
||||
|
||||
private ensureDirectoryExists(dir: string) {
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
async takeScreenshot(name: string): Promise<string> {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
const filename = `${timestamp}-${name}.png`
|
||||
const filepath = path.join(this.screenshotDir, filename)
|
||||
|
||||
await this.page.screenshot({
|
||||
path: filepath,
|
||||
fullPage: true
|
||||
})
|
||||
|
||||
return filepath
|
||||
}
|
||||
|
||||
async takeElementScreenshot(selector: string, name: string): Promise<string> {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
const filename = `${timestamp}-${name}.png`
|
||||
const filepath = path.join(this.screenshotDir, filename)
|
||||
|
||||
const element = await this.page.locator(selector)
|
||||
await element.screenshot({
|
||||
path: filepath
|
||||
})
|
||||
|
||||
return filepath
|
||||
}
|
||||
|
||||
async compareScreenshots(name: string, baselineDir: string = './test-results/baseline'): Promise<boolean> {
|
||||
const baselinePath = path.join(baselineDir, `${name}.png`)
|
||||
const currentPath = await this.takeScreenshot(`${name}-current`)
|
||||
|
||||
if (!fs.existsSync(baselinePath)) {
|
||||
console.warn(`Baseline screenshot not found: ${baselinePath}`)
|
||||
return false
|
||||
}
|
||||
|
||||
// 这里可以添加图片比较逻辑
|
||||
// 例如使用 pixelmatch 或其他图片比较库
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export default ScreenshotHelper
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Page, Locator } from '@playwright/test'
|
||||
|
||||
export class TableHelper {
|
||||
private page: Page
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page
|
||||
}
|
||||
|
||||
async getRowCount(tableSelector: string): Promise<number> {
|
||||
const rows = await this.page.locator(`${tableSelector} tbody tr`).count()
|
||||
return rows
|
||||
}
|
||||
|
||||
async getCellText(tableSelector: string, row: number, column: number): Promise<string> {
|
||||
const cell = this.page.locator(`${tableSelector} tbody tr:nth-child(${row}) td:nth-child(${column})`)
|
||||
return await cell.textContent() || ''
|
||||
}
|
||||
|
||||
async getRowData(tableSelector: string, row: number): Promise<string[]> {
|
||||
const selector = `${tableSelector} tbody tr:nth-child(${row}) td`
|
||||
const cells = await this.page.locator(selector).allTextContents()
|
||||
return cells
|
||||
}
|
||||
|
||||
async clickRowAction(tableSelector: string, row: number, actionSelector: string) {
|
||||
const actionButton = this.page.locator(`${tableSelector} tbody tr:nth-child(${row}) ${actionSelector}`);
|
||||
await actionButton.click();
|
||||
}
|
||||
|
||||
async sortByColumn(tableSelector: string, column: number) {
|
||||
const header = this.page.locator(`${tableSelector} thead tr th:nth-child(${column})`)
|
||||
await header.click()
|
||||
}
|
||||
|
||||
async waitForTableToLoad(tableSelector: string, timeout: number = 5000) {
|
||||
await this.page.waitForSelector(`${tableSelector} tbody tr`, { state: 'visible', timeout })
|
||||
}
|
||||
|
||||
async getTableHeaders(tableSelector: string): Promise<string[]> {
|
||||
const headers = await this.page.locator(`${tableSelector} thead tr th`).allTextContents()
|
||||
return headers
|
||||
}
|
||||
|
||||
async findRowByText(tableSelector: string, text: string): Promise<number> {
|
||||
const rows = await this.page.locator(`${tableSelector} tbody tr`).all()
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const rowText = await rows[i].textContent()
|
||||
if (rowText?.includes(text)) {
|
||||
return i + 1
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
async selectRow(tableSelector: string, row: number) {
|
||||
const checkbox = this.page.locator(`${tableSelector} tbody tr:nth-child(${row}) input[type="checkbox"]`)
|
||||
await checkbox.check()
|
||||
}
|
||||
|
||||
async deselectRow(tableSelector: string, row: number) {
|
||||
const checkbox = this.page.locator(`${tableSelector} tbody tr:nth-child(${row}) input[type="checkbox"]`)
|
||||
await checkbox.uncheck()
|
||||
}
|
||||
|
||||
async selectAllRows(tableSelector: string) {
|
||||
const checkbox = this.page.locator(`${tableSelector} thead input[type="checkbox"]`)
|
||||
await checkbox.check()
|
||||
}
|
||||
|
||||
async getSelectedRows(tableSelector: string): Promise<number[]> {
|
||||
const checkboxes = await this.page.locator(`${tableSelector} tbody input[type="checkbox"]:checked`).all()
|
||||
const selectedRows: number[] = []
|
||||
|
||||
for (let i = 0; i < checkboxes.length; i++) {
|
||||
const row = await checkboxes[i].locator('xpath=ancestor::tr').evaluate((el, index) => {
|
||||
const rows = el.closest('tbody')?.querySelectorAll('tr')
|
||||
return rows ? Array.from(rows).indexOf(el.closest('tr') as HTMLTableRowElement) + 1 : -1
|
||||
}, i)
|
||||
if (row > 0) {
|
||||
selectedRows.push(row)
|
||||
}
|
||||
}
|
||||
|
||||
return selectedRows
|
||||
}
|
||||
}
|
||||
|
||||
export default TableHelper
|
||||
@@ -0,0 +1,77 @@
|
||||
import { test, expect } from './test-fixtures';
|
||||
|
||||
test.describe('菜单管理 - 完全Mock模式', () => {
|
||||
test.beforeEach(async ({ page, mockManager }) => {
|
||||
mockManager.enableMock();
|
||||
mockManager.configureMock({
|
||||
mode: 'full',
|
||||
delay: 100
|
||||
});
|
||||
|
||||
mockManager.presetTestData({
|
||||
menus: [
|
||||
{ id: 1, name: '系统管理', code: 'system', path: '/system', icon: 'SettingOutlined', parentId: 0, sortOrder: 1, status: 'ENABLED', component: '', redirect: '', description: '系统管理模块', children: [], createdAt: '2024-01-01T10:00:00.000Z', updatedAt: '2024-01-01T10:00:00.000Z' },
|
||||
{ id: 2, name: '用户管理', code: 'user', path: '/system/user', icon: 'UserOutlined', parentId: 1, sortOrder: 1, status: 'ENABLED', component: 'views/UserManagement.vue', redirect: '', description: '用户管理页面', children: [], createdAt: '2024-01-01T10:00:00.000Z', updatedAt: '2024-01-01T10:00:00.000Z' },
|
||||
{ id: 3, name: '角色管理', code: 'role', path: '/system/role', icon: 'TeamOutlined', parentId: 1, sortOrder: 2, status: 'ENABLED', component: 'views/RoleManagement.vue', redirect: '', description: '角色管理页面', children: [], createdAt: '2024-01-01T10:00:00.000Z', updatedAt: '2024-01-01T10:00:00.000Z' },
|
||||
{ id: 4, name: '菜单管理', code: 'menu', path: '/system/menu', icon: 'MenuOutlined', parentId: 1, sortOrder: 3, status: 'ENABLED', component: 'views/MenuManagement.vue', redirect: '', description: '菜单管理页面', children: [], createdAt: '2024-01-01T10:00:00.000Z', updatedAt: '2024-01-01T10:00:00.000Z' }
|
||||
]
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
await page.fill('input[placeholder="请输入用户名"]', 'admin');
|
||||
await page.fill('input[placeholder="请输入密码"]', 'admin123');
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForURL(/.*dashboard/, { timeout: 10000 });
|
||||
});
|
||||
|
||||
test.afterEach(async ({ mockManager }) => {
|
||||
mockManager.clearPresets();
|
||||
mockManager.disableMock();
|
||||
});
|
||||
|
||||
test('应该显示菜单列表', async ({ page }) => {
|
||||
await page.goto('/menus');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await expect(page.locator('.ant-table')).toBeVisible();
|
||||
await expect(page.locator('text=系统管理')).toBeVisible();
|
||||
await expect(page.locator('text=用户管理')).toBeVisible();
|
||||
await expect(page.locator('text=角色管理')).toBeVisible();
|
||||
await expect(page.locator('text=菜单管理')).toBeVisible();
|
||||
});
|
||||
|
||||
test('应该能够创建新菜单', async ({ page }) => {
|
||||
await page.goto('/menus');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await page.click('button:has-text("新增菜单")');
|
||||
await page.fill('input[placeholder="请输入菜单名称"]', '测试菜单');
|
||||
await page.fill('input[placeholder="请输入菜单标识"]', 'test_menu');
|
||||
await page.fill('input[placeholder="请输入路由路径"]', '/test/menu');
|
||||
await page.fill('input[placeholder="请输入组件路径"]', 'views/TestMenu.vue');
|
||||
await page.click('button:has-text("确定")');
|
||||
|
||||
await expect(page.locator('text=创建成功')).toBeVisible();
|
||||
});
|
||||
|
||||
test('应该能够编辑菜单', async ({ page }) => {
|
||||
await page.goto('/menus');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await page.click('button:has-text("编辑"):first');
|
||||
await page.fill('input[placeholder="请输入菜单名称"]', '更新后的菜单名称');
|
||||
await page.click('button:has-text("确定")');
|
||||
|
||||
await expect(page.locator('text=更新成功')).toBeVisible();
|
||||
});
|
||||
|
||||
test('应该能够删除菜单', async ({ page }) => {
|
||||
await page.goto('/menus');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
page.on('dialog', dialog => dialog.accept());
|
||||
await page.click('button:has-text("删除"):first');
|
||||
|
||||
await expect(page.locator('text=删除成功')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,238 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { MockManager } from './mock-manager';
|
||||
|
||||
test.describe('菜单管理', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
const mockManager = new MockManager({
|
||||
enabled: true,
|
||||
mode: 'full',
|
||||
mockPaths: [],
|
||||
delay: 0,
|
||||
logCalls: true,
|
||||
validateResponses: true,
|
||||
dataSource: 'memory'
|
||||
});
|
||||
|
||||
mockManager.presetTestData({
|
||||
menus: [
|
||||
{
|
||||
id: 1,
|
||||
name: '仪表盘',
|
||||
code: 'dashboard',
|
||||
path: '/dashboard',
|
||||
icon: 'DashboardOutlined',
|
||||
sortOrder: 1,
|
||||
status: 'active',
|
||||
parentId: 0,
|
||||
component: 'views/Dashboard.vue',
|
||||
createBy: 'system',
|
||||
updateBy: 'system',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
children: []
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: '菜单管理',
|
||||
code: 'menu',
|
||||
path: '/menus',
|
||||
icon: 'MenuOutlined',
|
||||
sortOrder: 4,
|
||||
status: 'active',
|
||||
parentId: 0,
|
||||
component: 'views/MenuManagement.vue',
|
||||
createBy: 'system',
|
||||
updateBy: 'system',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
children: []
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: '系统管理',
|
||||
code: 'system',
|
||||
path: '/system',
|
||||
icon: 'SettingOutlined',
|
||||
sortOrder: 5,
|
||||
status: 'active',
|
||||
parentId: 0,
|
||||
component: null,
|
||||
createBy: 'system',
|
||||
updateBy: 'system',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
children: [
|
||||
{
|
||||
id: 6,
|
||||
name: '用户管理',
|
||||
code: 'user',
|
||||
path: '/system/users',
|
||||
icon: 'UserOutlined',
|
||||
sortOrder: 1,
|
||||
status: 'active',
|
||||
parentId: 5,
|
||||
component: 'views/UserManagement.vue',
|
||||
createBy: 'system',
|
||||
updateBy: 'system',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
children: []
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
name: '角色管理',
|
||||
code: 'role',
|
||||
path: '/system/roles',
|
||||
icon: 'LockOutlined',
|
||||
sortOrder: 2,
|
||||
status: 'active',
|
||||
parentId: 5,
|
||||
component: 'views/RoleManagement.vue',
|
||||
createBy: 'system',
|
||||
updateBy: 'system',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
children: []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
await mockManager.interceptAPIRequest(page);
|
||||
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const usernameInput = page.locator('input[placeholder="请输入用户名"]');
|
||||
const passwordInput = page.locator('input[placeholder="请输入密码"]');
|
||||
const loginButton = page.locator('button[type="submit"]');
|
||||
|
||||
await usernameInput.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await passwordInput.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await loginButton.waitFor({ state: 'visible', timeout: 10000 });
|
||||
|
||||
await usernameInput.fill('admin');
|
||||
await passwordInput.fill('admin123');
|
||||
await loginButton.click();
|
||||
|
||||
await page.waitForURL(/.*dashboard/, { timeout: 10000 });
|
||||
});
|
||||
|
||||
test('应该显示菜单列表页面', async ({ page }) => {
|
||||
await page.goto('/menus');
|
||||
await expect(page.getByText(/菜单管理/i)).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /添加菜单/i })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /刷新/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('应该显示菜单数据表格', async ({ page }) => {
|
||||
await page.goto('/menus');
|
||||
|
||||
const table = page.locator('.ant-table');
|
||||
await expect(table).toBeVisible();
|
||||
|
||||
await expect(page.getByText(/菜单名称/i)).toBeVisible();
|
||||
await expect(page.getByText(/菜单编码/i)).toBeVisible();
|
||||
await expect(page.getByText(/菜单类型/i)).toBeVisible();
|
||||
await expect(page.getByText(/路径/i)).toBeVisible();
|
||||
await expect(page.getByText(/排序/i)).toBeVisible();
|
||||
await expect(page.getByText(/状态/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('应该能够创建新菜单', async ({ page }) => {
|
||||
await page.goto('/menus');
|
||||
|
||||
await page.getByRole('button', { name: /添加菜单/i }).click();
|
||||
await expect(page).toHaveURL(/.*menus\/create/);
|
||||
|
||||
await page.getByPlaceholder(/请输入菜单名称/i).fill('测试菜单');
|
||||
await page.getByPlaceholder(/请输入菜单编码/i).fill('TEST_MENU');
|
||||
await page.getByPlaceholder(/请输入路径/i).fill('/test-menu');
|
||||
await page.getByPlaceholder(/请输入组件路径/i).fill('@/views/test/Test.vue');
|
||||
await page.getByPlaceholder(/请输入排序/i).fill('100');
|
||||
|
||||
await page.getByRole('button', { name: /提交/i }).click();
|
||||
|
||||
await expect(page).toHaveURL(/.*menus/);
|
||||
await expect(page.getByText(/创建成功/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('创建菜单时应该验证必填字段', async ({ page }) => {
|
||||
await page.goto('/menus');
|
||||
await page.getByRole('button', { name: /添加菜单/i }).click();
|
||||
|
||||
await page.getByRole('button', { name: /提交/i }).click();
|
||||
|
||||
await expect(page.getByText(/请输入菜单名称/i)).toBeVisible();
|
||||
await expect(page.getByText(/请输入菜单编码/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('应该能够编辑菜单', async ({ page }) => {
|
||||
await page.goto('/menus');
|
||||
|
||||
const editButton = page.getByRole('button').filter({ hasText: /编辑/i }).first();
|
||||
if (await editButton.isVisible()) {
|
||||
await editButton.click();
|
||||
await expect(page).toHaveURL(/.*menus\/\d+\/edit/);
|
||||
|
||||
const menuNameInput = page.getByPlaceholder(/请输入菜单名称/i);
|
||||
await menuNameInput.clear();
|
||||
await menuNameInput.fill('更新后的菜单名称');
|
||||
|
||||
await page.getByRole('button', { name: /提交/i }).click();
|
||||
|
||||
await expect(page).toHaveURL(/.*menus/);
|
||||
await expect(page.getByText(/更新成功/i)).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('应该能够删除菜单', async ({ page }) => {
|
||||
await page.goto('/menus');
|
||||
|
||||
const deleteButton = page.getByRole('button').filter({ hasText: /删除/i }).first();
|
||||
if (await deleteButton.isVisible()) {
|
||||
await deleteButton.click();
|
||||
|
||||
await expect(page.getByText(/确认删除/i)).toBeVisible();
|
||||
await page.getByRole('button', { name: /确认/i }).click();
|
||||
|
||||
await expect(page.getByText(/删除成功/i)).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('应该能够刷新菜单列表', async ({ page }) => {
|
||||
await page.goto('/menus');
|
||||
|
||||
await page.getByRole('button', { name: /刷新/i }).click();
|
||||
|
||||
await expect(page.getByText(/刷新成功/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('应该支持菜单类型选择', async ({ page }) => {
|
||||
await page.goto('/menus/create');
|
||||
|
||||
const menuTypeSelect = page.locator('.ant-select').first();
|
||||
await menuTypeSelect.click();
|
||||
|
||||
await expect(page.getByText(/菜单/i)).toBeVisible();
|
||||
await expect(page.getByText(/按钮/i)).toBeVisible();
|
||||
|
||||
await page.getByText(/按钮/i).click();
|
||||
await expect(menuTypeSelect).toContainText(/按钮/i);
|
||||
});
|
||||
|
||||
test('应该支持状态切换', async ({ page }) => {
|
||||
await page.goto('/menus/create');
|
||||
|
||||
const statusSelect = page.locator('.ant-select').filter({ hasText: /状态/i });
|
||||
await statusSelect.click();
|
||||
|
||||
await expect(page.getByText(/启用/i)).toBeVisible();
|
||||
await expect(page.getByText(/禁用/i)).toBeVisible();
|
||||
|
||||
await page.getByText(/禁用/i).click();
|
||||
await expect(statusSelect).toContainText(/禁用/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,505 @@
|
||||
import { Page } from '@playwright/test';
|
||||
|
||||
export interface MockConfig {
|
||||
enabled: boolean;
|
||||
mode: 'full' | 'partial' | 'none';
|
||||
mockPaths: string[];
|
||||
delay: number;
|
||||
logCalls: boolean;
|
||||
validateResponses: boolean;
|
||||
dataSource: 'memory' | 'file' | 'database';
|
||||
}
|
||||
|
||||
export interface MockStatus {
|
||||
enabled: boolean;
|
||||
mode: string;
|
||||
activeMocks: string[];
|
||||
callCount: number;
|
||||
}
|
||||
|
||||
export interface MockData {
|
||||
users?: any[];
|
||||
roles?: any[];
|
||||
menus?: any[];
|
||||
operationLogs?: any[];
|
||||
auth?: any;
|
||||
}
|
||||
|
||||
export class MockManager {
|
||||
private config: MockConfig;
|
||||
private mockData: Map<string, any> = new Map();
|
||||
private callHistory: Array<{ timestamp: number; url: string; method: string; response: any }> = [];
|
||||
|
||||
constructor(config: MockConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
enableMock(): void {
|
||||
this.config.enabled = true;
|
||||
console.log('✅ Mock已启用');
|
||||
}
|
||||
|
||||
disableMock(): void {
|
||||
this.config.enabled = false;
|
||||
console.log('❌ Mock已禁用');
|
||||
}
|
||||
|
||||
configureMock(config: Partial<MockConfig>): void {
|
||||
this.config = { ...this.config, ...config };
|
||||
console.log('⚙️ Mock配置已更新:', this.config);
|
||||
}
|
||||
|
||||
resetMockData(): void {
|
||||
this.mockData.clear();
|
||||
this.callHistory = [];
|
||||
console.log('🔄 Mock数据已重置');
|
||||
}
|
||||
|
||||
presetTestData(data: MockData): void {
|
||||
Object.entries(data).forEach(([key, value]) => {
|
||||
this.mockData.set(key, value);
|
||||
});
|
||||
console.log('📦 测试数据已预设:', Object.keys(data));
|
||||
}
|
||||
|
||||
clearPresets(): void {
|
||||
this.mockData.clear();
|
||||
console.log('🗑️ 预设数据已清除');
|
||||
}
|
||||
|
||||
getMockStatus(): MockStatus {
|
||||
return {
|
||||
enabled: this.config.enabled,
|
||||
mode: this.config.mode,
|
||||
activeMocks: Array.from(this.mockData.keys()),
|
||||
callCount: this.callHistory.length
|
||||
};
|
||||
}
|
||||
|
||||
getMockData(key: string): any {
|
||||
return this.mockData.get(key);
|
||||
}
|
||||
|
||||
recordCall(url: string, method: string, response: any): void {
|
||||
if (this.config.logCalls) {
|
||||
this.callHistory.push({
|
||||
timestamp: Date.now(),
|
||||
url,
|
||||
method,
|
||||
response
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getCallHistory(): Array<{ timestamp: number; url: string; method: string; response: any }> {
|
||||
return this.callHistory;
|
||||
}
|
||||
|
||||
async interceptAPIRequest(page: Page): Promise<void> {
|
||||
if (!this.config.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
await page.route('**/sys/**', async (route) => {
|
||||
const request = route.request();
|
||||
const url = request.url();
|
||||
const method = request.method();
|
||||
|
||||
const shouldMock = this.shouldMockRequest(url, method);
|
||||
|
||||
if (shouldMock) {
|
||||
const mockResponse = await this.getMockResponse(url, method, request.postData());
|
||||
|
||||
if (mockResponse) {
|
||||
this.recordCall(url, method, mockResponse);
|
||||
|
||||
await route.fulfill({
|
||||
status: mockResponse.status || 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockResponse.body)
|
||||
});
|
||||
|
||||
console.log(`🎭 Mock响应: ${method} ${url}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await route.continue();
|
||||
});
|
||||
}
|
||||
|
||||
private shouldMockRequest(url: string, method: string): boolean {
|
||||
if (!this.config.enabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.config.mode === 'full') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.config.mode === 'partial') {
|
||||
return this.config.mockPaths.some(path => url.includes(path));
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async getMockResponse(url: string, method: string, postData?: string): Promise<any> {
|
||||
if (this.config.delay > 0) {
|
||||
await new Promise(resolve => setTimeout(resolve, this.config.delay));
|
||||
}
|
||||
|
||||
if (url.includes('/sys/auth/login') && method === 'POST') {
|
||||
const credentials = JSON.parse(postData || '{}');
|
||||
|
||||
if (credentials.username === 'admin' && credentials.password === 'admin123') {
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
code: '200',
|
||||
message: '登录成功',
|
||||
data: {
|
||||
token: 'mock-token-123456',
|
||||
refreshToken: 'mock-refresh-token-789012',
|
||||
user: {
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
email: 'admin@example.com',
|
||||
phone: '13800138000',
|
||||
status: 'active',
|
||||
createBy: 'system',
|
||||
updateBy: 'admin',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z'
|
||||
},
|
||||
permissions: ['dashboard:view', 'user:read', 'user:write', 'role:read', 'role:write', 'menu:read', 'menu:write', 'operationLog:read']
|
||||
}
|
||||
}
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
code: '401',
|
||||
message: '用户名或密码错误',
|
||||
data: null
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (url.includes('/sys/auth/logout') && method === 'POST') {
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
code: '200',
|
||||
message: '登出成功',
|
||||
data: null
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (url.includes('/sys/auth/refresh') && method === 'POST') {
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
code: '200',
|
||||
message: '刷新成功',
|
||||
data: {
|
||||
token: 'new-mock-token-123456',
|
||||
refreshToken: 'new-mock-refresh-token-789012'
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (url.includes('/sys/user') && method === 'GET') {
|
||||
const users = this.mockData.get('users') || [];
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
code: '200',
|
||||
message: '查询成功',
|
||||
data: {
|
||||
records: users,
|
||||
total: users.length,
|
||||
current: 1,
|
||||
size: 10
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (url.includes('/sys/user') && method === 'POST') {
|
||||
const newUser = JSON.parse(postData || '{}');
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
code: '200',
|
||||
message: '创建成功',
|
||||
data: {
|
||||
...newUser,
|
||||
id: Math.floor(Math.random() * 10000) + 1,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (url.includes('/sys/user/') && method === 'PUT') {
|
||||
const updatedUser = JSON.parse(postData || '{}');
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
code: '200',
|
||||
message: '更新成功',
|
||||
data: {
|
||||
...updatedUser,
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (url.includes('/sys/user/') && method === 'DELETE') {
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
code: '200',
|
||||
message: '删除成功',
|
||||
data: null
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (url.includes('/sys/role') && method === 'GET') {
|
||||
const roles = this.mockData.get('roles') || [];
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
code: '200',
|
||||
message: '查询成功',
|
||||
data: {
|
||||
records: roles,
|
||||
total: roles.length,
|
||||
current: 1,
|
||||
size: 10
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (url.includes('/sys/role') && method === 'POST') {
|
||||
const newRole = JSON.parse(postData || '{}');
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
code: '200',
|
||||
message: '创建成功',
|
||||
data: {
|
||||
...newRole,
|
||||
id: Math.floor(Math.random() * 1000) + 1,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (url.includes('/sys/role/') && method === 'PUT') {
|
||||
const updatedRole = JSON.parse(postData || '{}');
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
code: '200',
|
||||
message: '更新成功',
|
||||
data: {
|
||||
...updatedRole,
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (url.includes('/sys/role/') && method === 'DELETE') {
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
code: '200',
|
||||
message: '删除成功',
|
||||
data: null
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (url.includes('/sys/menu') && method === 'GET') {
|
||||
const menus = this.mockData.get('menus') || [];
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
code: '200',
|
||||
message: '查询成功',
|
||||
data: menus
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (url.includes('/sys/menu/user/') && method === 'GET') {
|
||||
const userIdMatch = url.match(/\/sys\/menu\/user\/(\d+)/);
|
||||
const userId = userIdMatch ? parseInt(userIdMatch[1]) : 1;
|
||||
|
||||
const userMenus = [
|
||||
{
|
||||
id: 1,
|
||||
code: 'dashboard',
|
||||
name: '仪表盘',
|
||||
path: '/dashboard',
|
||||
icon: 'DashboardOutlined',
|
||||
parentId: 0,
|
||||
sortOrder: 1,
|
||||
status: 'active',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
children: []
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
code: 'user',
|
||||
name: '用户管理',
|
||||
path: '/users',
|
||||
icon: 'UserOutlined',
|
||||
parentId: 0,
|
||||
sortOrder: 2,
|
||||
status: 'active',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
children: []
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
code: 'role',
|
||||
name: '角色管理',
|
||||
path: '/roles',
|
||||
icon: 'TeamOutlined',
|
||||
parentId: 0,
|
||||
sortOrder: 3,
|
||||
status: 'active',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
children: []
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
code: 'menu',
|
||||
name: '菜单管理',
|
||||
path: '/menus',
|
||||
icon: 'MenuOutlined',
|
||||
parentId: 0,
|
||||
sortOrder: 4,
|
||||
status: 'active',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
children: []
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
code: 'permission',
|
||||
name: '权限管理',
|
||||
path: '/permissions',
|
||||
icon: 'SafetyOutlined',
|
||||
parentId: 0,
|
||||
sortOrder: 5,
|
||||
status: 'active',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
children: []
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
code: 'operationLog',
|
||||
name: '操作日志',
|
||||
path: '/operation-logs',
|
||||
icon: 'FileTextOutlined',
|
||||
parentId: 0,
|
||||
sortOrder: 6,
|
||||
status: 'active',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
children: []
|
||||
}
|
||||
];
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
code: '200',
|
||||
message: '查询成功',
|
||||
data: userMenus
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (url.includes('/sys/menu') && method === 'POST') {
|
||||
const newMenu = JSON.parse(postData || '{}');
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
code: '200',
|
||||
message: '创建成功',
|
||||
data: {
|
||||
...newMenu,
|
||||
id: Math.floor(Math.random() * 1000) + 1,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (url.includes('/sys/menu/') && method === 'PUT') {
|
||||
const updatedMenu = JSON.parse(postData || '{}');
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
code: '200',
|
||||
message: '更新成功',
|
||||
data: {
|
||||
...updatedMenu,
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (url.includes('/sys/menu/') && method === 'DELETE') {
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
code: '200',
|
||||
message: '删除成功',
|
||||
data: null
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (url.includes('/sys/operationLog') && method === 'GET') {
|
||||
const operationLogs = this.mockData.get('operationLogs') || [];
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
code: '200',
|
||||
message: '查询成功',
|
||||
data: {
|
||||
records: operationLogs,
|
||||
total: operationLogs.length,
|
||||
current: 1,
|
||||
size: 10
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { test, expect } from './test-fixtures';
|
||||
|
||||
test.describe('操作日志 - 完全Mock模式', () => {
|
||||
test.beforeEach(async ({ page, mockManager }) => {
|
||||
mockManager.enableMock();
|
||||
mockManager.configureMock({
|
||||
mode: 'full',
|
||||
delay: 100
|
||||
});
|
||||
|
||||
mockManager.presetTestData({
|
||||
operationLogs: [
|
||||
{ id: 1, userId: 1, username: 'admin', module: '用户管理', operation: '查询', method: 'GET', path: '/sys/user/query', params: '{"page":1,"pageSize":10}', ip: '192.168.1.100', status: 'success', errorMsg: undefined, duration: 120, createdAt: '2024-01-01T10:00:00.000Z' },
|
||||
{ id: 2, userId: 1, username: 'admin', module: '用户管理', operation: '新增', method: 'POST', path: '/sys/user/create', params: '{"username":"testuser"}', ip: '192.168.1.100', status: 'success', errorMsg: undefined, duration: 250, createdAt: '2024-01-01T11:00:00.000Z' },
|
||||
{ id: 3, userId: 1, username: 'admin', module: '角色管理', operation: '修改', method: 'PUT', path: '/sys/role/update', params: '{"roleId":1,"name":"更新后的角色"}', ip: '192.168.1.100', status: 'success', errorMsg: undefined, duration: 180, createdAt: '2024-01-01T12:00:00.000Z' },
|
||||
{ id: 4, userId: 2, username: 'user1', module: '菜单管理', operation: '删除', method: 'DELETE', path: '/sys/menu/delete', params: '{"menuId":5}', ip: '192.168.1.101', status: 'error', errorMsg: '操作失败:权限不足或参数错误', duration: 80, createdAt: '2024-01-01T13:00:00.000Z' },
|
||||
{ id: 5, userId: 1, username: 'admin', module: '操作日志', operation: '导出', method: 'GET', path: '/sys/operationLog/export', params: '{"startDate":"2024-01-01","endDate":"2024-01-31"}', ip: '192.168.1.100', status: 'success', errorMsg: undefined, duration: 350, createdAt: '2024-01-01T14:00:00.000Z' }
|
||||
]
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
await page.fill('input[placeholder="请输入用户名"]', 'admin');
|
||||
await page.fill('input[placeholder="请输入密码"]', 'admin123');
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForURL(/.*dashboard/, { timeout: 10000 });
|
||||
});
|
||||
|
||||
test.afterEach(async ({ mockManager }) => {
|
||||
mockManager.clearPresets();
|
||||
mockManager.disableMock();
|
||||
});
|
||||
|
||||
test('应该显示操作日志列表', async ({ page }) => {
|
||||
await page.goto('/operationLogs');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await expect(page.locator('.ant-table')).toBeVisible();
|
||||
await expect(page.locator('text=用户管理')).toBeVisible();
|
||||
await expect(page.locator('text=角色管理')).toBeVisible();
|
||||
await expect(page.locator('text=菜单管理')).toBeVisible();
|
||||
await expect(page.locator('text=操作日志')).toBeVisible();
|
||||
});
|
||||
|
||||
test('应该能够按用户名搜索', async ({ page }) => {
|
||||
await page.goto('/operationLogs');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await page.fill('input[placeholder="请输入用户名"]', 'admin');
|
||||
await page.click('button:has-text("查询")');
|
||||
|
||||
await expect(page.locator('text=admin')).toBeVisible();
|
||||
});
|
||||
|
||||
test('应该能够按模块搜索', async ({ page }) => {
|
||||
await page.goto('/operationLogs');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await page.click('.ant-select-selector');
|
||||
await page.click('text=用户管理');
|
||||
await page.click('button:has-text("查询")');
|
||||
|
||||
await expect(page.locator('text=用户管理')).toBeVisible();
|
||||
});
|
||||
|
||||
test('应该能够按日期范围搜索', async ({ page }) => {
|
||||
await page.goto('/operationLogs');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await page.click('.ant-picker');
|
||||
await page.click('text=今天');
|
||||
await page.click('button:has-text("查询")');
|
||||
|
||||
await expect(page.locator('.ant-table')).toBeVisible();
|
||||
});
|
||||
|
||||
test('应该能够导出操作日志', async ({ page }) => {
|
||||
await page.goto('/operationLogs');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const downloadPromise = page.waitForEvent('download');
|
||||
await page.click('button:has-text("导出")');
|
||||
const download = await downloadPromise;
|
||||
|
||||
expect(download.suggestedFilename()).toContain('.xlsx');
|
||||
});
|
||||
|
||||
test('应该能够查看日志详情', async ({ page }) => {
|
||||
await page.goto('/operationLogs');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await page.click('button:has-text("详情"):first');
|
||||
|
||||
await expect(page.locator('.ant-modal')).toBeVisible();
|
||||
await expect(page.locator('text=用户名')).toBeVisible();
|
||||
await expect(page.locator('text=模块')).toBeVisible();
|
||||
await expect(page.locator('text=操作')).toBeVisible();
|
||||
await expect(page.locator('text=请求方法')).toBeVisible();
|
||||
await expect(page.locator('text=请求路径')).toBeVisible();
|
||||
await expect(page.locator('text=请求参数')).toBeVisible();
|
||||
await expect(page.locator('text=IP地址')).toBeVisible();
|
||||
await expect(page.locator('text=状态')).toBeVisible();
|
||||
await expect(page.locator('text=执行时长')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,414 @@
|
||||
import { Page, Locator, expect } from '@playwright/test';
|
||||
import { testConfig } from '../core/test-config';
|
||||
import { testLogger } from '../core/test-logger';
|
||||
import { ScreenshotHelper } from '../helpers/screenshot-helper';
|
||||
|
||||
export class BasePage {
|
||||
protected page: Page;
|
||||
protected screenshotHelper: ScreenshotHelper;
|
||||
protected baseURL: string;
|
||||
protected timeout: {
|
||||
default: number;
|
||||
navigation: number;
|
||||
element: number;
|
||||
network: number;
|
||||
};
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
this.screenshotHelper = new ScreenshotHelper(page);
|
||||
this.baseURL = testConfig.getBaseURL();
|
||||
this.timeout = testConfig.getEnvironment().timeout;
|
||||
}
|
||||
|
||||
async navigate(path: string = ''): Promise<void> {
|
||||
const url = path.startsWith('http') ? path : `${this.baseURL}${path}`;
|
||||
testLogger.info(`导航到页面: ${url}`);
|
||||
|
||||
try {
|
||||
await this.page.goto(url, {
|
||||
timeout: this.timeout.navigation,
|
||||
waitUntil: 'networkidle'
|
||||
});
|
||||
|
||||
await this.page.waitForLoadState('networkidle', {
|
||||
timeout: this.timeout.network
|
||||
});
|
||||
|
||||
testLogger.info(`页面加载完成: ${url}`);
|
||||
} catch (error) {
|
||||
testLogger.error(`页面导航失败: ${url}`, error as Error);
|
||||
await this.screenshotHelper.takeScreenshot('navigation-error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async reload(): Promise<void> {
|
||||
testLogger.info('重新加载页面');
|
||||
|
||||
try {
|
||||
await this.page.reload({
|
||||
timeout: this.timeout.navigation,
|
||||
waitUntil: 'networkidle'
|
||||
});
|
||||
|
||||
await this.page.waitForLoadState('networkidle', {
|
||||
timeout: this.timeout.network
|
||||
});
|
||||
|
||||
testLogger.info('页面重新加载完成');
|
||||
} catch (error) {
|
||||
testLogger.error('页面重新加载失败', error as Error);
|
||||
await this.screenshotHelper.takeScreenshot('reload-error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async goBack(): Promise<void> {
|
||||
testLogger.info('返回上一页');
|
||||
await this.page.goBack();
|
||||
}
|
||||
|
||||
async goForward(): Promise<void> {
|
||||
testLogger.info('前进到下一页');
|
||||
await this.page.goForward();
|
||||
}
|
||||
|
||||
async waitForLoad(timeout?: number): Promise<void> {
|
||||
const loadTimeout = timeout || this.timeout.navigation;
|
||||
testLogger.debug(`等待页面加载完成, 超时时间: ${loadTimeout}ms`);
|
||||
|
||||
try {
|
||||
await this.page.waitForLoadState('networkidle', {
|
||||
timeout: loadTimeout
|
||||
});
|
||||
|
||||
testLogger.debug('页面加载完成');
|
||||
} catch (error) {
|
||||
testLogger.error('等待页面加载超时', error as Error);
|
||||
await this.screenshotHelper.takeScreenshot('wait-load-error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async waitForURL(urlPattern: string | RegExp, timeout?: number): Promise<void> {
|
||||
const waitTimeout = timeout || this.timeout.navigation;
|
||||
testLogger.debug(`等待URL匹配: ${urlPattern}, 超时时间: ${waitTimeout}ms`);
|
||||
|
||||
try {
|
||||
await this.page.waitForURL(urlPattern, {
|
||||
timeout: waitTimeout
|
||||
});
|
||||
|
||||
testLogger.debug(`URL匹配成功: ${this.page.url()}`);
|
||||
} catch (error) {
|
||||
testLogger.error(`等待URL匹配失败: ${urlPattern}`, error as Error);
|
||||
await this.screenshotHelper.takeScreenshot('wait-url-error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async waitForElement(selector: string, timeout?: number): Promise<Locator> {
|
||||
const waitTimeout = timeout || this.timeout.element;
|
||||
testLogger.debug(`等待元素可见: ${selector}, 超时时间: ${waitTimeout}ms`);
|
||||
|
||||
try {
|
||||
const locator = this.page.locator(selector);
|
||||
await locator.waitFor({
|
||||
state: 'visible',
|
||||
timeout: waitTimeout
|
||||
});
|
||||
|
||||
testLogger.debug(`元素可见: ${selector}`);
|
||||
return locator;
|
||||
} catch (error) {
|
||||
testLogger.error(`等待元素超时: ${selector}`, error as Error);
|
||||
await this.screenshotHelper.takeScreenshot('wait-element-error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async waitForElementHidden(selector: string, timeout?: number): Promise<void> {
|
||||
const waitTimeout = timeout || this.timeout.element;
|
||||
testLogger.debug(`等待元素隐藏: ${selector}, 超时时间: ${waitTimeout}ms`);
|
||||
|
||||
try {
|
||||
const locator = this.page.locator(selector);
|
||||
await locator.waitFor({
|
||||
state: 'hidden',
|
||||
timeout: waitTimeout
|
||||
});
|
||||
|
||||
testLogger.debug(`元素已隐藏: ${selector}`);
|
||||
} catch (error) {
|
||||
testLogger.error(`等待元素隐藏超时: ${selector}`, error as Error);
|
||||
await this.screenshotHelper.takeScreenshot('wait-hidden-error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async click(selector: string, options?: { timeout?: number; force?: boolean }): Promise<void> {
|
||||
const clickTimeout = options?.timeout || this.timeout.element;
|
||||
testLogger.debug(`点击元素: ${selector}`);
|
||||
|
||||
try {
|
||||
const locator = this.page.locator(selector);
|
||||
await locator.waitFor({
|
||||
state: 'visible',
|
||||
timeout: clickTimeout
|
||||
});
|
||||
|
||||
await locator.click({
|
||||
timeout: clickTimeout,
|
||||
force: options?.force
|
||||
});
|
||||
|
||||
testLogger.debug(`元素点击成功: ${selector}`);
|
||||
} catch (error) {
|
||||
testLogger.error(`点击元素失败: ${selector}`, error as Error);
|
||||
await this.screenshotHelper.takeScreenshot('click-error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async fill(selector: string, value: string, options?: { timeout?: number }): Promise<void> {
|
||||
const fillTimeout = options?.timeout || this.timeout.element;
|
||||
testLogger.debug(`填充输入框: ${selector}, 值: ${value}`);
|
||||
|
||||
try {
|
||||
const locator = this.page.locator(selector);
|
||||
await locator.waitFor({
|
||||
state: 'visible',
|
||||
timeout: fillTimeout
|
||||
});
|
||||
|
||||
await locator.fill(value, {
|
||||
timeout: fillTimeout
|
||||
});
|
||||
|
||||
testLogger.debug(`输入框填充成功: ${selector}`);
|
||||
} catch (error) {
|
||||
testLogger.error(`填充输入框失败: ${selector}`, error as Error);
|
||||
await this.screenshotHelper.takeScreenshot('fill-error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async selectOption(selector: string, value: string | string[], options?: { timeout?: number }): Promise<void> {
|
||||
const selectTimeout = options?.timeout || this.timeout.element;
|
||||
testLogger.debug(`选择下拉选项: ${selector}, 值: ${value}`);
|
||||
|
||||
try {
|
||||
const locator = this.page.locator(selector);
|
||||
await locator.waitFor({
|
||||
state: 'visible',
|
||||
timeout: selectTimeout
|
||||
});
|
||||
|
||||
await locator.selectOption(value, {
|
||||
timeout: selectTimeout
|
||||
});
|
||||
|
||||
testLogger.debug(`下拉选项选择成功: ${selector}`);
|
||||
} catch (error) {
|
||||
testLogger.error(`选择下拉选项失败: ${selector}`, error as Error);
|
||||
await this.screenshotHelper.takeScreenshot('select-error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async check(selector: string, options?: { timeout?: number }): Promise<void> {
|
||||
const checkTimeout = options?.timeout || this.timeout.element;
|
||||
testLogger.debug(`勾选复选框: ${selector}`);
|
||||
|
||||
try {
|
||||
const locator = this.page.locator(selector);
|
||||
await locator.waitFor({
|
||||
state: 'visible',
|
||||
timeout: checkTimeout
|
||||
});
|
||||
|
||||
await locator.check({
|
||||
timeout: checkTimeout
|
||||
});
|
||||
|
||||
testLogger.debug(`复选框勾选成功: ${selector}`);
|
||||
} catch (error) {
|
||||
testLogger.error(`勾选复选框失败: ${selector}`, error as Error);
|
||||
await this.screenshotHelper.takeScreenshot('check-error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async uncheck(selector: string, options?: { timeout?: number }): Promise<void> {
|
||||
const uncheckTimeout = options?.timeout || this.timeout.element;
|
||||
testLogger.debug(`取消勾选复选框: ${selector}`);
|
||||
|
||||
try {
|
||||
const locator = this.page.locator(selector);
|
||||
await locator.waitFor({
|
||||
state: 'visible',
|
||||
timeout: uncheckTimeout
|
||||
});
|
||||
|
||||
await locator.uncheck({
|
||||
timeout: uncheckTimeout
|
||||
});
|
||||
|
||||
testLogger.debug(`复选框取消勾选成功: ${selector}`);
|
||||
} catch (error) {
|
||||
testLogger.error(`取消勾选复选框失败: ${selector}`, error as Error);
|
||||
await this.screenshotHelper.takeScreenshot('uncheck-error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getText(selector: string, options?: { timeout?: number }): Promise<string> {
|
||||
const textTimeout = options?.timeout || this.timeout.element;
|
||||
testLogger.debug(`获取元素文本: ${selector}`);
|
||||
|
||||
try {
|
||||
const locator = this.page.locator(selector);
|
||||
await locator.waitFor({
|
||||
state: 'visible',
|
||||
timeout: textTimeout
|
||||
});
|
||||
|
||||
const text = await locator.textContent();
|
||||
testLogger.debug(`元素文本: ${selector} = ${text}`);
|
||||
|
||||
return text || '';
|
||||
} catch (error) {
|
||||
testLogger.error(`获取元素文本失败: ${selector}`, error as Error);
|
||||
await this.screenshotHelper.takeScreenshot('get-text-error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getAttribute(selector: string, attributeName: string, options?: { timeout?: number }): Promise<string | null> {
|
||||
const attrTimeout = options?.timeout || this.timeout.element;
|
||||
testLogger.debug(`获取元素属性: ${selector}, 属性名: ${attributeName}`);
|
||||
|
||||
try {
|
||||
const locator = this.page.locator(selector);
|
||||
await locator.waitFor({
|
||||
state: 'visible',
|
||||
timeout: attrTimeout
|
||||
});
|
||||
|
||||
const attribute = await locator.getAttribute(attributeName);
|
||||
testLogger.debug(`元素属性: ${selector}[${attributeName}] = ${attribute}`);
|
||||
|
||||
return attribute;
|
||||
} catch (error) {
|
||||
testLogger.error(`获取元素属性失败: ${selector}`, error as Error);
|
||||
await this.screenshotHelper.takeScreenshot('get-attribute-error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async isVisible(selector: string): Promise<boolean> {
|
||||
try {
|
||||
const locator = this.page.locator(selector);
|
||||
const visible = await locator.isVisible({ timeout: 5000 });
|
||||
testLogger.debug(`元素可见性: ${selector} = ${visible}`);
|
||||
|
||||
return visible;
|
||||
} catch (error) {
|
||||
testLogger.debug(`元素不可见: ${selector}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async isEnabled(selector: string): Promise<boolean> {
|
||||
try {
|
||||
const locator = this.page.locator(selector);
|
||||
const enabled = await locator.isEnabled({ timeout: 5000 });
|
||||
testLogger.debug(`元素可用性: ${selector} = ${enabled}`);
|
||||
|
||||
return enabled;
|
||||
} catch (error) {
|
||||
testLogger.debug(`元素不可用: ${selector}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async waitForTimeout(ms: number): Promise<void> {
|
||||
testLogger.debug(`等待 ${ms}ms`);
|
||||
await this.page.waitForTimeout(ms);
|
||||
}
|
||||
|
||||
async executeScript(script: string, ...args: any[]): Promise<any> {
|
||||
testLogger.debug('执行JavaScript脚本');
|
||||
|
||||
try {
|
||||
const result = await this.page.evaluate(script, ...args);
|
||||
testLogger.debug('JavaScript脚本执行成功');
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
testLogger.error('JavaScript脚本执行失败', error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async scrollToElement(selector: string): Promise<void> {
|
||||
testLogger.debug(`滚动到元素: ${selector}`);
|
||||
|
||||
try {
|
||||
const locator = this.page.locator(selector);
|
||||
await locator.scrollIntoViewIfNeeded();
|
||||
|
||||
testLogger.debug(`滚动到元素成功: ${selector}`);
|
||||
} catch (error) {
|
||||
testLogger.error(`滚动到元素失败: ${selector}`, error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async takeScreenshot(name: string): Promise<string> {
|
||||
return await this.screenshotHelper.takeScreenshot(name);
|
||||
}
|
||||
|
||||
async takeElementScreenshot(selector: string, name: string): Promise<string> {
|
||||
return await this.screenshotHelper.takeElementScreenshot(selector, name);
|
||||
}
|
||||
|
||||
getCurrentURL(): string {
|
||||
return this.page.url();
|
||||
}
|
||||
|
||||
getTitle(): Promise<string> {
|
||||
return this.page.title();
|
||||
}
|
||||
|
||||
async expectVisible(selector: string, timeout?: number): Promise<void> {
|
||||
const locator = await this.waitForElement(selector, timeout);
|
||||
await expect(locator).toBeVisible();
|
||||
}
|
||||
|
||||
async expectHidden(selector: string, timeout?: number): Promise<void> {
|
||||
const locator = this.page.locator(selector);
|
||||
await expect(locator).toBeHidden({ timeout });
|
||||
}
|
||||
|
||||
async expectText(selector: string, expectedText: string, timeout?: number): Promise<void> {
|
||||
const locator = await this.waitForElement(selector, timeout);
|
||||
await expect(locator).toHaveText(expectedText);
|
||||
}
|
||||
|
||||
async expectValue(selector: string, expectedValue: string, timeout?: number): Promise<void> {
|
||||
const locator = await this.waitForElement(selector, timeout);
|
||||
await expect(locator).toHaveValue(expectedValue);
|
||||
}
|
||||
|
||||
async expectAttribute(selector: string, attributeName: string, expectedValue: string, timeout?: number): Promise<void> {
|
||||
const locator = await this.waitForElement(selector, timeout);
|
||||
await expect(locator).toHaveAttribute(attributeName, expectedValue);
|
||||
}
|
||||
|
||||
async expectCount(selector: string, expectedCount: number, timeout?: number): Promise<void> {
|
||||
const locator = this.page.locator(selector);
|
||||
await expect(locator).toHaveCount(expectedCount, { timeout });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { Page, expect } from '@playwright/test';
|
||||
import { BasePage } from './base-page';
|
||||
import { SELECTORS, TIMEOUTS } from '../constants';
|
||||
|
||||
export class DashboardPage extends BasePage {
|
||||
private readonly selectors = {
|
||||
dashboardContainer: '.dashboard-container',
|
||||
pageTitle: '.page-title',
|
||||
statisticsCards: '.statistic-card',
|
||||
charts: '.chart-container',
|
||||
menuItems: '.ant-menu-item',
|
||||
welcomeMessage: '.welcome-message',
|
||||
quickActions: '.quick-action'
|
||||
};
|
||||
|
||||
constructor(page: Page) {
|
||||
super(page);
|
||||
}
|
||||
|
||||
async navigate(): Promise<void> {
|
||||
testLogger.info('导航到仪表盘页面');
|
||||
await super.navigate('/dashboard');
|
||||
}
|
||||
|
||||
async waitForLoad(): Promise<void> {
|
||||
testLogger.info('等待仪表盘页面加载');
|
||||
|
||||
try {
|
||||
await this.page.waitForSelector(this.selectors.dashboardContainer, {
|
||||
state: 'visible',
|
||||
timeout: this.timeout.default
|
||||
});
|
||||
|
||||
testLogger.info('仪表盘页面加载完成');
|
||||
} catch (error) {
|
||||
testLogger.error('仪表盘页面加载超时', error as Error);
|
||||
await this.screenshotHelper.takeScreenshot('dashboard-load-error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getPageTitle(): Promise<string> {
|
||||
testLogger.debug('获取页面标题');
|
||||
|
||||
try {
|
||||
const titleElement = this.page.locator(this.selectors.pageTitle);
|
||||
await titleElement.waitFor({ state: 'visible', timeout: this.timeout.element });
|
||||
|
||||
const title = await titleElement.textContent();
|
||||
testLogger.debug(`页面标题: ${title}`);
|
||||
|
||||
return title || '';
|
||||
} catch (error) {
|
||||
testLogger.error('获取页面标题失败', error as Error);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
async getWelcomeMessage(): Promise<string> {
|
||||
testLogger.debug('获取欢迎消息');
|
||||
|
||||
try {
|
||||
const welcomeElement = this.page.locator(this.selectors.welcomeMessage);
|
||||
await welcomeElement.waitFor({ state: 'visible', timeout: this.timeout.element });
|
||||
|
||||
const message = await welcomeElement.textContent();
|
||||
testLogger.debug(`欢迎消息: ${message}`);
|
||||
|
||||
return message || '';
|
||||
} catch (error) {
|
||||
testLogger.error('获取欢迎消息失败', error as Error);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
async getStatisticsCardCount(): Promise<number> {
|
||||
testLogger.debug('获取统计卡片数量');
|
||||
|
||||
try {
|
||||
const cards = this.page.locator(this.selectors.statisticsCards);
|
||||
const count = await cards.count();
|
||||
|
||||
testLogger.debug(`统计卡片数量: ${count}`);
|
||||
|
||||
return count;
|
||||
} catch (error) {
|
||||
testLogger.error('获取统计卡片数量失败', error as Error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
async getChartCount(): Promise<number> {
|
||||
testLogger.debug('获取图表数量');
|
||||
|
||||
try {
|
||||
const charts = this.page.locator(this.selectors.charts);
|
||||
const count = await charts.count();
|
||||
|
||||
testLogger.debug(`图表数量: ${count}`);
|
||||
|
||||
return count;
|
||||
} catch (error) {
|
||||
testLogger.error('获取图表数量失败', error as Error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
async clickMenuItem(menuName: string): Promise<void> {
|
||||
testLogger.info(`点击菜单项: ${menuName}`);
|
||||
|
||||
try {
|
||||
const menuItem = this.page.locator(this.selectors.menuItems).filter({ hasText: menuName });
|
||||
await menuItem.waitFor({ state: 'visible', timeout: this.timeout.element });
|
||||
|
||||
await menuItem.click();
|
||||
|
||||
testLogger.info(`菜单项点击成功: ${menuName}`);
|
||||
} catch (error) {
|
||||
testLogger.error(`点击菜单项失败: ${menuName}`, error as Error);
|
||||
await this.screenshotHelper.takeScreenshot(`click-menu-${menuName}-error`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async clickQuickAction(actionName: string): Promise<void> {
|
||||
testLogger.info(`点击快捷操作: ${actionName}`);
|
||||
|
||||
try {
|
||||
const quickAction = this.page.locator(this.selectors.quickActions).filter({ hasText: actionName });
|
||||
await quickAction.waitFor({ state: 'visible', timeout: this.timeout.element });
|
||||
|
||||
await quickAction.click();
|
||||
|
||||
testLogger.info(`快捷操作点击成功: ${actionName}`);
|
||||
} catch (error) {
|
||||
testLogger.error(`点击快捷操作失败: ${actionName}`, error as Error);
|
||||
await this.screenshotHelper.takeScreenshot(`click-quick-action-${actionName}-error`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async isDashboardVisible(): Promise<boolean> {
|
||||
testLogger.debug('检查仪表盘是否可见');
|
||||
|
||||
try {
|
||||
const dashboard = this.page.locator(this.selectors.dashboardContainer);
|
||||
const isVisible = await dashboard.isVisible();
|
||||
|
||||
testLogger.debug(`仪表盘可见性: ${isVisible}`);
|
||||
|
||||
return isVisible;
|
||||
} catch (error) {
|
||||
testLogger.error('检查仪表盘可见性失败', error as Error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async waitForStatistics(): Promise<void> {
|
||||
testLogger.info('等待统计数据加载');
|
||||
|
||||
try {
|
||||
await this.page.waitForSelector(this.selectors.statisticsCards, {
|
||||
state: 'visible',
|
||||
timeout: this.timeout.network
|
||||
});
|
||||
|
||||
testLogger.info('统计数据加载完成');
|
||||
} catch (error) {
|
||||
testLogger.error('等待统计数据加载超时', error as Error);
|
||||
await this.screenshotHelper.takeScreenshot('statistics-load-error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async waitForCharts(): Promise<void> {
|
||||
testLogger.info('等待图表加载');
|
||||
|
||||
try {
|
||||
await this.page.waitForSelector(this.selectors.charts, {
|
||||
state: 'visible',
|
||||
timeout: this.timeout.network
|
||||
});
|
||||
|
||||
testLogger.info('图表加载完成');
|
||||
} catch (error) {
|
||||
testLogger.error('等待图表加载超时', error as Error);
|
||||
await this.screenshotHelper.takeScreenshot('charts-load-error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import { Page, expect } from '@playwright/test';
|
||||
import { BasePage } from './base-page';
|
||||
import { testLogger } from '../core/test-logger';
|
||||
|
||||
export class LoginPage extends BasePage {
|
||||
private readonly selectors = {
|
||||
usernameInput: '[data-testid="username-input"]',
|
||||
passwordInput: '[data-testid="password-input"]',
|
||||
loginButton: '[data-testid="login-button"]',
|
||||
errorMessage: '.ant-message-error',
|
||||
successMessage: '.ant-message-success',
|
||||
loginForm: '[data-testid="login-form"]',
|
||||
rememberMeCheckbox: '[data-testid="remember-checkbox"]',
|
||||
forgotPasswordLink: 'text=忘记密码',
|
||||
registerLink: 'text=注册账号'
|
||||
};
|
||||
|
||||
constructor(page: Page) {
|
||||
super(page);
|
||||
}
|
||||
|
||||
async navigate(): Promise<void> {
|
||||
testLogger.info('导航到登录页面');
|
||||
await super.navigate('/login');
|
||||
}
|
||||
|
||||
async waitForLoad(): Promise<void> {
|
||||
testLogger.debug('等待登录页面加载完成');
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
this.waitForElement(this.selectors.usernameInput),
|
||||
this.waitForElement(this.selectors.passwordInput),
|
||||
this.waitForElement(this.selectors.loginButton)
|
||||
]);
|
||||
|
||||
testLogger.info('登录页面加载完成');
|
||||
} catch (error) {
|
||||
testLogger.error('登录页面加载失败', error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getUsernameInput() {
|
||||
return this.page.locator(this.selectors.usernameInput);
|
||||
}
|
||||
|
||||
async getPasswordInput() {
|
||||
return this.page.locator(this.selectors.passwordInput);
|
||||
}
|
||||
|
||||
async getLoginButton() {
|
||||
return this.page.locator(this.selectors.loginButton);
|
||||
}
|
||||
|
||||
async getErrorMessage() {
|
||||
return this.page.locator(this.selectors.errorMessage);
|
||||
}
|
||||
|
||||
async getSuccessMessage() {
|
||||
return this.page.locator(this.selectors.successMessage);
|
||||
}
|
||||
|
||||
async fillUsername(username: string): Promise<void> {
|
||||
testLogger.info(`填写用户名: ${username}`);
|
||||
await this.fill(this.selectors.usernameInput, username);
|
||||
}
|
||||
|
||||
async fillPassword(password: string): Promise<void> {
|
||||
testLogger.info('填写密码');
|
||||
await this.fill(this.selectors.passwordInput, password);
|
||||
}
|
||||
|
||||
async clickLoginButton(): Promise<void> {
|
||||
testLogger.info('点击登录按钮');
|
||||
await this.click(this.selectors.loginButton);
|
||||
}
|
||||
|
||||
async toggleRememberMe(remember: boolean): Promise<void> {
|
||||
testLogger.info(`设置记住密码: ${remember}`);
|
||||
|
||||
if (remember) {
|
||||
await this.check(this.selectors.rememberMeCheckbox);
|
||||
} else {
|
||||
await this.uncheck(this.selectors.rememberMeCheckbox);
|
||||
}
|
||||
}
|
||||
|
||||
async login(username: string, password: string, rememberMe: boolean = false): Promise<void> {
|
||||
testLogger.startStep('用户登录');
|
||||
|
||||
try {
|
||||
await this.waitForLoad();
|
||||
await this.fillUsername(username);
|
||||
await this.fillPassword(password);
|
||||
|
||||
if (rememberMe) {
|
||||
await this.toggleRememberMe(true);
|
||||
}
|
||||
|
||||
await this.clickLoginButton();
|
||||
|
||||
testLogger.endStep('用户登录', 'passed');
|
||||
} catch (error) {
|
||||
testLogger.endStep('用户登录', 'failed', error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async loginAndWaitForDashboard(username: string, password: string, rememberMe: boolean = false): Promise<void> {
|
||||
testLogger.startStep('登录并等待跳转到仪表盘');
|
||||
|
||||
try {
|
||||
await this.login(username, password, rememberMe);
|
||||
|
||||
await this.waitForURL(/.*dashboard/, this.timeout.navigation);
|
||||
|
||||
testLogger.endStep('登录并等待跳转到仪表盘', 'passed');
|
||||
} catch (error) {
|
||||
testLogger.endStep('登录并等待跳转到仪表盘', 'failed', error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async expectErrorMessage(message: string): Promise<void> {
|
||||
testLogger.debug(`期望错误消息: ${message}`);
|
||||
|
||||
const errorLocator = this.getErrorMessage();
|
||||
await expect(errorLocator).toBeVisible({ timeout: 5000 });
|
||||
await expect(errorLocator).toContainText(message);
|
||||
}
|
||||
|
||||
async expectSuccessMessage(message: string): Promise<void> {
|
||||
testLogger.debug(`期望成功消息: ${message}`);
|
||||
|
||||
const successLocator = this.getSuccessMessage();
|
||||
await expect(successLocator).toBeVisible({ timeout: 5000 });
|
||||
await expect(successLocator).toContainText(message);
|
||||
}
|
||||
|
||||
async hasErrorMessage(): Promise<boolean> {
|
||||
const errorLocator = this.getErrorMessage();
|
||||
const count = await errorLocator.count();
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
async hasSuccessMessage(): Promise<boolean> {
|
||||
const successLocator = this.getSuccessMessage();
|
||||
const count = await successLocator.count();
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
async getErrorMessageText(): Promise<string> {
|
||||
const errorLocator = this.getErrorMessage();
|
||||
const text = await errorLocator.textContent();
|
||||
return text || '';
|
||||
}
|
||||
|
||||
async getSuccessMessageText(): Promise<string> {
|
||||
const successLocator = this.getSuccessMessage();
|
||||
const text = await successLocator.textContent();
|
||||
return text || '';
|
||||
}
|
||||
|
||||
async isLoginButtonEnabled(): Promise<boolean> {
|
||||
const loginButton = this.getLoginButton();
|
||||
return await loginButton.isEnabled();
|
||||
}
|
||||
|
||||
async isUsernameInputVisible(): Promise<boolean> {
|
||||
return await this.isVisible(this.selectors.usernameInput);
|
||||
}
|
||||
|
||||
async isPasswordInputVisible(): Promise<boolean> {
|
||||
return await this.isVisible(this.selectors.passwordInput);
|
||||
}
|
||||
|
||||
async isLoginFormVisible(): Promise<boolean> {
|
||||
return await this.isVisible(this.selectors.loginForm);
|
||||
}
|
||||
|
||||
async clearUsername(): Promise<void> {
|
||||
testLogger.info('清空用户名输入框');
|
||||
const usernameInput = this.getUsernameInput();
|
||||
await usernameInput.fill('');
|
||||
}
|
||||
|
||||
async clearPassword(): Promise<void> {
|
||||
testLogger.info('清空密码输入框');
|
||||
const passwordInput = this.getPasswordInput();
|
||||
await passwordInput.fill('');
|
||||
}
|
||||
|
||||
async clearAllFields(): Promise<void> {
|
||||
await this.clearUsername();
|
||||
await this.clearPassword();
|
||||
}
|
||||
|
||||
async clickForgotPassword(): Promise<void> {
|
||||
testLogger.info('点击忘记密码链接');
|
||||
await this.click(this.selectors.forgotPasswordLink);
|
||||
}
|
||||
|
||||
async clickRegister(): Promise<void> {
|
||||
testLogger.info('点击注册账号链接');
|
||||
await this.click(this.selectors.registerLink);
|
||||
}
|
||||
|
||||
async pressEnter(): Promise<void> {
|
||||
testLogger.info('按Enter键提交登录');
|
||||
await this.page.keyboard.press('Enter');
|
||||
}
|
||||
|
||||
async loginWithEnter(username: string, password: string): Promise<void> {
|
||||
testLogger.startStep('使用Enter键登录');
|
||||
|
||||
try {
|
||||
await this.waitForLoad();
|
||||
await this.fillUsername(username);
|
||||
await this.fillPassword(password);
|
||||
await this.pressEnter();
|
||||
|
||||
testLogger.endStep('使用Enter键登录', 'passed');
|
||||
} catch (error) {
|
||||
testLogger.endStep('使用Enter键登录', 'failed', error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async takeScreenshotOnLogin(name: string = 'login-page'): Promise<string> {
|
||||
return await this.takeScreenshot(name);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import { Page } from '@playwright/test';
|
||||
import { BasePage } from './base-page';
|
||||
import { FormHelper } from '../helpers/form-helper';
|
||||
import { TableHelper } from '../helpers/table-helper';
|
||||
import { ScreenshotHelper } from '../helpers/screenshot-helper';
|
||||
import { testLogger } from '../core/test-logger';
|
||||
|
||||
export class MenuManagementPage extends BasePage {
|
||||
private formHelper: FormHelper;
|
||||
private tableHelper: TableHelper;
|
||||
private screenshotHelper: ScreenshotHelper;
|
||||
|
||||
private readonly selectors = {
|
||||
menuTree: '.menu-tree',
|
||||
menuTable: '.menu-table',
|
||||
addMenuButton: 'button:has-text("新增")',
|
||||
editButton: 'button:has-text("编辑")',
|
||||
deleteButton: 'button:has-text("删除")',
|
||||
searchInput: 'input[placeholder*="搜索"]',
|
||||
searchButton: 'button:has-text("查询")',
|
||||
resetButton: 'button:has-text("重置")',
|
||||
modal: '.ant-modal',
|
||||
modalTitle: '.ant-modal-title',
|
||||
modalConfirmButton: '.ant-modal-confirm-btn',
|
||||
modalCancelButton: '.ant-modal-cancel-btn',
|
||||
successMessage: '.ant-message-success',
|
||||
errorMessage: '.ant-message-error',
|
||||
menuForm: '.menu-form',
|
||||
menuNameInput: 'input[name="menuName"]',
|
||||
menuTypeSelect: 'select[name="menuType"]',
|
||||
menuIconInput: 'input[name="icon"]',
|
||||
orderNumInput: 'input[name="orderNum"]',
|
||||
pathInput: 'input[name="path"]',
|
||||
componentInput: 'input[name="component"]',
|
||||
statusSelect: 'select[name="status"]',
|
||||
visibleSelect: 'select[name="visible"]',
|
||||
remarkInput: 'textarea[name="remark"]',
|
||||
treeNode: '.ant-tree-node',
|
||||
treeExpandButton: '.ant-tree-switcher'
|
||||
};
|
||||
|
||||
constructor(page: Page) {
|
||||
super(page);
|
||||
this.formHelper = new FormHelper(page);
|
||||
this.tableHelper = new TableHelper(page);
|
||||
this.screenshotHelper = new ScreenshotHelper(page);
|
||||
}
|
||||
|
||||
async navigate(): Promise<void> {
|
||||
testLogger.info('导航到菜单管理页面');
|
||||
await super.navigate('/system/menu');
|
||||
}
|
||||
|
||||
async waitForLoad(): Promise<void> {
|
||||
testLogger.info('等待菜单管理页面加载');
|
||||
|
||||
try {
|
||||
await this.page.waitForSelector(this.selectors.menuTree, {
|
||||
state: 'visible',
|
||||
timeout: this.timeout.default
|
||||
});
|
||||
|
||||
testLogger.info('菜单管理页面加载完成');
|
||||
} catch (error) {
|
||||
testLogger.error('菜单管理页面加载超时', error as Error);
|
||||
await this.screenshotHelper.takeScreenshot('menu-management-load-error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async clickAddMenu(): Promise<void> {
|
||||
testLogger.info('点击新增菜单按钮');
|
||||
|
||||
try {
|
||||
await this.page.waitForSelector(this.selectors.addMenuButton, {
|
||||
state: 'visible',
|
||||
timeout: this.timeout.element
|
||||
});
|
||||
|
||||
await this.page.click(this.selectors.addMenuButton);
|
||||
|
||||
await this.page.waitForSelector(this.selectors.modal, {
|
||||
state: 'visible',
|
||||
timeout: this.timeout.element
|
||||
});
|
||||
|
||||
testLogger.info('新增菜单对话框已打开');
|
||||
} catch (error) {
|
||||
testLogger.error('点击新增菜单按钮失败', error as Error);
|
||||
await this.screenshotHelper.takeScreenshot('click-add-menu-error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async clickEditMenu(menuName: string): Promise<void> {
|
||||
testLogger.info(`点击编辑菜单按钮,菜单名称: ${menuName}`);
|
||||
|
||||
try {
|
||||
const editButtons = this.page.locator(this.selectors.editButton);
|
||||
await editButtons.first().waitFor({ state: 'visible', timeout: this.timeout.element });
|
||||
|
||||
await editButtons.first().click();
|
||||
|
||||
await this.page.waitForSelector(this.selectors.modal, {
|
||||
state: 'visible',
|
||||
timeout: this.timeout.element
|
||||
});
|
||||
|
||||
testLogger.info('编辑菜单对话框已打开');
|
||||
} catch (error) {
|
||||
testLogger.error(`点击编辑菜单按钮失败,菜单名称: ${menuName}`, error as Error);
|
||||
await this.screenshotHelper.takeScreenshot(`click-edit-menu-${menuName}-error`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async clickDeleteMenu(menuName: string): Promise<void> {
|
||||
testLogger.info(`点击删除菜单按钮,菜单名称: ${menuName}`);
|
||||
|
||||
try {
|
||||
const deleteButtons = this.page.locator(this.selectors.deleteButton);
|
||||
await deleteButtons.first().waitFor({ state: 'visible', timeout: this.timeout.element });
|
||||
|
||||
await deleteButtons.first().click();
|
||||
|
||||
testLogger.info('删除确认对话框已打开');
|
||||
} catch (error) {
|
||||
testLogger.error(`点击删除菜单按钮失败,菜单名称: ${menuName}`, error as Error);
|
||||
await this.screenshotHelper.takeScreenshot(`click-delete-menu-${menuName}-error`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async confirmDelete(): Promise<void> {
|
||||
testLogger.info('确认删除菜单');
|
||||
|
||||
try {
|
||||
await this.page.waitForSelector(this.selectors.modalConfirmButton, {
|
||||
state: 'visible',
|
||||
timeout: this.timeout.element
|
||||
});
|
||||
|
||||
await this.page.click(this.selectors.modalConfirmButton);
|
||||
|
||||
await this.page.waitForSelector(this.selectors.modal, {
|
||||
state: 'hidden',
|
||||
timeout: this.timeout.element
|
||||
});
|
||||
|
||||
testLogger.info('菜单删除确认成功');
|
||||
} catch (error) {
|
||||
testLogger.error('确认删除菜单失败', error as Error);
|
||||
await this.screenshotHelper.takeScreenshot('confirm-delete-error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async searchMenu(keyword: string): Promise<void> {
|
||||
testLogger.info(`搜索菜单,关键词: ${keyword}`);
|
||||
|
||||
try {
|
||||
await this.page.waitForSelector(this.selectors.searchInput, {
|
||||
state: 'visible',
|
||||
timeout: this.timeout.element
|
||||
});
|
||||
|
||||
await this.page.fill(this.selectors.searchInput, keyword);
|
||||
await this.page.click(this.selectors.searchButton);
|
||||
|
||||
await this.page.waitForLoadState('networkidle', {
|
||||
timeout: this.timeout.network
|
||||
});
|
||||
|
||||
testLogger.info('菜单搜索完成');
|
||||
} catch (error) {
|
||||
testLogger.error(`搜索菜单失败,关键词: ${keyword}`, error as Error);
|
||||
await this.screenshotHelper.takeScreenshot('search-menu-error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async expandTreeNode(nodeIndex: number): Promise<void> {
|
||||
testLogger.info(`展开树节点,索引: ${nodeIndex}`);
|
||||
|
||||
try {
|
||||
const expandButtons = this.page.locator(this.selectors.treeExpandButton);
|
||||
await expandButtons.nth(nodeIndex).waitFor({ state: 'visible', timeout: this.timeout.element });
|
||||
|
||||
await expandButtons.nth(nodeIndex).click();
|
||||
|
||||
testLogger.info(`树节点已展开,索引: ${nodeIndex}`);
|
||||
} catch (error) {
|
||||
testLogger.error(`展开树节点失败,索引: ${nodeIndex}`, error as Error);
|
||||
await this.screenshotHelper.takeScreenshot(`expand-tree-node-${nodeIndex}-error`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getSuccessMessage(): Promise<string> {
|
||||
testLogger.debug('获取成功消息');
|
||||
|
||||
try {
|
||||
const successElement = this.page.locator(this.selectors.successMessage);
|
||||
await successElement.waitFor({ state: 'visible', timeout: this.timeout.element });
|
||||
|
||||
const message = await successElement.textContent();
|
||||
testLogger.debug(`成功消息: ${message}`);
|
||||
|
||||
return message || '';
|
||||
} catch (error) {
|
||||
testLogger.error('获取成功消息失败', error as Error);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
async getErrorMessage(): Promise<string> {
|
||||
testLogger.debug('获取错误消息');
|
||||
|
||||
try {
|
||||
const errorElement = this.page.locator(this.selectors.errorMessage);
|
||||
await errorElement.waitFor({ state: 'visible', timeout: this.timeout.element });
|
||||
|
||||
const message = await errorElement.textContent();
|
||||
testLogger.debug(`错误消息: ${message}`);
|
||||
|
||||
return message || '';
|
||||
} catch (error) {
|
||||
testLogger.error('获取错误消息失败', error as Error);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
async getMenuCount(): Promise<number> {
|
||||
testLogger.debug('获取菜单数量');
|
||||
|
||||
try {
|
||||
const count = await this.tableHelper.getRowCount(this.selectors.menuTable);
|
||||
testLogger.debug(`菜单数量: ${count}`);
|
||||
|
||||
return count;
|
||||
} catch (error) {
|
||||
testLogger.error('获取菜单数量失败', error as Error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
async getTreeNodeCount(): Promise<number> {
|
||||
testLogger.debug('获取树节点数量');
|
||||
|
||||
try {
|
||||
const treeNodes = this.page.locator(this.selectors.treeNode);
|
||||
const count = await treeNodes.count();
|
||||
|
||||
testLogger.debug(`树节点数量: ${count}`);
|
||||
|
||||
return count;
|
||||
} catch (error) {
|
||||
testLogger.error('获取树节点数量失败', error as Error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import { Page } from '@playwright/test';
|
||||
import { BasePage } from './base-page';
|
||||
import { FormHelper } from '../helpers/form-helper';
|
||||
import { TableHelper } from '../helpers/table-helper';
|
||||
import { ScreenshotHelper } from '../helpers/screenshot-helper';
|
||||
import { testLogger } from '../core/test-logger';
|
||||
|
||||
export class RoleManagementPage extends BasePage {
|
||||
private formHelper: FormHelper;
|
||||
private tableHelper: TableHelper;
|
||||
private screenshotHelper: ScreenshotHelper;
|
||||
|
||||
private readonly selectors = {
|
||||
roleTable: '.role-table',
|
||||
addRoleButton: 'button:has-text("新增")',
|
||||
editButton: 'button:has-text("编辑")',
|
||||
deleteButton: 'button:has-text("删除")',
|
||||
searchInput: 'input[placeholder*="搜索"]',
|
||||
searchButton: 'button:has-text("查询")',
|
||||
resetButton: 'button:has-text("重置")',
|
||||
modal: '.ant-modal',
|
||||
modalTitle: '.ant-modal-title',
|
||||
modalConfirmButton: '.ant-modal-confirm-btn',
|
||||
modalCancelButton: '.ant-modal-cancel-btn',
|
||||
successMessage: '.ant-message-success',
|
||||
errorMessage: '.ant-message-error',
|
||||
roleForm: '.role-form',
|
||||
roleNameInput: 'input[name="roleName"]',
|
||||
roleKeyInput: 'input[name="roleKey"]',
|
||||
roleSortInput: 'input[name="roleSort"]',
|
||||
statusSelect: 'select[name="status"]',
|
||||
remarkInput: 'textarea[name="remark"]',
|
||||
permissionTree: '.permission-tree',
|
||||
permissionCheckbox: '.ant-tree-checkbox'
|
||||
};
|
||||
|
||||
constructor(page: Page) {
|
||||
super(page);
|
||||
this.formHelper = new FormHelper(page);
|
||||
this.tableHelper = new TableHelper(page);
|
||||
this.screenshotHelper = new ScreenshotHelper(page);
|
||||
}
|
||||
|
||||
async navigate(): Promise<void> {
|
||||
testLogger.info('导航到角色管理页面');
|
||||
await super.navigate('/system/role');
|
||||
}
|
||||
|
||||
async waitForLoad(): Promise<void> {
|
||||
testLogger.info('等待角色管理页面加载');
|
||||
|
||||
try {
|
||||
await this.page.waitForSelector(this.selectors.roleTable, {
|
||||
state: 'visible',
|
||||
timeout: this.timeout.default
|
||||
});
|
||||
|
||||
testLogger.info('角色管理页面加载完成');
|
||||
} catch (error) {
|
||||
testLogger.error('角色管理页面加载超时', error as Error);
|
||||
await this.screenshotHelper.takeScreenshot('role-management-load-error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async clickAddRole(): Promise<void> {
|
||||
testLogger.info('点击新增角色按钮');
|
||||
|
||||
try {
|
||||
await this.page.waitForSelector(this.selectors.addRoleButton, {
|
||||
state: 'visible',
|
||||
timeout: this.timeout.element
|
||||
});
|
||||
|
||||
await this.page.click(this.selectors.addRoleButton);
|
||||
|
||||
await this.page.waitForSelector(this.selectors.modal, {
|
||||
state: 'visible',
|
||||
timeout: this.timeout.element
|
||||
});
|
||||
|
||||
testLogger.info('新增角色对话框已打开');
|
||||
} catch (error) {
|
||||
testLogger.error('点击新增角色按钮失败', error as Error);
|
||||
await this.screenshotHelper.takeScreenshot('click-add-role-error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async clickEditRole(rowIndex: number): Promise<void> {
|
||||
testLogger.info(`点击编辑角色按钮,行索引: ${rowIndex}`);
|
||||
|
||||
try {
|
||||
const editButtons = this.page.locator(this.selectors.editButton);
|
||||
await editButtons.nth(rowIndex).waitFor({ state: 'visible', timeout: this.timeout.element });
|
||||
|
||||
await editButtons.nth(rowIndex).click();
|
||||
|
||||
await this.page.waitForSelector(this.selectors.modal, {
|
||||
state: 'visible',
|
||||
timeout: this.timeout.element
|
||||
});
|
||||
|
||||
testLogger.info('编辑角色对话框已打开');
|
||||
} catch (error) {
|
||||
testLogger.error(`点击编辑角色按钮失败,行索引: ${rowIndex}`, error as Error);
|
||||
await this.screenshotHelper.takeScreenshot(`click-edit-role-${rowIndex}-error`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async clickDeleteRole(rowIndex: number): Promise<void> {
|
||||
testLogger.info(`点击删除角色按钮,行索引: ${rowIndex}`);
|
||||
|
||||
try {
|
||||
const deleteButtons = this.page.locator(this.selectors.deleteButton);
|
||||
await deleteButtons.nth(rowIndex).waitFor({ state: 'visible', timeout: this.timeout.element });
|
||||
|
||||
await deleteButtons.nth(rowIndex).click();
|
||||
|
||||
testLogger.info('删除确认对话框已打开');
|
||||
} catch (error) {
|
||||
testLogger.error(`点击删除角色按钮失败,行索引: ${rowIndex}`, error as Error);
|
||||
await this.screenshotHelper.takeScreenshot(`click-delete-role-${rowIndex}-error`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async confirmDelete(): Promise<void> {
|
||||
testLogger.info('确认删除角色');
|
||||
|
||||
try {
|
||||
await this.page.waitForSelector(this.selectors.modalConfirmButton, {
|
||||
state: 'visible',
|
||||
timeout: this.timeout.element
|
||||
});
|
||||
|
||||
await this.page.click(this.selectors.modalConfirmButton);
|
||||
|
||||
await this.page.waitForSelector(this.selectors.modal, {
|
||||
state: 'hidden',
|
||||
timeout: this.timeout.element
|
||||
});
|
||||
|
||||
testLogger.info('角色删除确认成功');
|
||||
} catch (error) {
|
||||
testLogger.error('确认删除角色失败', error as Error);
|
||||
await this.screenshotHelper.takeScreenshot('confirm-delete-error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async searchRole(keyword: string): Promise<void> {
|
||||
testLogger.info(`搜索角色,关键词: ${keyword}`);
|
||||
|
||||
try {
|
||||
await this.page.waitForSelector(this.selectors.searchInput, {
|
||||
state: 'visible',
|
||||
timeout: this.timeout.element
|
||||
});
|
||||
|
||||
await this.page.fill(this.selectors.searchInput, keyword);
|
||||
await this.page.click(this.selectors.searchButton);
|
||||
|
||||
await this.page.waitForLoadState('networkidle', {
|
||||
timeout: this.timeout.network
|
||||
});
|
||||
|
||||
testLogger.info('角色搜索完成');
|
||||
} catch (error) {
|
||||
testLogger.error(`搜索角色失败,关键词: ${keyword}`, error as Error);
|
||||
await this.screenshotHelper.takeScreenshot('search-role-error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getSuccessMessage(): Promise<string> {
|
||||
testLogger.debug('获取成功消息');
|
||||
|
||||
try {
|
||||
const successElement = this.page.locator(this.selectors.successMessage);
|
||||
await successElement.waitFor({ state: 'visible', timeout: this.timeout.element });
|
||||
|
||||
const message = await successElement.textContent();
|
||||
testLogger.debug(`成功消息: ${message}`);
|
||||
|
||||
return message || '';
|
||||
} catch (error) {
|
||||
testLogger.error('获取成功消息失败', error as Error);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
async getErrorMessage(): Promise<string> {
|
||||
testLogger.debug('获取错误消息');
|
||||
|
||||
try {
|
||||
const errorElement = this.page.locator(this.selectors.errorMessage);
|
||||
await errorElement.waitFor({ state: 'visible', timeout: this.timeout.element });
|
||||
|
||||
const message = await errorElement.textContent();
|
||||
testLogger.debug(`错误消息: ${message}`);
|
||||
|
||||
return message || '';
|
||||
} catch (error) {
|
||||
testLogger.error('获取错误消息失败', error as Error);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
async getRoleCount(): Promise<number> {
|
||||
testLogger.debug('获取角色数量');
|
||||
|
||||
try {
|
||||
const count = await this.tableHelper.getRowCount(this.selectors.roleTable);
|
||||
testLogger.debug(`角色数量: ${count}`);
|
||||
|
||||
return count;
|
||||
} catch (error) {
|
||||
testLogger.error('获取角色数量失败', error as Error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import { Page } from '@playwright/test';
|
||||
import { BasePage } from './base-page';
|
||||
import { FormHelper } from '../helpers/form-helper';
|
||||
import { TableHelper } from '../helpers/table-helper';
|
||||
import { ScreenshotHelper } from '../helpers/screenshot-helper';
|
||||
import { testLogger } from '../core/test-logger';
|
||||
|
||||
export class UserManagementPage extends BasePage {
|
||||
private formHelper: FormHelper;
|
||||
private tableHelper: TableHelper;
|
||||
private screenshotHelper: ScreenshotHelper;
|
||||
|
||||
private readonly selectors = {
|
||||
userTable: '[data-testid="user-table"]',
|
||||
addUserButton: '[data-testid="add-user-button"]',
|
||||
editButton: 'button:has-text("编辑")',
|
||||
deleteButton: 'button:has-text("删除")',
|
||||
searchInput: '[data-testid="username-search-input"]',
|
||||
emailSearchInput: '[data-testid="email-search-input"]',
|
||||
statusSelect: '[data-testid="status-select"]',
|
||||
searchButton: '[data-testid="search-button"]',
|
||||
resetButton: '[data-testid="reset-button"]',
|
||||
modal: '.ant-modal',
|
||||
modalTitle: '.ant-modal-title',
|
||||
modalConfirmButton: '.ant-modal .ant-btn-primary',
|
||||
modalCancelButton: '.ant-modal .ant-btn-default',
|
||||
successMessage: '.ant-message-success',
|
||||
errorMessage: '.ant-message-error',
|
||||
pagination: '.ant-pagination',
|
||||
userForm: '.user-form',
|
||||
usernameInput: 'input[name="username"]',
|
||||
passwordInput: 'input[name="password"]',
|
||||
emailInput: 'input[name="email"]',
|
||||
phoneInput: 'input[name="phone"]',
|
||||
realNameInput: 'input[name="realName"]',
|
||||
statusSelect: 'select[name="status"]',
|
||||
roleSelect: 'select[name="roleIds"]'
|
||||
};
|
||||
|
||||
constructor(page: Page) {
|
||||
super(page);
|
||||
this.formHelper = new FormHelper(page);
|
||||
this.tableHelper = new TableHelper(page);
|
||||
this.screenshotHelper = new ScreenshotHelper(page);
|
||||
}
|
||||
|
||||
async navigate(): Promise<void> {
|
||||
testLogger.info('导航到用户管理页面');
|
||||
await super.navigate('/system/user');
|
||||
}
|
||||
|
||||
async waitForLoad(): Promise<void> {
|
||||
testLogger.info('等待用户管理页面加载');
|
||||
|
||||
try {
|
||||
await this.page.waitForSelector(this.selectors.userTable, {
|
||||
state: 'visible',
|
||||
timeout: this.timeout.default
|
||||
});
|
||||
|
||||
testLogger.info('用户管理页面加载完成');
|
||||
} catch (error) {
|
||||
testLogger.error('用户管理页面加载超时', error as Error);
|
||||
await this.screenshotHelper.takeScreenshot('user-management-load-error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async clickAddUser(): Promise<void> {
|
||||
testLogger.info('点击新增用户按钮');
|
||||
|
||||
try {
|
||||
await this.page.waitForSelector(this.selectors.addUserButton, {
|
||||
state: 'visible',
|
||||
timeout: this.timeout.element
|
||||
});
|
||||
|
||||
await this.page.click(this.selectors.addUserButton);
|
||||
|
||||
await this.page.waitForSelector(this.selectors.modal, {
|
||||
state: 'visible',
|
||||
timeout: this.timeout.element
|
||||
});
|
||||
|
||||
testLogger.info('新增用户对话框已打开');
|
||||
} catch (error) {
|
||||
testLogger.error('点击新增用户按钮失败', error as Error);
|
||||
await this.screenshotHelper.takeScreenshot('click-add-user-error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async clickEditUser(rowIndex: number): Promise<void> {
|
||||
testLogger.info(`点击编辑用户按钮,行索引: ${rowIndex}`);
|
||||
|
||||
try {
|
||||
const editButtons = this.page.locator(this.selectors.editButton);
|
||||
await editButtons.nth(rowIndex).waitFor({ state: 'visible', timeout: this.timeout.element });
|
||||
|
||||
await editButtons.nth(rowIndex).click();
|
||||
|
||||
await this.page.waitForSelector(this.selectors.modal, {
|
||||
state: 'visible',
|
||||
timeout: this.timeout.element
|
||||
});
|
||||
|
||||
testLogger.info('编辑用户对话框已打开');
|
||||
} catch (error) {
|
||||
testLogger.error(`点击编辑用户按钮失败,行索引: ${rowIndex}`, error as Error);
|
||||
await this.screenshotHelper.takeScreenshot(`click-edit-user-${rowIndex}-error`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async clickDeleteUser(rowIndex: number): Promise<void> {
|
||||
testLogger.info(`点击删除用户按钮,行索引: ${rowIndex}`);
|
||||
|
||||
try {
|
||||
const deleteButtons = this.page.locator(this.selectors.deleteButton);
|
||||
await deleteButtons.nth(rowIndex).waitFor({ state: 'visible', timeout: this.timeout.element });
|
||||
|
||||
await deleteButtons.nth(rowIndex).click();
|
||||
|
||||
testLogger.info('删除确认对话框已打开');
|
||||
} catch (error) {
|
||||
testLogger.error(`点击删除用户按钮失败,行索引: ${rowIndex}`, error as Error);
|
||||
await this.screenshotHelper.takeScreenshot(`click-delete-user-${rowIndex}-error`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async confirmDelete(): Promise<void> {
|
||||
testLogger.info('确认删除用户');
|
||||
|
||||
try {
|
||||
await this.page.waitForSelector(this.selectors.modalConfirmButton, {
|
||||
state: 'visible',
|
||||
timeout: this.timeout.element
|
||||
});
|
||||
|
||||
await this.page.click(this.selectors.modalConfirmButton);
|
||||
|
||||
await this.page.waitForSelector(this.selectors.modal, {
|
||||
state: 'hidden',
|
||||
timeout: this.timeout.element
|
||||
});
|
||||
|
||||
testLogger.info('用户删除确认成功');
|
||||
} catch (error) {
|
||||
testLogger.error('确认删除用户失败', error as Error);
|
||||
await this.screenshotHelper.takeScreenshot('confirm-delete-error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async searchUser(keyword: string): Promise<void> {
|
||||
testLogger.info(`搜索用户,关键词: ${keyword}`);
|
||||
|
||||
try {
|
||||
await this.page.waitForSelector(this.selectors.searchInput, {
|
||||
state: 'visible',
|
||||
timeout: this.timeout.element
|
||||
});
|
||||
|
||||
await this.page.fill(this.selectors.searchInput, keyword);
|
||||
await this.page.click(this.selectors.searchButton);
|
||||
|
||||
await this.page.waitForLoadState('networkidle', {
|
||||
timeout: this.timeout.network
|
||||
});
|
||||
|
||||
testLogger.info('用户搜索完成');
|
||||
} catch (error) {
|
||||
testLogger.error(`搜索用户失败,关键词: ${keyword}`, error as Error);
|
||||
await this.screenshotHelper.takeScreenshot('search-user-error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async resetSearch(): Promise<void> {
|
||||
testLogger.info('重置搜索条件');
|
||||
|
||||
try {
|
||||
await this.page.waitForSelector(this.selectors.resetButton, {
|
||||
state: 'visible',
|
||||
timeout: this.timeout.element
|
||||
});
|
||||
|
||||
await this.page.click(this.selectors.resetButton);
|
||||
|
||||
await this.page.waitForLoadState('networkidle', {
|
||||
timeout: this.timeout.network
|
||||
});
|
||||
|
||||
testLogger.info('搜索条件已重置');
|
||||
} catch (error) {
|
||||
testLogger.error('重置搜索条件失败', error as Error);
|
||||
await this.screenshotHelper.takeScreenshot('reset-search-error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getSuccessMessage(): Promise<string> {
|
||||
testLogger.debug('获取成功消息');
|
||||
|
||||
try {
|
||||
const successElement = this.page.locator(this.selectors.successMessage);
|
||||
await successElement.waitFor({ state: 'visible', timeout: this.timeout.element });
|
||||
|
||||
const message = await successElement.textContent();
|
||||
testLogger.debug(`成功消息: ${message}`);
|
||||
|
||||
return message || '';
|
||||
} catch (error) {
|
||||
testLogger.error('获取成功消息失败', error as Error);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
async getErrorMessage(): Promise<string> {
|
||||
testLogger.debug('获取错误消息');
|
||||
|
||||
try {
|
||||
const errorElement = this.page.locator(this.selectors.errorMessage);
|
||||
await errorElement.waitFor({ state: 'visible', timeout: this.timeout.element });
|
||||
|
||||
const message = await errorElement.textContent();
|
||||
testLogger.debug(`错误消息: ${message}`);
|
||||
|
||||
return message || '';
|
||||
} catch (error) {
|
||||
testLogger.error('获取错误消息失败', error as Error);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
async getUserCount(): Promise<number> {
|
||||
testLogger.debug('获取用户数量');
|
||||
|
||||
try {
|
||||
const count = await this.tableHelper.getRowCount(this.selectors.userTable);
|
||||
testLogger.debug(`用户数量: ${count}`);
|
||||
|
||||
return count;
|
||||
} catch (error) {
|
||||
testLogger.error('获取用户数量失败', error as Error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
# 性能测试文档
|
||||
|
||||
## 概述
|
||||
|
||||
性能测试用于评估系统在不同负载条件下的性能表现,包括页面加载速度、API响应时间、并发处理能力和资源使用情况。
|
||||
|
||||
## 测试框架
|
||||
|
||||
性能测试基于 Playwright 框架,提供以下功能:
|
||||
- 页面加载性能测试
|
||||
- API响应性能测试
|
||||
- 并发和负载测试
|
||||
- 内存使用监控
|
||||
- 网络请求统计
|
||||
|
||||
### 核心文件
|
||||
|
||||
- `performance.spec.ts`: 页面加载性能测试和性能指标收集工具
|
||||
- `api-performance.spec.ts`: API响应性能测试
|
||||
- `concurrency-performance.spec.ts`: 并发和负载测试
|
||||
|
||||
## 运行性能测试
|
||||
|
||||
### 前置条件
|
||||
|
||||
1. 确保后端服务已启动
|
||||
2. 确保前端开发服务器已启动
|
||||
3. 确保数据库已初始化测试数据
|
||||
4. 确保系统资源充足(CPU、内存、网络)
|
||||
|
||||
### 运行所有性能测试
|
||||
|
||||
```bash
|
||||
npx playwright test e2e/performance
|
||||
```
|
||||
|
||||
### 运行特定性能测试套件
|
||||
|
||||
```bash
|
||||
# 运行页面加载性能测试
|
||||
npx playwright test e2e/performance/performance.spec.ts
|
||||
|
||||
# 运行API响应性能测试
|
||||
npx playwright test e2e/performance/api-performance.spec.ts
|
||||
|
||||
# 运行并发和负载测试
|
||||
npx playwright test e2e/performance/concurrency-performance.spec.ts
|
||||
```
|
||||
|
||||
### 运行性能测试(UI模式)
|
||||
|
||||
```bash
|
||||
npx playwright test e2e/performance --ui
|
||||
```
|
||||
|
||||
### 调试性能测试
|
||||
|
||||
```bash
|
||||
npx playwright test e2e/performance --debug
|
||||
```
|
||||
|
||||
## 性能指标
|
||||
|
||||
### 性能指标类
|
||||
|
||||
`PerformanceMetrics` 类提供以下统计方法:
|
||||
|
||||
- `getAverage(name)`: 获取平均值
|
||||
- `getP95(name)`: 获取95百分位值
|
||||
- `getP99(name)`: 获取99百分位值
|
||||
- `getMax(name)`: 获取最大值
|
||||
- `getMin(name)`: 获取最小值
|
||||
|
||||
### 性能测试辅助类
|
||||
|
||||
`PerformanceTestHelper` 类提供以下辅助方法:
|
||||
|
||||
- `measurePageLoad(page, url)`: 测量页面加载时间
|
||||
- `measureApiCall(page, apiCall)`: 测量API调用时间
|
||||
- `measureElementInteraction(page, selector, action)`: 测量元素交互时间
|
||||
- `measurePageNavigation(page, fromUrl, toUrl)`: 测量页面导航时间
|
||||
- `measureMemoryUsage(page)`: 测量内存使用情况
|
||||
- `measureNetworkRequests(page)`: 测量网络请求数量
|
||||
|
||||
## 性能测试用例
|
||||
|
||||
### PT-001: 页面加载性能
|
||||
|
||||
| 用例ID | 用例名称 | 性能目标 |
|
||||
|---------|---------|---------|
|
||||
| PT-001 | 登录页面加载性能 | < 3000ms |
|
||||
| PT-002 | 仪表盘页面加载性能 | < 2000ms |
|
||||
| PT-003 | 用户管理页面加载性能 | < 2000ms |
|
||||
| PT-004 | 黄历页面加载性能 | < 2000ms |
|
||||
| PT-005 | 运势页面加载性能 | < 2000ms |
|
||||
|
||||
### PT-006: API响应性能
|
||||
|
||||
| 用例ID | 用例名称 | 性能目标 |
|
||||
|---------|---------|---------|
|
||||
| PT-006 | 用户登录API性能 | < 2000ms |
|
||||
| PT-007 | 获取用户列表API性能 | < 1500ms |
|
||||
| PT-008 | 创建用户API性能 | < 2000ms |
|
||||
| PT-009 | 黄历查询API性能 | < 1500ms |
|
||||
| PT-010 | 运势查询API性能 | < 2000ms |
|
||||
| PT-011 | 紫微斗数生成API性能 | < 3000ms |
|
||||
|
||||
### PT-012: 并发和负载测试
|
||||
|
||||
| 用例ID | 用例名称 | 性能目标 |
|
||||
|---------|---------|---------|
|
||||
| PT-012 | 并发登录测试 | 平均响应时间 < 5000ms |
|
||||
| PT-013 | 并发黄历查询测试 | 平均响应时间 < 3000ms |
|
||||
| PT-014 | 页面切换性能测试 | 页面切换 < 1000ms |
|
||||
| PT-015 | 表单提交性能测试 | 表单提交 < 1000ms |
|
||||
| PT-016 | 内存使用监控 | 内存增长 < 50MB |
|
||||
| PT-017 | 网络请求统计 | 平均请求次数 < 20 |
|
||||
|
||||
## 性能基准
|
||||
|
||||
### 页面加载性能基准
|
||||
|
||||
- **优秀**: < 1000ms
|
||||
- **良好**: 1000ms - 2000ms
|
||||
- **可接受**: 2000ms - 3000ms
|
||||
- **需要优化**: > 3000ms
|
||||
|
||||
### API响应性能基准
|
||||
|
||||
- **优秀**: < 500ms
|
||||
- **良好**: 500ms - 1000ms
|
||||
- **可接受**: 1000ms - 2000ms
|
||||
- **需要优化**: > 2000ms
|
||||
|
||||
### 并发性能基准
|
||||
|
||||
- **优秀**: 平均响应时间 < 1000ms
|
||||
- **良好**: 平均响应时间 1000ms - 3000ms
|
||||
- **可接受**: 平均响应时间 3000ms - 5000ms
|
||||
- **需要优化**: 平均响应时间 > 5000ms
|
||||
|
||||
## 性能报告
|
||||
|
||||
性能测试执行后会生成以下报告:
|
||||
|
||||
1. **控制台输出**: 实时显示性能指标
|
||||
2. **HTML报告**: `playwright-report/index.html`
|
||||
3. **JSON报告**: `test-results/results.json`
|
||||
|
||||
### 性能报告示例
|
||||
|
||||
```
|
||||
=== 性能测试报告 ===
|
||||
|
||||
登录页面加载时间:
|
||||
平均值: 1250.50ms
|
||||
P95: 1450.00ms
|
||||
P99: 1520.00ms
|
||||
最大值: 1600.00ms
|
||||
最小值: 1100.00ms
|
||||
样本数: 10
|
||||
|
||||
仪表盘页面加载时间:
|
||||
平均值: 890.30ms
|
||||
P95: 980.00ms
|
||||
P99: 1020.00ms
|
||||
最大值: 1100.00ms
|
||||
最小值: 750.00ms
|
||||
样本数: 10
|
||||
|
||||
====================
|
||||
```
|
||||
|
||||
## 性能优化建议
|
||||
|
||||
### 页面加载优化
|
||||
|
||||
1. **代码分割**: 使用动态导入减少初始加载时间
|
||||
2. **资源压缩**: 启用Gzip/Brotli压缩
|
||||
3. **CDN加速**: 使用CDN分发静态资源
|
||||
4. **缓存策略**: 实现合理的缓存策略
|
||||
5. **图片优化**: 使用WebP格式和懒加载
|
||||
|
||||
### API响应优化
|
||||
|
||||
1. **数据库优化**: 添加索引、优化查询
|
||||
2. **缓存机制**: 使用Redis缓存热点数据
|
||||
3. **异步处理**: 使用消息队列处理耗时操作
|
||||
4. **连接池**: 优化数据库连接池配置
|
||||
5. **分页查询**: 避免返回大量数据
|
||||
|
||||
### 并发处理优化
|
||||
|
||||
1. **负载均衡**: 使用负载均衡器分发请求
|
||||
2. **限流措施**: 实现API限流保护
|
||||
3. **连接复用**: 使用HTTP/2和连接复用
|
||||
4. **资源隔离**: 隔离不同服务的资源
|
||||
5. **自动扩容**: 实现自动扩容机制
|
||||
|
||||
## 性能监控
|
||||
|
||||
### 持续监控
|
||||
|
||||
建议在生产环境中实施以下监控:
|
||||
|
||||
1. **APM工具**: 使用New Relic、Datadog等APM工具
|
||||
2. **日志分析**: 使用ELK Stack分析日志
|
||||
3. **指标收集**: 使用Prometheus收集指标
|
||||
4. **告警机制**: 设置性能告警阈值
|
||||
5. **定期报告**: 生成定期性能报告
|
||||
|
||||
### 性能指标
|
||||
|
||||
建议监控以下关键指标:
|
||||
|
||||
1. **响应时间**: P50、P95、P99响应时间
|
||||
2. **吞吐量**: 每秒请求数(RPS)
|
||||
3. **错误率**: HTTP错误率
|
||||
4. **资源使用**: CPU、内存、磁盘使用率
|
||||
5. **网络流量**: 入站和出站流量
|
||||
|
||||
## 故障排查
|
||||
|
||||
### 常见性能问题
|
||||
|
||||
1. **页面加载缓慢**
|
||||
- 检查网络带宽
|
||||
- 检查服务器资源使用
|
||||
- 检查资源加载顺序
|
||||
- 使用浏览器开发者工具分析
|
||||
|
||||
2. **API响应缓慢**
|
||||
- 检查数据库查询性能
|
||||
- 检查缓存命中率
|
||||
- 检查网络延迟
|
||||
- 检查并发连接数
|
||||
|
||||
3. **内存泄漏**
|
||||
- 检查内存使用趋势
|
||||
- 检查对象引用
|
||||
- 使用内存分析工具
|
||||
- 优化数据结构
|
||||
|
||||
4. **并发性能差**
|
||||
- 检查线程池配置
|
||||
- 检查锁竞争
|
||||
- 检查资源争用
|
||||
- 优化并发算法
|
||||
|
||||
## 性能测试最佳实践
|
||||
|
||||
1. **测试环境**: 使用与生产环境相似的测试环境
|
||||
2. **测试数据**: 使用真实大小的测试数据
|
||||
3. **多次运行**: 每个测试运行多次取平均值
|
||||
4. **基线对比**: 与历史基线对比性能变化
|
||||
5. **持续监控**: 建立持续性能监控机制
|
||||
|
||||
## 联系方式
|
||||
|
||||
如有问题,请联系测试团队或查看项目文档。
|
||||
@@ -0,0 +1,110 @@
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
import { LoginPage } from '../pages/login-page';
|
||||
import { DashboardPage } from '../pages/dashboard-page';
|
||||
import { UserManagementPage } from '../pages/user-management-page';
|
||||
import { testConfig } from '../core/test-config';
|
||||
import { PerformanceMetrics, PerformanceTestHelper } from '../helpers/performance-helper';
|
||||
|
||||
test.describe.configure({
|
||||
mode: 'serial',
|
||||
timeout: 120000
|
||||
});
|
||||
|
||||
test.describe('性能测试 - API响应性能', () => {
|
||||
const metrics = new PerformanceMetrics();
|
||||
const helper = new PerformanceTestHelper();
|
||||
|
||||
test.afterAll(() => {
|
||||
metrics.printReport();
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
const loginPage = new LoginPage(page);
|
||||
await page.goto(testConfig.getBaseURL());
|
||||
await loginPage.login('admin', 'admin123');
|
||||
});
|
||||
|
||||
test('PT-006: 用户登录API性能', async ({ page }) => {
|
||||
await helper.clearCacheAndCookies(page);
|
||||
|
||||
const responseTime = await helper.measureApiCall(page, async () => {
|
||||
await page.goto(testConfig.getBaseURL());
|
||||
const loginPage = new LoginPage(page);
|
||||
await loginPage.login('admin', 'admin123');
|
||||
});
|
||||
|
||||
metrics.recordMetric('用户登录API响应时间', responseTime);
|
||||
expect(responseTime).toBeLessThan(2000);
|
||||
});
|
||||
|
||||
test('PT-007: 获取用户列表API性能', async ({ page }) => {
|
||||
const responseTime = await helper.measureApiCall(page, async () => {
|
||||
await page.goto(`${testConfig.getBaseURL()}/users`);
|
||||
await page.waitForSelector('[data-testid="user-table"]');
|
||||
});
|
||||
|
||||
metrics.recordMetric('获取用户列表API响应时间', responseTime);
|
||||
expect(responseTime).toBeLessThan(1500);
|
||||
});
|
||||
|
||||
test('PT-008: 创建用户API性能', async ({ page }) => {
|
||||
const userManagementPage = new UserManagementPage(page);
|
||||
const testUsername = `perfuser_${Date.now()}`;
|
||||
|
||||
const responseTime = await helper.measureApiCall(page, async () => {
|
||||
await page.goto(`${testConfig.getBaseURL()}/users`);
|
||||
await userManagementPage.clickAddUser();
|
||||
await userManagementPage.fillUserForm({
|
||||
username: testUsername,
|
||||
email: `perfuser_${Date.now()}@example.com`,
|
||||
password: 'Test@123456',
|
||||
confirmPassword: 'Test@123456',
|
||||
role: 'USER',
|
||||
status: 'ACTIVE'
|
||||
});
|
||||
await userManagementPage.submitUserForm();
|
||||
await page.waitForSelector('.ant-message-success');
|
||||
});
|
||||
|
||||
metrics.recordMetric('创建用户API响应时间', responseTime);
|
||||
expect(responseTime).toBeLessThan(2000);
|
||||
});
|
||||
|
||||
test('PT-009: 黄历查询API性能', async ({ page }) => {
|
||||
const responseTime = await helper.measureApiCall(page, async () => {
|
||||
await page.goto(`${testConfig.getBaseURL()}/almanac`);
|
||||
await page.fill('[data-testid="date-picker"]', '2024-01-01');
|
||||
await page.click('[data-testid="query-button"]');
|
||||
await page.waitForSelector('[data-testid="almanac-result"]');
|
||||
});
|
||||
|
||||
metrics.recordMetric('黄历查询API响应时间', responseTime);
|
||||
expect(responseTime).toBeLessThan(1500);
|
||||
});
|
||||
|
||||
test('PT-010: 运势查询API性能', async ({ page }) => {
|
||||
const responseTime = await helper.measureApiCall(page, async () => {
|
||||
await page.goto(`${testConfig.getBaseURL()}/fortune`);
|
||||
await page.fill('[data-testid="fortune-date"]', '2024-01-15');
|
||||
await page.click('[data-testid="query-fortune-button"]');
|
||||
await page.waitForSelector('[data-testid="daily-fortune"]');
|
||||
});
|
||||
|
||||
metrics.recordMetric('运势查询API响应时间', responseTime);
|
||||
expect(responseTime).toBeLessThan(2000);
|
||||
});
|
||||
|
||||
test('PT-011: 紫微斗数生成API性能', async ({ page }) => {
|
||||
const responseTime = await helper.measureApiCall(page, async () => {
|
||||
await page.goto(`${testConfig.getBaseURL()}/ziwei`);
|
||||
await page.fill('[data-testid="birth-date"]', '1990-05-15');
|
||||
await page.fill('[data-testid="birth-time"]', '08:30');
|
||||
await page.click('[data-testid="gender-male"]');
|
||||
await page.click('[data-testid="generate-chart-button"]');
|
||||
await page.waitForSelector('[data-testid="ziwei-chart"]');
|
||||
});
|
||||
|
||||
metrics.recordMetric('紫微斗数生成API响应时间', responseTime);
|
||||
expect(responseTime).toBeLessThan(3000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
import { LoginPage } from '../pages/login-page';
|
||||
import { testConfig } from '../core/test-config';
|
||||
import { PerformanceMetrics, PerformanceTestHelper } from '../helpers/performance-helper';
|
||||
|
||||
test.describe.configure({
|
||||
mode: 'serial',
|
||||
timeout: 180000
|
||||
});
|
||||
|
||||
test.describe('性能测试 - 并发和负载测试', () => {
|
||||
const metrics = new PerformanceMetrics();
|
||||
const helper = new PerformanceTestHelper();
|
||||
|
||||
test.afterAll(() => {
|
||||
metrics.printReport();
|
||||
});
|
||||
|
||||
test('PT-012: 并发登录测试', async ({ browser }) => {
|
||||
const concurrency = 5;
|
||||
const promises: Promise<void>[] = [];
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
for (let i = 0; i < concurrency; i++) {
|
||||
promises.push(
|
||||
(async () => {
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await page.goto(testConfig.getBaseURL());
|
||||
const loginPage = new LoginPage(page);
|
||||
await loginPage.login(`user${i}`, `password${i}`);
|
||||
|
||||
const endTime = Date.now();
|
||||
const duration = endTime - startTime;
|
||||
metrics.recordMetric(`并发登录用户${i}响应时间`, duration);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
})()
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
const averageTime = metrics.getAverage('并发登录用户0响应时间');
|
||||
expect(averageTime).toBeLessThan(5000);
|
||||
});
|
||||
|
||||
test('PT-013: 并发黄历查询测试', async ({ browser }) => {
|
||||
const concurrency = 10;
|
||||
const promises: Promise<void>[] = [];
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
for (let i = 0; i < concurrency; i++) {
|
||||
promises.push(
|
||||
(async () => {
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await page.goto(testConfig.getBaseURL());
|
||||
const loginPage = new LoginPage(page);
|
||||
await loginPage.login('admin', 'admin123');
|
||||
|
||||
await page.goto(`${testConfig.getBaseURL()}/almanac`);
|
||||
await page.fill('[data-testid="date-picker"]', `2024-01-${(i % 30) + 1}`);
|
||||
await page.click('[data-testid="query-button"]');
|
||||
await page.waitForSelector('[data-testid="almanac-result"]');
|
||||
|
||||
const endTime = Date.now();
|
||||
const duration = endTime - startTime;
|
||||
metrics.recordMetric(`并发黄历查询${i}响应时间`, duration);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
})()
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
const averageTime = metrics.getAverage('并发黄历查询0响应时间');
|
||||
expect(averageTime).toBeLessThan(3000);
|
||||
});
|
||||
|
||||
test('PT-014: 页面切换性能测试', async ({ page }) => {
|
||||
const loginPage = new LoginPage(page);
|
||||
await page.goto(testConfig.getBaseURL());
|
||||
await loginPage.login('admin', 'admin123');
|
||||
|
||||
const pages = [
|
||||
'/dashboard',
|
||||
'/users',
|
||||
'/almanac',
|
||||
'/fortune',
|
||||
'/ziwei'
|
||||
];
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
for (const pagePath of pages) {
|
||||
const startTime = Date.now();
|
||||
await page.goto(`${testConfig.getBaseURL()}${pagePath}`, { waitUntil: 'networkidle' });
|
||||
const endTime = Date.now();
|
||||
const duration = endTime - startTime;
|
||||
metrics.recordMetric(`页面切换-${pagePath}`, duration);
|
||||
}
|
||||
}
|
||||
|
||||
const averageDashboardTime = metrics.getAverage('页面切换-/dashboard');
|
||||
const averageUsersTime = metrics.getAverage('页面切换-/users');
|
||||
const averageAlmanacTime = metrics.getAverage('页面切换-/almanac');
|
||||
|
||||
expect(averageDashboardTime).toBeLessThan(1000);
|
||||
expect(averageUsersTime).toBeLessThan(1000);
|
||||
expect(averageAlmanacTime).toBeLessThan(1000);
|
||||
});
|
||||
|
||||
test('PT-015: 表单提交性能测试', async ({ page }) => {
|
||||
const loginPage = new LoginPage(page);
|
||||
await page.goto(testConfig.getBaseURL());
|
||||
await loginPage.login('admin', 'admin123');
|
||||
|
||||
await page.goto(`${testConfig.getBaseURL()}/almanac`);
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const startTime = Date.now();
|
||||
await page.fill('[data-testid="date-picker"]', `2024-01-${(i % 30) + 1}`);
|
||||
await page.click('[data-testid="query-button"]');
|
||||
await page.waitForSelector('[data-testid="almanac-result"]');
|
||||
const endTime = Date.now();
|
||||
const duration = endTime - startTime;
|
||||
metrics.recordMetric(`表单提交-${i}`, duration);
|
||||
}
|
||||
|
||||
const averageTime = metrics.getAverage('表单提交-0');
|
||||
expect(averageTime).toBeLessThan(1000);
|
||||
});
|
||||
|
||||
test('PT-016: 内存使用监控', async ({ page }) => {
|
||||
const loginPage = new LoginPage(page);
|
||||
await page.goto(testConfig.getBaseURL());
|
||||
await loginPage.login('admin', 'admin123');
|
||||
|
||||
const memoryUsages: number[] = [];
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const metrics = await helper.measureMemoryUsage(page);
|
||||
memoryUsages.push(metrics.used);
|
||||
|
||||
await page.goto(`${testConfig.getBaseURL()}/almanac`);
|
||||
await page.fill('[data-testid="date-picker"]', `2024-01-${(i % 30) + 1}`);
|
||||
await page.click('[data-testid="query-button"]');
|
||||
await page.waitForSelector('[data-testid="almanac-result"]');
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
const maxMemory = Math.max(...memoryUsages);
|
||||
const minMemory = Math.min(...memoryUsages);
|
||||
const memoryGrowth = maxMemory - minMemory;
|
||||
|
||||
console.log(`\n内存使用统计:`);
|
||||
console.log(` 最小值: ${(minMemory / 1024 / 1024).toFixed(2)} MB`);
|
||||
console.log(` 最大值: ${(maxMemory / 1024 / 1024).toFixed(2)} MB`);
|
||||
console.log(` 增长: ${(memoryGrowth / 1024 / 1024).toFixed(2)} MB`);
|
||||
|
||||
expect(memoryGrowth).toBeLessThan(50 * 1024 * 1024);
|
||||
});
|
||||
|
||||
test('PT-017: 网络请求统计', async ({ page }) => {
|
||||
const loginPage = new LoginPage(page);
|
||||
await page.goto(testConfig.getBaseURL());
|
||||
await loginPage.login('admin', 'admin123');
|
||||
|
||||
const requestCounts: number[] = [];
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
let requestCount = 0;
|
||||
|
||||
page.on('request', () => {
|
||||
requestCount++;
|
||||
});
|
||||
|
||||
await page.goto(`${testConfig.getBaseURL()}/almanac`);
|
||||
await page.fill('[data-testid="date-picker"]', `2024-01-${(i % 30) + 1}`);
|
||||
await page.click('[data-testid="query-button"]');
|
||||
await page.waitForSelector('[data-testid="almanac-result"]');
|
||||
|
||||
requestCounts.push(requestCount);
|
||||
metrics.recordMetric(`页面请求次数-${i}`, requestCount);
|
||||
}
|
||||
|
||||
const averageRequests = metrics.getAverage('页面请求次数-0');
|
||||
console.log(`\n网络请求统计:`);
|
||||
console.log(` 平均请求次数: ${averageRequests.toFixed(0)}`);
|
||||
|
||||
expect(averageRequests).toBeLessThan(20);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
import { LoginPage } from '../pages/login-page';
|
||||
import { testConfig } from '../core/test-config';
|
||||
|
||||
export class PerformanceMetrics {
|
||||
private metrics: Map<string, number[]> = new Map();
|
||||
|
||||
recordMetric(name: string, value: number) {
|
||||
if (!this.metrics.has(name)) {
|
||||
this.metrics.set(name, []);
|
||||
}
|
||||
this.metrics.get(name)!.push(value);
|
||||
}
|
||||
|
||||
getAverage(name: string): number {
|
||||
const values = this.metrics.get(name) || [];
|
||||
if (values.length === 0) return 0;
|
||||
const sum = values.reduce((a, b) => a + b, 0);
|
||||
return sum / values.length;
|
||||
}
|
||||
|
||||
getP95(name: string): number {
|
||||
const values = this.metrics.get(name) || [];
|
||||
if (values.length === 0) return 0;
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const index = Math.floor(sorted.length * 0.95);
|
||||
return sorted[index];
|
||||
}
|
||||
|
||||
getP99(name: string): number {
|
||||
const values = this.metrics.get(name) || [];
|
||||
if (values.length === 0) return 0;
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const index = Math.floor(sorted.length * 0.99);
|
||||
return sorted[index];
|
||||
}
|
||||
|
||||
getMax(name: string): number {
|
||||
const values = this.metrics.get(name) || [];
|
||||
if (values.length === 0) return 0;
|
||||
return Math.max(...values);
|
||||
}
|
||||
|
||||
getMin(name: string): number {
|
||||
const values = this.metrics.get(name) || [];
|
||||
if (values.length === 0) return 0;
|
||||
return Math.min(...values);
|
||||
}
|
||||
|
||||
printReport() {
|
||||
console.log('\n=== 性能测试报告 ===');
|
||||
for (const [name, values] of this.metrics.entries()) {
|
||||
console.log(`\n${name}:`);
|
||||
console.log(` 平均值: ${this.getAverage(name).toFixed(2)}ms`);
|
||||
console.log(` P95: ${this.getP95(name).toFixed(2)}ms`);
|
||||
console.log(` P99: ${this.getP99(name).toFixed(2)}ms`);
|
||||
console.log(` 最大值: ${this.getMax(name).toFixed(2)}ms`);
|
||||
console.log(` 最小值: ${this.getMin(name).toFixed(2)}ms`);
|
||||
console.log(` 样本数: ${values.length}`);
|
||||
}
|
||||
console.log('\n====================\n');
|
||||
}
|
||||
}
|
||||
|
||||
export class PerformanceTestHelper {
|
||||
static async measurePageLoad(page: Page, url: string): Promise<number> {
|
||||
const startTime = Date.now();
|
||||
await page.goto(url, { waitUntil: 'networkidle' });
|
||||
const endTime = Date.now();
|
||||
return endTime - startTime;
|
||||
}
|
||||
|
||||
static async measureApiCall(page: Page, apiCall: () => Promise<void>): Promise<number> {
|
||||
const startTime = Date.now();
|
||||
await apiCall();
|
||||
const endTime = Date.now();
|
||||
return endTime - startTime;
|
||||
}
|
||||
|
||||
static async measureElementInteraction(
|
||||
page: Page,
|
||||
selector: string,
|
||||
action: () => Promise<void>
|
||||
): Promise<number> {
|
||||
await page.waitForSelector(selector, { state: 'visible' });
|
||||
const startTime = Date.now();
|
||||
await action();
|
||||
const endTime = Date.now();
|
||||
return endTime - startTime;
|
||||
}
|
||||
|
||||
static async measurePageNavigation(
|
||||
page: Page,
|
||||
fromUrl: string,
|
||||
toUrl: string
|
||||
): Promise<number> {
|
||||
await page.goto(fromUrl, { waitUntil: 'networkidle' });
|
||||
const startTime = Date.now();
|
||||
await page.goto(toUrl, { waitUntil: 'networkidle' });
|
||||
const endTime = Date.now();
|
||||
return endTime - startTime;
|
||||
}
|
||||
|
||||
static async measureMemoryUsage(page: Page): Promise<{ used: number; total: number }> {
|
||||
const metrics = await page.evaluate(() => {
|
||||
if (performance && (performance as any).memory) {
|
||||
return {
|
||||
used: (performance as any).memory.usedJSHeapSize,
|
||||
total: (performance as any).memory.totalJSHeapSize
|
||||
};
|
||||
}
|
||||
return { used: 0, total: 0 };
|
||||
});
|
||||
return metrics;
|
||||
}
|
||||
|
||||
static async measureNetworkRequests(page: Page): Promise<number> {
|
||||
let requestCount = 0;
|
||||
|
||||
page.on('request', () => {
|
||||
requestCount++;
|
||||
});
|
||||
|
||||
return requestCount;
|
||||
}
|
||||
|
||||
static async clearCacheAndCookies(page: Page) {
|
||||
await page.context().clearCookies();
|
||||
await page.context().clearPermissions();
|
||||
}
|
||||
}
|
||||
|
||||
test.describe.configure({
|
||||
mode: 'serial',
|
||||
timeout: 120000
|
||||
});
|
||||
|
||||
test.describe('性能测试 - 页面加载性能', () => {
|
||||
const metrics = new PerformanceMetrics();
|
||||
const helper = new PerformanceTestHelper();
|
||||
|
||||
test.afterAll(() => {
|
||||
metrics.printReport();
|
||||
});
|
||||
|
||||
test('PT-001: 登录页面加载性能', async ({ page }) => {
|
||||
const loadTime = await helper.measurePageLoad(page, testConfig.getBaseURL());
|
||||
metrics.recordMetric('登录页面加载时间', loadTime);
|
||||
|
||||
expect(loadTime).toBeLessThan(3000);
|
||||
});
|
||||
|
||||
test('PT-002: 仪表盘页面加载性能', async ({ page }) => {
|
||||
const loginPage = new LoginPage(page);
|
||||
await page.goto(testConfig.getBaseURL());
|
||||
await loginPage.login('admin', 'admin123');
|
||||
|
||||
const loadTime = await helper.measurePageLoad(page, `${testConfig.getBaseURL()}/dashboard`);
|
||||
metrics.recordMetric('仪表盘页面加载时间', loadTime);
|
||||
|
||||
expect(loadTime).toBeLessThan(2000);
|
||||
});
|
||||
|
||||
test('PT-003: 用户管理页面加载性能', async ({ page }) => {
|
||||
const loginPage = new LoginPage(page);
|
||||
await page.goto(testConfig.getBaseURL());
|
||||
await loginPage.login('admin', 'admin123');
|
||||
|
||||
const loadTime = await helper.measurePageLoad(page, `${testConfig.getBaseURL()}/users`);
|
||||
metrics.recordMetric('用户管理页面加载时间', loadTime);
|
||||
|
||||
expect(loadTime).toBeLessThan(2000);
|
||||
});
|
||||
|
||||
test('PT-004: 黄历页面加载性能', async ({ page }) => {
|
||||
const loginPage = new LoginPage(page);
|
||||
await page.goto(testConfig.getBaseURL());
|
||||
await loginPage.login('admin', 'admin123');
|
||||
|
||||
const loadTime = await helper.measurePageLoad(page, `${testConfig.getBaseURL()}/almanac`);
|
||||
metrics.recordMetric('黄历页面加载时间', loadTime);
|
||||
|
||||
expect(loadTime).toBeLessThan(2000);
|
||||
});
|
||||
|
||||
test('PT-005: 运势页面加载性能', async ({ page }) => {
|
||||
const loginPage = new LoginPage(page);
|
||||
await page.goto(testConfig.getBaseURL());
|
||||
await loginPage.login('admin', 'admin123');
|
||||
|
||||
const loadTime = await helper.measurePageLoad(page, `${testConfig.getBaseURL()}/fortune`);
|
||||
metrics.recordMetric('运势页面加载时间', loadTime);
|
||||
|
||||
expect(loadTime).toBeLessThan(2000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { test, expect } from './test-fixtures';
|
||||
|
||||
test.describe('角色管理 - 完全Mock模式', () => {
|
||||
test.beforeEach(async ({ page, mockManager }) => {
|
||||
mockManager.enableMock();
|
||||
mockManager.configureMock({
|
||||
mode: 'full',
|
||||
delay: 100
|
||||
});
|
||||
|
||||
mockManager.presetTestData({
|
||||
roles: [
|
||||
{ id: 1, name: '超级管理员', roleKey: 'super_admin', description: '拥有所有权限', status: 'ENABLED', sortOrder: 1, remark: '系统默认角色', menuIds: [1, 2, 3], createdAt: '2024-01-01T10:00:00.000Z', updatedAt: '2024-01-01T10:00:00.000Z' },
|
||||
{ id: 2, name: '管理员', roleKey: 'admin', description: '拥有大部分权限', status: 'ENABLED', sortOrder: 2, remark: '系统管理员', menuIds: [1, 2], createdAt: '2024-01-02T10:00:00.000Z', updatedAt: '2024-01-02T10:00:00.000Z' },
|
||||
{ id: 3, name: '普通用户', roleKey: 'user', description: '拥有基本权限', status: 'ENABLED', sortOrder: 3, remark: '普通用户角色', menuIds: [1], createdAt: '2024-01-03T10:00:00.000Z', updatedAt: '2024-01-03T10:00:00.000Z' }
|
||||
]
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
await page.fill('input[placeholder="请输入用户名"]', 'admin');
|
||||
await page.fill('input[placeholder="请输入密码"]', 'admin123');
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForURL(/.*dashboard/, { timeout: 10000 });
|
||||
});
|
||||
|
||||
test.afterEach(async ({ mockManager }) => {
|
||||
mockManager.clearPresets();
|
||||
mockManager.disableMock();
|
||||
});
|
||||
|
||||
test('应该显示角色列表', async ({ page }) => {
|
||||
await page.goto('/roles');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await expect(page.locator('.ant-table')).toBeVisible();
|
||||
await expect(page.locator('text=超级管理员')).toBeVisible();
|
||||
await expect(page.locator('text=管理员')).toBeVisible();
|
||||
await expect(page.locator('text=普通用户')).toBeVisible();
|
||||
});
|
||||
|
||||
test('应该能够创建新角色', async ({ page }) => {
|
||||
await page.goto('/roles');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await page.click('button:has-text("新增角色")');
|
||||
await page.fill('input[placeholder="请输入角色名称"]', '测试角色');
|
||||
await page.fill('input[placeholder="请输入角色标识"]', 'test_role');
|
||||
await page.fill('textarea[placeholder="请输入角色描述"]', '这是一个测试角色');
|
||||
await page.click('button:has-text("确定")');
|
||||
|
||||
await expect(page.locator('text=创建成功')).toBeVisible();
|
||||
});
|
||||
|
||||
test('应该能够编辑角色', async ({ page }) => {
|
||||
await page.goto('/roles');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await page.click('button:has-text("编辑"):first');
|
||||
await page.fill('input[placeholder="请输入角色名称"]', '更新后的角色名称');
|
||||
await page.click('button:has-text("确定")');
|
||||
|
||||
await expect(page.locator('text=更新成功')).toBeVisible();
|
||||
});
|
||||
|
||||
test('应该能够删除角色', async ({ page }) => {
|
||||
await page.goto('/roles');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
page.on('dialog', dialog => dialog.accept());
|
||||
await page.click('button:has-text("删除"):first');
|
||||
|
||||
await expect(page.locator('text=删除成功')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { MockManager } from './mock-manager';
|
||||
|
||||
test.describe('角色管理', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
const mockManager = new MockManager({
|
||||
enabled: true,
|
||||
mode: 'full',
|
||||
mockPaths: [],
|
||||
delay: 0,
|
||||
logCalls: true,
|
||||
validateResponses: true,
|
||||
dataSource: 'memory'
|
||||
});
|
||||
|
||||
mockManager.presetTestData({
|
||||
menus: [
|
||||
{
|
||||
id: 1,
|
||||
name: '仪表盘',
|
||||
code: 'dashboard',
|
||||
path: '/dashboard',
|
||||
icon: 'DashboardOutlined',
|
||||
sortOrder: 1,
|
||||
status: 'active',
|
||||
parentId: 0,
|
||||
component: 'views/Dashboard.vue',
|
||||
createBy: 'system',
|
||||
updateBy: 'system',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
children: []
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: '角色管理',
|
||||
code: 'role',
|
||||
path: '/roles',
|
||||
icon: 'LockOutlined',
|
||||
sortOrder: 3,
|
||||
status: 'active',
|
||||
parentId: 0,
|
||||
component: 'views/RoleManagement.vue',
|
||||
createBy: 'system',
|
||||
updateBy: 'system',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
children: []
|
||||
}
|
||||
],
|
||||
roles: [
|
||||
{
|
||||
id: 1,
|
||||
name: '超级管理员',
|
||||
code: 'super_admin',
|
||||
status: 'active',
|
||||
permissions: ['*'],
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: '普通用户',
|
||||
code: 'user',
|
||||
status: 'active',
|
||||
permissions: ['user:view', 'user:create'],
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z'
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
await mockManager.interceptAPIRequest(page);
|
||||
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const usernameInput = page.locator('input[placeholder="请输入用户名"]');
|
||||
const passwordInput = page.locator('input[placeholder="请输入密码"]');
|
||||
const loginButton = page.locator('button[type="submit"]');
|
||||
|
||||
await usernameInput.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await passwordInput.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await loginButton.waitFor({ state: 'visible', timeout: 10000 });
|
||||
|
||||
await usernameInput.fill('admin');
|
||||
await passwordInput.fill('admin123');
|
||||
await loginButton.click();
|
||||
|
||||
await page.waitForURL(/.*dashboard/, { timeout: 10000 });
|
||||
});
|
||||
|
||||
test('应该显示角色列表页面', async ({ page }) => {
|
||||
await page.goto('/roles');
|
||||
await expect(page.getByText(/角色管理/i)).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /添加角色/i })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /刷新/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('应该显示角色数据表格', async ({ page }) => {
|
||||
await page.goto('/roles');
|
||||
|
||||
const table = page.locator('.ant-table');
|
||||
await expect(table).toBeVisible();
|
||||
|
||||
await expect(page.getByText(/角色名称/i)).toBeVisible();
|
||||
await expect(page.getByText(/角色编码/i)).toBeVisible();
|
||||
await expect(page.getByText(/状态/i)).toBeVisible();
|
||||
await expect(page.getByText(/创建时间/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('应该能够创建新角色', async ({ page }) => {
|
||||
await page.goto('/roles');
|
||||
|
||||
await page.getByRole('button', { name: /添加角色/i }).click();
|
||||
await expect(page).toHaveURL(/.*roles\/create/);
|
||||
|
||||
await page.getByPlaceholder(/请输入角色名称/i).fill('测试角色');
|
||||
await page.getByPlaceholder(/请输入角色编码/i).fill('TEST_ROLE');
|
||||
await page.getByPlaceholder(/请输入角色描述/i).fill('这是一个测试角色');
|
||||
|
||||
await page.getByRole('button', { name: /提交/i }).click();
|
||||
|
||||
await expect(page).toHaveURL(/.*roles/);
|
||||
await expect(page.getByText(/创建成功/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('创建角色时应该验证必填字段', async ({ page }) => {
|
||||
await page.goto('/roles');
|
||||
await page.getByRole('button', { name: /添加角色/i }).click();
|
||||
|
||||
await page.getByRole('button', { name: /提交/i }).click();
|
||||
|
||||
await expect(page.getByText(/请输入角色名称/i)).toBeVisible();
|
||||
await expect(page.getByText(/请输入角色编码/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('应该能够编辑角色', async ({ page }) => {
|
||||
await page.goto('/roles');
|
||||
|
||||
const editButton = page.getByRole('button').filter({ hasText: /编辑/i }).first();
|
||||
if (await editButton.isVisible()) {
|
||||
await editButton.click();
|
||||
await expect(page).toHaveURL(/.*roles\/\d+\/edit/);
|
||||
|
||||
const roleNameInput = page.getByPlaceholder(/请输入角色名称/i);
|
||||
await roleNameInput.clear();
|
||||
await roleNameInput.fill('更新后的角色名称');
|
||||
|
||||
await page.getByRole('button', { name: /提交/i }).click();
|
||||
|
||||
await expect(page).toHaveURL(/.*roles/);
|
||||
await expect(page.getByText(/更新成功/i)).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('应该能够删除角色', async ({ page }) => {
|
||||
await page.goto('/roles');
|
||||
|
||||
const deleteButton = page.getByRole('button').filter({ hasText: /删除/i }).first();
|
||||
if (await deleteButton.isVisible()) {
|
||||
await deleteButton.click();
|
||||
|
||||
await expect(page.getByText(/确认删除/i)).toBeVisible();
|
||||
await page.getByRole('button', { name: /确认/i }).click();
|
||||
|
||||
await expect(page.getByText(/删除成功/i)).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('应该能够查看角色详情', async ({ page }) => {
|
||||
await page.goto('/roles');
|
||||
|
||||
const detailButton = page.getByRole('button').filter({ hasText: /详情/i }).first();
|
||||
if (await detailButton.isVisible()) {
|
||||
await detailButton.click();
|
||||
await expect(page).toHaveURL(/.*roles\/\d+\/detail/);
|
||||
await expect(page.getByText(/角色详情/i)).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('应该能够刷新角色列表', async ({ page }) => {
|
||||
await page.goto('/roles');
|
||||
|
||||
await page.getByRole('button', { name: /刷新/i }).click();
|
||||
|
||||
await expect(page.getByText(/刷新成功/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('应该支持状态切换', async ({ page }) => {
|
||||
await page.goto('/roles/create');
|
||||
|
||||
const statusSelect = page.locator('.ant-select').filter({ hasText: /状态/i });
|
||||
await statusSelect.click();
|
||||
|
||||
await expect(page.getByText(/启用/i)).toBeVisible();
|
||||
await expect(page.getByText(/禁用/i)).toBeVisible();
|
||||
|
||||
await page.getByText(/禁用/i).click();
|
||||
await expect(statusSelect).toContainText(/禁用/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
import { test as base, Page, BrowserContext } from '@playwright/test';
|
||||
import { testConfig, TestEnvironment } from './core/test-config';
|
||||
import { testDataGenerator, UserData, RoleData, MenuData, PermissionData } from './core/test-data';
|
||||
import { testLogger } from './core/test-logger';
|
||||
import { testReporter } from './core/test-reporter';
|
||||
import { BasePage } from './pages/base-page';
|
||||
import { LoginPage } from './pages/login-page';
|
||||
import { DashboardPage } from './pages/dashboard-page';
|
||||
import { UserManagementPage } from './pages/user-management-page';
|
||||
import { RoleManagementPage } from './pages/role-management-page';
|
||||
import { MenuManagementPage } from './pages/menu-management-page';
|
||||
import { ScreenshotHelper } from './helpers/screenshot-helper';
|
||||
import { FormHelper } from './helpers/form-helper';
|
||||
import { TableHelper } from './helpers/table-helper';
|
||||
import { MockManager, MockConfig } from './mock-manager';
|
||||
|
||||
export interface TestFixtures {
|
||||
page: Page;
|
||||
context: BrowserContext;
|
||||
testConfig: TestEnvironment;
|
||||
testDataGenerator: typeof testDataGenerator;
|
||||
testLogger: typeof testLogger;
|
||||
testReporter: typeof testReporter;
|
||||
|
||||
pageObjects: {
|
||||
basePage: BasePage;
|
||||
loginPage: LoginPage;
|
||||
dashboardPage: DashboardPage;
|
||||
userManagementPage: UserManagementPage;
|
||||
roleManagementPage: RoleManagementPage;
|
||||
menuManagementPage: MenuManagementPage;
|
||||
};
|
||||
|
||||
helpers: {
|
||||
screenshot: ScreenshotHelper;
|
||||
form: FormHelper;
|
||||
table: TableHelper;
|
||||
};
|
||||
|
||||
mockManager: MockManager;
|
||||
|
||||
testData: {
|
||||
user: UserData;
|
||||
admin: UserData;
|
||||
role: RoleData;
|
||||
menu: MenuData;
|
||||
permission: PermissionData;
|
||||
};
|
||||
}
|
||||
|
||||
export const test = base.extend<TestFixtures>({
|
||||
page: async ({ page }, use) => {
|
||||
await use(page);
|
||||
},
|
||||
|
||||
context: async ({ context }, use) => {
|
||||
await use(context);
|
||||
},
|
||||
|
||||
testConfig: async ({}, use) => {
|
||||
const config = testConfig.getEnvironment();
|
||||
await use(config);
|
||||
},
|
||||
|
||||
testDataGenerator: async ({}, use) => {
|
||||
await use(testDataGenerator);
|
||||
},
|
||||
|
||||
testLogger: async ({}, use) => {
|
||||
await use(testLogger);
|
||||
},
|
||||
|
||||
testReporter: async ({}, use) => {
|
||||
await use(testReporter);
|
||||
},
|
||||
|
||||
pageObjects: async ({ page }, use) => {
|
||||
const pageObjects = {
|
||||
basePage: new BasePage(page),
|
||||
loginPage: new LoginPage(page),
|
||||
dashboardPage: new DashboardPage(page),
|
||||
userManagementPage: new UserManagementPage(page),
|
||||
roleManagementPage: new RoleManagementPage(page),
|
||||
menuManagementPage: new MenuManagementPage(page)
|
||||
};
|
||||
|
||||
await use(pageObjects);
|
||||
},
|
||||
|
||||
helpers: async ({ page }, use) => {
|
||||
const helpers = {
|
||||
screenshot: new ScreenshotHelper(page),
|
||||
form: new FormHelper(page),
|
||||
table: new TableHelper(page)
|
||||
};
|
||||
|
||||
await use(helpers);
|
||||
},
|
||||
|
||||
mockManager: async ({ page }, use) => {
|
||||
const mockConfig: MockConfig = {
|
||||
enabled: process.env.E2E_MOCK_ENABLED === 'true',
|
||||
mode: (process.env.E2E_MOCK_MODE as 'full' | 'partial' | 'none') || 'none',
|
||||
mockPaths: [],
|
||||
delay: 0,
|
||||
logCalls: true,
|
||||
validateResponses: true,
|
||||
dataSource: 'memory'
|
||||
};
|
||||
|
||||
const mockManager = new MockManager(mockConfig);
|
||||
|
||||
await mockManager.interceptAPIRequest(page);
|
||||
|
||||
await use(mockManager);
|
||||
},
|
||||
|
||||
testData: async ({}, use) => {
|
||||
const testData = {
|
||||
user: testDataGenerator.generateUserData({
|
||||
username: 'testuser',
|
||||
email: 'test@example.com',
|
||||
status: 'active'
|
||||
}),
|
||||
|
||||
admin: testDataGenerator.generateUserData({
|
||||
username: 'admin',
|
||||
email: 'admin@example.com',
|
||||
status: 'active',
|
||||
roleIds: [1]
|
||||
}),
|
||||
|
||||
role: testDataGenerator.generateRoleData({
|
||||
roleName: '测试角色',
|
||||
roleCode: 'test_role',
|
||||
status: 1
|
||||
}),
|
||||
|
||||
menu: testDataGenerator.generateMenuData({
|
||||
menuName: '测试菜单',
|
||||
menuType: 1,
|
||||
path: '/test',
|
||||
status: 0
|
||||
}),
|
||||
|
||||
permission: testDataGenerator.generatePermissionData({
|
||||
permissionName: '测试权限',
|
||||
permissionCode: 'test:permission',
|
||||
permissionType: 'button'
|
||||
})
|
||||
};
|
||||
|
||||
await use(testData);
|
||||
}
|
||||
});
|
||||
|
||||
export const expect = test.expect;
|
||||
|
||||
export type PageObjects = TestFixtures['pageObjects'];
|
||||
export type Helpers = TestFixtures['helpers'];
|
||||
export type TestData = TestFixtures['testData'];
|
||||
@@ -0,0 +1,184 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { MockManager } from './mock-manager';
|
||||
|
||||
test.describe('Token刷新机制', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
const mockManager = new MockManager({
|
||||
enabled: true,
|
||||
mode: 'full',
|
||||
mockPaths: [],
|
||||
delay: 0,
|
||||
logCalls: true,
|
||||
validateResponses: true,
|
||||
dataSource: 'memory'
|
||||
});
|
||||
|
||||
mockManager.presetTestData({
|
||||
menus: [
|
||||
{
|
||||
id: 1,
|
||||
name: '仪表盘',
|
||||
code: 'dashboard',
|
||||
path: '/dashboard',
|
||||
icon: 'DashboardOutlined',
|
||||
sortOrder: 1,
|
||||
status: 'active',
|
||||
parentId: 0,
|
||||
createBy: 'system',
|
||||
updateBy: 'system',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
children: []
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
await mockManager.interceptAPIRequest(page);
|
||||
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
});
|
||||
|
||||
test('应该能够成功登录并获取token', async ({ page }) => {
|
||||
const usernameInput = page.locator('input[placeholder="请输入用户名"]');
|
||||
const passwordInput = page.locator('input[placeholder="请输入密码"]');
|
||||
const loginButton = page.locator('button[type="submit"]');
|
||||
|
||||
await usernameInput.fill('admin');
|
||||
await passwordInput.fill('admin123');
|
||||
await loginButton.click();
|
||||
|
||||
await page.waitForURL(/.*dashboard/, { timeout: 10000 });
|
||||
|
||||
const token = await page.evaluate(() => localStorage.getItem('access_token'));
|
||||
const refreshToken = await page.evaluate(() => localStorage.getItem('refreshToken'));
|
||||
|
||||
expect(token).toBeTruthy();
|
||||
expect(refreshToken).toBeTruthy();
|
||||
expect(token).toBe('mock-token-123456');
|
||||
expect(refreshToken).toBe('mock-refresh-token-789012');
|
||||
});
|
||||
|
||||
test('token过期时应该自动刷新', async ({ page }) => {
|
||||
const usernameInput = page.locator('input[placeholder="请输入用户名"]');
|
||||
const passwordInput = page.locator('input[placeholder="请输入密码"]');
|
||||
const loginButton = page.locator('button[type="submit"]');
|
||||
|
||||
await usernameInput.fill('admin');
|
||||
await passwordInput.fill('admin123');
|
||||
await loginButton.click();
|
||||
|
||||
await page.waitForURL(/.*dashboard/, { timeout: 10000 });
|
||||
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem('access_token', 'expired-token');
|
||||
});
|
||||
|
||||
await page.reload();
|
||||
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const newToken = await page.evaluate(() => localStorage.getItem('access_token'));
|
||||
const newRefreshToken = await page.evaluate(() => localStorage.getItem('refreshToken'));
|
||||
|
||||
expect(newToken).toBeTruthy();
|
||||
expect(newRefreshToken).toBeTruthy();
|
||||
});
|
||||
|
||||
test('刷新token失败时应该跳转到登录页', async ({ page }) => {
|
||||
const usernameInput = page.locator('input[placeholder="请输入用户名"]');
|
||||
const passwordInput = page.locator('input[placeholder="请输入密码"]');
|
||||
const loginButton = page.locator('button[type="submit"]');
|
||||
|
||||
await usernameInput.fill('admin');
|
||||
await passwordInput.fill('admin123');
|
||||
await loginButton.click();
|
||||
|
||||
await page.waitForURL(/.*dashboard/, { timeout: 10000 });
|
||||
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem('access_token', 'expired-token');
|
||||
localStorage.setItem('refreshToken', 'invalid-refresh-token');
|
||||
});
|
||||
|
||||
await page.route('**/sys/auth/refresh', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
code: '401',
|
||||
message: 'Refresh token已过期',
|
||||
data: null
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
await page.reload();
|
||||
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
const currentUrl = page.url();
|
||||
expect(currentUrl).toContain('/login');
|
||||
});
|
||||
|
||||
test('没有refresh token时应该跳转到登录页', async ({ page }) => {
|
||||
const usernameInput = page.locator('input[placeholder="请输入用户名"]');
|
||||
const passwordInput = page.locator('input[placeholder="请输入密码"]');
|
||||
const loginButton = page.locator('button[type="submit"]');
|
||||
|
||||
await usernameInput.fill('admin');
|
||||
await passwordInput.fill('admin123');
|
||||
await loginButton.click();
|
||||
|
||||
await page.waitForURL(/.*dashboard/, { timeout: 10000 });
|
||||
|
||||
await page.evaluate(() => {
|
||||
localStorage.removeItem('refreshToken');
|
||||
});
|
||||
|
||||
await page.route('**/sys/user', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 401,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
code: '401',
|
||||
message: 'Token已过期',
|
||||
data: null
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
await page.reload();
|
||||
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
const currentUrl = page.url();
|
||||
expect(currentUrl).toContain('/login');
|
||||
});
|
||||
|
||||
test('token刷新成功后应该保持用户登录状态', async ({ page }) => {
|
||||
const usernameInput = page.locator('input[placeholder="请输入用户名"]');
|
||||
const passwordInput = page.locator('input[placeholder="请输入密码"]');
|
||||
const loginButton = page.locator('button[type="submit"]');
|
||||
|
||||
await usernameInput.fill('admin');
|
||||
await passwordInput.fill('admin123');
|
||||
await loginButton.click();
|
||||
|
||||
await page.waitForURL(/.*dashboard/, { timeout: 10000 });
|
||||
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem('access_token', 'expired-token');
|
||||
});
|
||||
|
||||
await page.reload();
|
||||
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const welcomeMessage = page.locator('text=/欢迎/i');
|
||||
await expect(welcomeMessage).toBeVisible({ timeout: 5000 });
|
||||
|
||||
const newToken = await page.evaluate(() => localStorage.getItem('access_token'));
|
||||
expect(newToken).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
# UAT 测试文档
|
||||
|
||||
## 概述
|
||||
|
||||
UAT (User Acceptance Testing) 测试是用户验收测试,用于验证系统是否满足业务需求和用户期望。UAT测试从最终用户的角度出发,模拟真实用户的使用场景。
|
||||
|
||||
## 测试框架
|
||||
|
||||
UAT测试基于 Playwright 框架,采用 BDD (Behavior-Driven Development) 风格,使用 Given-When-Then 结构描述测试步骤。
|
||||
|
||||
### 核心文件
|
||||
|
||||
- `uat-base.ts`: UAT测试基础框架,包含测试夹具、测试步骤和断言工具
|
||||
- `uat-001-auth.spec.ts`: 用户认证相关测试
|
||||
- `uat-002-user-management.spec.ts`: 用户管理功能测试
|
||||
- `uat-003-almanac.spec.ts`: 黄历查询功能测试
|
||||
- `uat-004-fortune.spec.ts`: 运势分析功能测试
|
||||
- `uat-005-ziwei.spec.ts`: 紫微斗数功能测试
|
||||
|
||||
## 运行UAT测试
|
||||
|
||||
### 前置条件
|
||||
|
||||
1. 确保后端服务已启动
|
||||
2. 确保前端开发服务器已启动
|
||||
3. 确保数据库已初始化测试数据
|
||||
|
||||
### 运行所有UAT测试
|
||||
|
||||
```bash
|
||||
npm run test:e2e
|
||||
```
|
||||
|
||||
### 运行特定UAT测试套件
|
||||
|
||||
```bash
|
||||
# 运行认证测试
|
||||
npx playwright test e2e/uat/uat-001-auth.spec.ts
|
||||
|
||||
# 运行用户管理测试
|
||||
npx playwright test e2e/uat/uat-002-user-management.spec.ts
|
||||
|
||||
# 运行黄历查询测试
|
||||
npx playwright test e2e/uat/uat-003-almanac.spec.ts
|
||||
|
||||
# 运行运势分析测试
|
||||
npx playwright test e2e/uat/uat-004-fortune.spec.ts
|
||||
|
||||
# 运行紫微斗数测试
|
||||
npx playwright test e2e/uat/uat-005-ziwei.spec.ts
|
||||
```
|
||||
|
||||
### 运行UAT测试(UI模式)
|
||||
|
||||
```bash
|
||||
npx playwright test e2e/uat --ui
|
||||
```
|
||||
|
||||
### 调试UAT测试
|
||||
|
||||
```bash
|
||||
npx playwright test e2e/uat --debug
|
||||
```
|
||||
|
||||
## 测试用例说明
|
||||
|
||||
### UAT-001: 用户注册和登录流程
|
||||
|
||||
| 用例ID | 用例名称 | 描述 |
|
||||
|---------|---------|------|
|
||||
| UAT-001-01 | 用户成功登录系统 | 验证用户使用正确的凭据登录系统 |
|
||||
| UAT-001-02 | 用户登录失败 - 错误密码 | 验证使用错误密码登录时显示错误消息 |
|
||||
| UAT-001-03 | 用户登出系统 | 验证用户成功登出系统 |
|
||||
|
||||
### UAT-002: 用户管理功能
|
||||
|
||||
| 用例ID | 用例名称 | 描述 |
|
||||
|---------|---------|------|
|
||||
| UAT-002-01 | 查看用户列表 | 验证用户可以查看用户列表 |
|
||||
| UAT-002-02 | 创建新用户 | 验证用户可以创建新用户 |
|
||||
| UAT-002-03 | 编辑用户信息 | 验证用户可以编辑用户信息 |
|
||||
| UAT-002-04 | 删除用户 | 验证用户可以删除用户 |
|
||||
| UAT-002-05 | 搜索用户 | 验证用户可以搜索用户 |
|
||||
|
||||
### UAT-003: 黄历查询功能
|
||||
|
||||
| 用例ID | 用例名称 | 描述 |
|
||||
|---------|---------|------|
|
||||
| UAT-003-01 | 查询单日黄历 | 验证用户可以查询指定日期的黄历 |
|
||||
| UAT-003-02 | 查看宜忌事项 | 验证黄历显示正确的宜忌事项 |
|
||||
| UAT-003-03 | 查看吉凶方位 | 验证黄历显示正确的吉凶方位 |
|
||||
| UAT-003-04 | 查看冲煞信息 | 验证黄历显示正确的冲煞信息 |
|
||||
| UAT-003-05 | 查看建除十二神 | 验证黄历显示正确的建除十二神 |
|
||||
|
||||
### UAT-004: 运势分析功能
|
||||
|
||||
| 用例ID | 用例名称 | 描述 |
|
||||
|---------|---------|------|
|
||||
| UAT-004-01 | 查看每日运势 | 验证用户可以查看每日运势 |
|
||||
| UAT-004-02 | 查看每月运势 | 验证用户可以查看每月运势 |
|
||||
| UAT-004-03 | 查看每年运势 | 验证用户可以查看每年运势 |
|
||||
| UAT-004-04 | 查看宫位运势 | 验证运势显示正确的宫位信息 |
|
||||
| UAT-004-05 | 查看幸运信息 | 验证运势显示正确的幸运色、数字、方位 |
|
||||
|
||||
### UAT-005: 紫微斗数功能
|
||||
|
||||
| 用例ID | 用例名称 | 描述 |
|
||||
|---------|---------|------|
|
||||
| UAT-005-01 | 生成紫微斗数命盘 | 验证用户可以生成紫微斗数命盘 |
|
||||
| UAT-005-02 | 查看十二宫位 | 验证命盘显示正确的十二宫位 |
|
||||
| UAT-005-03 | 查看主星排列 | 验证命盘显示正确的主星排列 |
|
||||
| UAT-005-04 | 查看四化飞星 | 验证命盘显示正确的四化飞星 |
|
||||
| UAT-005-05 | 查看命盘分析 | 验证命盘显示正确的分析结果 |
|
||||
| UAT-005-06 | 保存命盘 | 验证用户可以保存命盘 |
|
||||
|
||||
## 测试数据
|
||||
|
||||
UAT测试使用以下测试数据:
|
||||
|
||||
### 用户认证
|
||||
- 用户名: `admin`
|
||||
- 密码: `admin123`
|
||||
|
||||
### 测试用户创建
|
||||
- 用户名: `testuser_${timestamp}`
|
||||
- 邮箱: `testuser_${timestamp}@example.com`
|
||||
- 密码: `Test@123456`
|
||||
- 角色: `USER` 或 `ADMIN`
|
||||
- 状态: `ACTIVE`
|
||||
|
||||
### 黄历查询
|
||||
- 测试日期: `2024-01-01`
|
||||
|
||||
### 运势分析
|
||||
- 测试日期: `2024-01-15`
|
||||
|
||||
### 紫微斗数
|
||||
- 出生日期: `1990-05-15`
|
||||
- 出生时间: `08:30`
|
||||
- 性别: `MALE`
|
||||
|
||||
## 测试报告
|
||||
|
||||
UAT测试执行后会生成以下报告:
|
||||
|
||||
1. **HTML报告**: `playwright-report/index.html`
|
||||
2. **JSON报告**: `test-results/results.json`
|
||||
3. **截图**: 失败测试的截图保存在 `test-results/` 目录
|
||||
4. **视频**: 失败测试的视频保存在 `test-results/` 目录
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **测试独立性**: 每个测试用例应该独立运行,不依赖其他测试用例
|
||||
2. **测试清理**: 每个测试用例执行后应该清理测试数据
|
||||
3. **测试覆盖**: UAT测试应该覆盖所有关键业务流程
|
||||
4. **测试文档**: 每个测试用例应该有清晰的描述和预期结果
|
||||
5. **测试维护**: 定期更新UAT测试以反映业务需求的变化
|
||||
|
||||
## 故障排查
|
||||
|
||||
### 常见问题
|
||||
|
||||
1. **测试超时**
|
||||
- 检查网络连接
|
||||
- 检查后端服务是否正常运行
|
||||
- 增加测试超时时间
|
||||
|
||||
2. **元素定位失败**
|
||||
- 检查页面是否完全加载
|
||||
- 检查元素选择器是否正确
|
||||
- 使用 Playwright 的等待机制
|
||||
|
||||
3. **测试数据问题**
|
||||
- 检查数据库是否有正确的测试数据
|
||||
- 检查测试数据是否被其他测试修改
|
||||
- 使用唯一的测试数据标识符
|
||||
|
||||
## 联系方式
|
||||
|
||||
如有问题,请联系测试团队或查看项目文档。
|
||||
@@ -0,0 +1,63 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { test as uatTest } from './uat-base';
|
||||
import { LoginPage } from '../pages/login-page';
|
||||
import { DashboardPage } from '../pages/dashboard-page';
|
||||
import { UserManagementPage } from '../pages/user-management-page';
|
||||
import { testConfig } from '../core/test-config';
|
||||
|
||||
uatTest.describe('UAT-001: 用户注册和登录流程', () => {
|
||||
let loginPage: LoginPage;
|
||||
let dashboardPage: DashboardPage;
|
||||
|
||||
test.beforeEach(async ({ page, uatLogin, uatDashboard }) => {
|
||||
loginPage = uatLogin;
|
||||
dashboardPage = uatDashboard;
|
||||
});
|
||||
|
||||
uatTest('UAT-001-01: 用户成功登录系统', async ({ page }) => {
|
||||
await test.step('Given 用户打开登录页面', async () => {
|
||||
await page.goto(testConfig.getBaseURL());
|
||||
await expect(page).toHaveTitle(/登录/);
|
||||
});
|
||||
|
||||
await test.step('When 用户输入有效的用户名和密码', async () => {
|
||||
await loginPage.login('admin', 'admin123');
|
||||
});
|
||||
|
||||
await test.step('Then 用户应成功登录并跳转到仪表盘', async () => {
|
||||
await expect(page).toHaveURL(/.*dashboard/);
|
||||
await expect(page.locator('[data-testid="page-title"]')).toContainText('仪表盘');
|
||||
});
|
||||
});
|
||||
|
||||
uatTest('UAT-001-02: 用户登录失败 - 错误密码', async ({ page }) => {
|
||||
await test.step('Given 用户打开登录页面', async () => {
|
||||
await page.goto(testConfig.getBaseURL());
|
||||
});
|
||||
|
||||
await test.step('When 用户输入错误的密码', async () => {
|
||||
await loginPage.login('admin', 'wrongpassword');
|
||||
});
|
||||
|
||||
await test.step('Then 系统应显示错误消息', async () => {
|
||||
await expect(page.locator('.ant-message-error')).toBeVisible();
|
||||
await expect(page.locator('.ant-message-error')).toContainText('用户名或密码错误');
|
||||
});
|
||||
});
|
||||
|
||||
uatTest('UAT-001-03: 用户登出系统', async ({ page }) => {
|
||||
await test.step('Given 用户已登录系统', async () => {
|
||||
await page.goto(testConfig.getBaseURL());
|
||||
await loginPage.login('admin', 'admin123');
|
||||
await expect(page).toHaveURL(/.*dashboard/);
|
||||
});
|
||||
|
||||
await test.step('When 用户点击登出按钮', async () => {
|
||||
await page.click('[data-testid="logout-button"]');
|
||||
});
|
||||
|
||||
await test.step('Then 用户应被重定向到登录页面', async () => {
|
||||
await expect(page).toHaveURL(/.*login/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { test } from '@playwright/test';
|
||||
import { test as uatTest } from './uat-base';
|
||||
import { LoginPage } from '../pages/login-page';
|
||||
import { DashboardPage } from '../pages/dashboard-page';
|
||||
import { UserManagementPage } from '../pages/user-management-page';
|
||||
import { testConfig } from '../core/test-config';
|
||||
|
||||
uatTest.describe('UAT-002: 用户管理功能', () => {
|
||||
let loginPage: LoginPage;
|
||||
let dashboardPage: DashboardPage;
|
||||
let userManagementPage: UserManagementPage;
|
||||
|
||||
test.beforeEach(async ({ page, uatLogin, uatDashboard, uatUserManagement }) => {
|
||||
loginPage = uatLogin;
|
||||
dashboardPage = uatDashboard;
|
||||
userManagementPage = uatUserManagement;
|
||||
|
||||
await page.goto(testConfig.getBaseURL());
|
||||
await loginPage.login('admin', 'admin123');
|
||||
await expect(page).toHaveURL(/.*dashboard/);
|
||||
});
|
||||
|
||||
uatTest('UAT-002-01: 查看用户列表', async ({ page }) => {
|
||||
await test.step('Given 用户已登录系统', async () => {
|
||||
await expect(page).toHaveURL(/.*dashboard/);
|
||||
});
|
||||
|
||||
await test.step('When 用户导航到用户管理页面', async () => {
|
||||
await dashboardPage.navigateToUserManagement();
|
||||
});
|
||||
|
||||
await test.step('Then 用户应看到用户列表', async () => {
|
||||
await expect(page.locator('[data-testid="user-table"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="page-title"]')).toContainText('用户管理');
|
||||
});
|
||||
});
|
||||
|
||||
uatTest('UAT-002-02: 创建新用户', async ({ page }) => {
|
||||
const testUsername = `testuser_${Date.now()}`;
|
||||
const testEmail = `testuser_${Date.now()}@example.com`;
|
||||
|
||||
await test.step('Given 用户在用户管理页面', async () => {
|
||||
await dashboardPage.navigateToUserManagement();
|
||||
await expect(page.locator('[data-testid="user-table"]')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('When 用户点击新增用户按钮并填写信息', async () => {
|
||||
await userManagementPage.clickAddUser();
|
||||
await userManagementPage.fillUserForm({
|
||||
username: testUsername,
|
||||
email: testEmail,
|
||||
password: 'Test@123456',
|
||||
confirmPassword: 'Test@123456',
|
||||
role: 'USER',
|
||||
status: 'ACTIVE'
|
||||
});
|
||||
await userManagementPage.submitUserForm();
|
||||
});
|
||||
|
||||
await test.step('Then 新用户应创建成功', async () => {
|
||||
await expect(page.locator('.ant-message-success')).toBeVisible();
|
||||
await expect(page.locator('.ant-message-success')).toContainText('用户创建成功');
|
||||
});
|
||||
});
|
||||
|
||||
uatTest('UAT-002-03: 编辑用户信息', async ({ page }) => {
|
||||
await test.step('Given 用户在用户管理页面', async () => {
|
||||
await dashboardPage.navigateToUserManagement();
|
||||
await expect(page.locator('[data-testid="user-table"]')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('When 用户点击编辑按钮并修改信息', async () => {
|
||||
await page.click('button:has-text("编辑")');
|
||||
await page.fill('[data-testid="email-input"]', 'updated@example.com');
|
||||
await page.click('[data-testid="submit-button"]');
|
||||
});
|
||||
|
||||
await test.step('Then 用户信息应更新成功', async () => {
|
||||
await expect(page.locator('.ant-message-success')).toBeVisible();
|
||||
await expect(page.locator('.ant-message-success')).toContainText('用户更新成功');
|
||||
});
|
||||
});
|
||||
|
||||
uatTest('UAT-002-04: 删除用户', async ({ page }) => {
|
||||
await test.step('Given 用户在用户管理页面', async () => {
|
||||
await dashboardPage.navigateToUserManagement();
|
||||
await expect(page.locator('[data-testid="user-table"]')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('When 用户点击删除按钮并确认', async () => {
|
||||
await page.click('button:has-text("删除")');
|
||||
await page.click('.ant-modal-confirm-btn');
|
||||
});
|
||||
|
||||
await test.step('Then 用户应删除成功', async () => {
|
||||
await expect(page.locator('.ant-message-success')).toBeVisible();
|
||||
await expect(page.locator('.ant-message-success')).toContainText('用户删除成功');
|
||||
});
|
||||
});
|
||||
|
||||
uatTest('UAT-002-05: 搜索用户', async ({ page }) => {
|
||||
await test.step('Given 用户在用户管理页面', async () => {
|
||||
await dashboardPage.navigateToUserManagement();
|
||||
await expect(page.locator('[data-testid="user-table"]')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('When 用户输入用户名进行搜索', async () => {
|
||||
await page.fill('[data-testid="username-search-input"]', 'admin');
|
||||
await page.click('[data-testid="search-button"]');
|
||||
});
|
||||
|
||||
await test.step('Then 系统应显示匹配的用户', async () => {
|
||||
await expect(page.locator('[data-testid="user-table"]')).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import { test } from '@playwright/test';
|
||||
import { test as uatTest } from './uat-base';
|
||||
import { LoginPage } from '../pages/login-page';
|
||||
import { DashboardPage } from '../pages/dashboard-page';
|
||||
import { testConfig } from '../core/test-config';
|
||||
|
||||
uatTest.describe('UAT-003: 黄历查询功能', () => {
|
||||
let loginPage: LoginPage;
|
||||
let dashboardPage: DashboardPage;
|
||||
|
||||
test.beforeEach(async ({ page, uatLogin, uatDashboard }) => {
|
||||
loginPage = uatLogin;
|
||||
dashboardPage = uatDashboard;
|
||||
|
||||
await page.goto(testConfig.getBaseURL());
|
||||
await loginPage.login('admin', 'admin123');
|
||||
await expect(page).toHaveURL(/.*dashboard/);
|
||||
});
|
||||
|
||||
uatTest('UAT-003-01: 查询单日黄历', async ({ page }) => {
|
||||
const testDate = '2024-01-01';
|
||||
|
||||
await test.step('Given 用户已登录系统', async () => {
|
||||
await expect(page).toHaveURL(/.*dashboard/);
|
||||
});
|
||||
|
||||
await test.step('When 用户导航到黄历页面并选择日期', async () => {
|
||||
await page.goto(`${testConfig.getBaseURL()}/almanac`);
|
||||
await page.fill('[data-testid="date-picker"]', testDate);
|
||||
await page.click('[data-testid="query-button"]');
|
||||
});
|
||||
|
||||
await test.step('Then 系统应显示黄历信息', async () => {
|
||||
await expect(page.locator('[data-testid="almanac-result"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="suitable-activities"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="unsuitable-activities"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="god-direction"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="fortune-direction"]')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
uatTest('UAT-003-02: 查看宜忌事项', async ({ page }) => {
|
||||
await test.step('Given 用户已查询黄历', async () => {
|
||||
await page.goto(`${testConfig.getBaseURL()}/almanac`);
|
||||
await page.fill('[data-testid="date-picker"]', '2024-01-01');
|
||||
await page.click('[data-testid="query-button"]');
|
||||
await expect(page.locator('[data-testid="almanac-result"]')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('When 用户查看宜忌事项', async () => {
|
||||
await expect(page.locator('[data-testid="suitable-activities"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="unsuitable-activities"]')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Then 宜忌事项应正确显示', async () => {
|
||||
const suitableText = await page.locator('[data-testid="suitable-activities"]').textContent();
|
||||
const unsuitableText = await page.locator('[data-testid="unsuitable-activities"]').textContent();
|
||||
|
||||
expect(suitableText).toBeTruthy();
|
||||
expect(suitableText!.length).toBeGreaterThan(0);
|
||||
expect(unsuitableText).toBeTruthy();
|
||||
expect(unsuitableText!.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
uatTest('UAT-003-03: 查看吉凶方位', async ({ page }) => {
|
||||
await test.step('Given 用户已查询黄历', async () => {
|
||||
await page.goto(`${testConfig.getBaseURL()}/almanac`);
|
||||
await page.fill('[data-testid="date-picker"]', '2024-01-01');
|
||||
await page.click('[data-testid="query-button"]');
|
||||
await expect(page.locator('[data-testid="almanac-result"]')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('When 用户查看吉凶方位', async () => {
|
||||
await expect(page.locator('[data-testid="god-direction"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="joy-direction"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="fortune-direction"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="noble-direction"]')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Then 方位信息应正确显示', async () => {
|
||||
const godDirection = await page.locator('[data-testid="god-direction"]').textContent();
|
||||
const fortuneDirection = await page.locator('[data-testid="fortune-direction"]').textContent();
|
||||
|
||||
expect(godDirection).toBeTruthy();
|
||||
expect(godDirection!.length).toBeGreaterThan(0);
|
||||
expect(fortuneDirection).toBeTruthy();
|
||||
expect(fortuneDirection!.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
uatTest('UAT-003-04: 查看冲煞信息', async ({ page }) => {
|
||||
await test.step('Given 用户已查询黄历', async () => {
|
||||
await page.goto(`${testConfig.getBaseURL()}/almanac`);
|
||||
await page.fill('[data-testid="date-picker"]', '2024-01-01');
|
||||
await page.click('[data-testid="query-button"]');
|
||||
await expect(page.locator('[data-testid="almanac-result"]')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('When 用户查看冲煞信息', async () => {
|
||||
await expect(page.locator('[data-testid="clash-info"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="evil-direction"]')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Then 冲煞信息应正确显示', async () => {
|
||||
const clashInfo = await page.locator('[data-testid="clash-info"]').textContent();
|
||||
const evilDirection = await page.locator('[data-testid="evil-direction"]').textContent();
|
||||
|
||||
expect(clashInfo).toBeTruthy();
|
||||
expect(clashInfo!.length).toBeGreaterThan(0);
|
||||
expect(evilDirection).toBeTruthy();
|
||||
expect(evilDirection!.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
uatTest('UAT-003-05: 查看建除十二神', async ({ page }) => {
|
||||
await test.step('Given 用户已查询黄历', async () => {
|
||||
await page.goto(`${testConfig.getBaseURL()}/almanac`);
|
||||
await page.fill('[data-testid="date-picker"]', '2024-01-01');
|
||||
await page.click('[data-testid="query-button"]');
|
||||
await expect(page.locator('[data-testid="almanac-result"]')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('When 用户查看建除十二神', async () => {
|
||||
await expect(page.locator('[data-testid="jian-chu"]')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Then 建除十二神应正确显示', async () => {
|
||||
const jianChu = await page.locator('[data-testid="jian-chu"]').textContent();
|
||||
|
||||
expect(jianChu).toBeTruthy();
|
||||
expect(jianChu!.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { test } from '@playwright/test';
|
||||
import { test as uatTest } from './uat-base';
|
||||
import { LoginPage } from '../pages/login-page';
|
||||
import { DashboardPage } from '../pages/dashboard-page';
|
||||
import { testConfig } from '../core/test-config';
|
||||
|
||||
uatTest.describe('UAT-004: 运势分析功能', () => {
|
||||
let loginPage: LoginPage;
|
||||
let dashboardPage: DashboardPage;
|
||||
|
||||
test.beforeEach(async ({ page, uatLogin, uatDashboard }) => {
|
||||
loginPage = uatLogin;
|
||||
dashboardPage = uatDashboard;
|
||||
|
||||
await page.goto(testConfig.getBaseURL());
|
||||
await loginPage.login('admin', 'admin123');
|
||||
await expect(page).toHaveURL(/.*dashboard/);
|
||||
});
|
||||
|
||||
uatTest('UAT-004-01: 查看每日运势', async ({ page }) => {
|
||||
const testDate = '2024-01-15';
|
||||
|
||||
await test.step('Given 用户已登录系统', async () => {
|
||||
await expect(page).toHaveURL(/.*dashboard/);
|
||||
});
|
||||
|
||||
await test.step('When 用户导航到运势页面并选择日期', async () => {
|
||||
await page.goto(`${testConfig.getBaseURL()}/fortune`);
|
||||
await page.fill('[data-testid="fortune-date"]', testDate);
|
||||
await page.click('[data-testid="query-fortune-button"]');
|
||||
});
|
||||
|
||||
await test.step('Then 系统应显示每日运势', async () => {
|
||||
await expect(page.locator('[data-testid="daily-fortune"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="overall-luck"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="career-advice"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="wealth-advice"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="relationship-advice"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="health-advice"]')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
uatTest('UAT-004-02: 查看每月运势', async ({ page }) => {
|
||||
await test.step('Given 用户已登录系统', async () => {
|
||||
await expect(page).toHaveURL(/.*dashboard/);
|
||||
});
|
||||
|
||||
await test.step('When 用户导航到运势页面并切换到每月运势', async () => {
|
||||
await page.goto(`${testConfig.getBaseURL()}/fortune`);
|
||||
await page.click('[data-testid="monthly-fortune-tab"]');
|
||||
});
|
||||
|
||||
await test.step('Then 系统应显示每月运势', async () => {
|
||||
await expect(page.locator('[data-testid="monthly-fortune"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="monthly-overall-luck"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="monthly-key-focus"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="monthly-caution-advice"]')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
uatTest('UAT-004-03: 查看每年运势', async ({ page }) => {
|
||||
await test.step('Given 用户已登录系统', async () => {
|
||||
await expect(page).toHaveURL(/.*dashboard/);
|
||||
});
|
||||
|
||||
await test.step('When 用户导航到运势页面并切换到每年运势', async () => {
|
||||
await page.goto(`${testConfig.getBaseURL()}/fortune`);
|
||||
await page.click('[data-testid="yearly-fortune-tab"]');
|
||||
});
|
||||
|
||||
await test.step('Then 系统应显示每年运势', async () => {
|
||||
await expect(page.locator('[data-testid="yearly-fortune"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="yearly-overall-luck"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="yearly-theme"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="yearly-major-opportunity"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="yearly-major-challenge"]')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
uatTest('UAT-004-04: 查看宫位运势', async ({ page }) => {
|
||||
await test.step('Given 用户已查看每日运势', async () => {
|
||||
await page.goto(`${testConfig.getBaseURL()}/fortune`);
|
||||
await page.fill('[data-testid="fortune-date"]', '2024-01-15');
|
||||
await page.click('[data-testid="query-fortune-button"]');
|
||||
await expect(page.locator('[data-testid="daily-fortune"]')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('When 用户查看宫位运势', async () => {
|
||||
await expect(page.locator('[data-testid="palace-fortunes"]')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Then 宫位运势应正确显示', async () => {
|
||||
const palaceCount = await page.locator('[data-testid^="palace-"]').count();
|
||||
expect(palaceCount).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
uatTest('UAT-004-05: 查看幸运信息', async ({ page }) => {
|
||||
await test.step('Given 用户已查看每日运势', async () => {
|
||||
await page.goto(`${testConfig.getBaseURL()}/fortune`);
|
||||
await page.fill('[data-testid="fortune-date"]', '2024-01-15');
|
||||
await page.click('[data-testid="query-fortune-button"]');
|
||||
await expect(page.locator('[data-testid="daily-fortune"]')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('When 用户查看幸运信息', async () => {
|
||||
await expect(page.locator('[data-testid="lucky-color"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="lucky-number"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="lucky-direction"]')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Then 幸运信息应正确显示', async () => {
|
||||
const luckyColor = await page.locator('[data-testid="lucky-color"]').textContent();
|
||||
const luckyNumber = await page.locator('[data-testid="lucky-number"]').textContent();
|
||||
const luckyDirection = await page.locator('[data-testid="lucky-direction"]').textContent();
|
||||
|
||||
expect(luckyColor).toBeTruthy();
|
||||
expect(luckyColor!.length).toBeGreaterThan(0);
|
||||
expect(luckyNumber).toBeTruthy();
|
||||
expect(luckyNumber!.length).toBeGreaterThan(0);
|
||||
expect(luckyDirection).toBeTruthy();
|
||||
expect(luckyDirection!.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import { test } from '@playwright/test';
|
||||
import { test as uatTest } from './uat-base';
|
||||
import { LoginPage } from '../pages/login-page';
|
||||
import { DashboardPage } from '../pages/dashboard-page';
|
||||
import { testConfig } from '../core/test-config';
|
||||
|
||||
uatTest.describe('UAT-005: 紫微斗数功能', () => {
|
||||
let loginPage: LoginPage;
|
||||
let dashboardPage: DashboardPage;
|
||||
|
||||
test.beforeEach(async ({ page, uatLogin, uatDashboard }) => {
|
||||
loginPage = uatLogin;
|
||||
dashboardPage = uatDashboard;
|
||||
|
||||
await page.goto(testConfig.getBaseURL());
|
||||
await loginPage.login('admin', 'admin123');
|
||||
await expect(page).toHaveURL(/.*dashboard/);
|
||||
});
|
||||
|
||||
uatTest('UAT-005-01: 生成紫微斗数命盘', async ({ page }) => {
|
||||
const birthDate = '1990-05-15';
|
||||
const birthTime = '08:30';
|
||||
|
||||
await test.step('Given 用户已登录系统', async () => {
|
||||
await expect(page).toHaveURL(/.*dashboard/);
|
||||
});
|
||||
|
||||
await test.step('When 用户导航到紫微斗数页面并输入出生信息', async () => {
|
||||
await page.goto(`${testConfig.getBaseURL()}/ziwei`);
|
||||
await page.fill('[data-testid="birth-date"]', birthDate);
|
||||
await page.fill('[data-testid="birth-time"]', birthTime);
|
||||
await page.click('[data-testid="gender-male"]');
|
||||
await page.click('[data-testid="generate-chart-button"]');
|
||||
});
|
||||
|
||||
await test.step('Then 系统应生成紫微斗数命盘', async () => {
|
||||
await expect(page.locator('[data-testid="ziwei-chart"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="palace-grid"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="ming-gong"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="shen-gong"]')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
uatTest('UAT-005-02: 查看十二宫位', async ({ page }) => {
|
||||
await test.step('Given 用户已生成紫微斗数命盘', async () => {
|
||||
await page.goto(`${testConfig.getBaseURL()}/ziwei`);
|
||||
await page.fill('[data-testid="birth-date"]', '1990-05-15');
|
||||
await page.fill('[data-testid="birth-time"]', '08:30');
|
||||
await page.click('[data-testid="gender-male"]');
|
||||
await page.click('[data-testid="generate-chart-button"]');
|
||||
await expect(page.locator('[data-testid="ziwei-chart"]')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('When 用户查看十二宫位', async () => {
|
||||
await expect(page.locator('[data-testid="palace-grid"]')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Then 十二宫位应正确显示', async () => {
|
||||
const palaceCount = await page.locator('[data-testid^="palace-"]').count();
|
||||
expect(palaceCount).toBe(12);
|
||||
});
|
||||
});
|
||||
|
||||
uatTest('UAT-005-03: 查看主星排列', async ({ page }) => {
|
||||
await test.step('Given 用户已生成紫微斗数命盘', async () => {
|
||||
await page.goto(`${testConfig.getBaseURL()}/ziwei`);
|
||||
await page.fill('[data-testid="birth-date"]', '1990-05-15');
|
||||
await page.fill('[data-testid="birth-time"]', '08:30');
|
||||
await page.click('[data-testid="gender-male"]');
|
||||
await page.click('[data-testid="generate-chart-button"]');
|
||||
await expect(page.locator('[data-testid="ziwei-chart"]')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('When 用户查看主星排列', async () => {
|
||||
await expect(page.locator('[data-testid="major-stars"]')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Then 主星应正确显示', async () => {
|
||||
const majorStars = await page.locator('[data-testid^="major-star-"]').count();
|
||||
expect(majorStars).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
uatTest('UAT-005-04: 查看四化飞星', async ({ page }) => {
|
||||
await test.step('Given 用户已生成紫微斗数命盘', async () => {
|
||||
await page.goto(`${testConfig.getBaseURL()}/ziwei`);
|
||||
await page.fill('[data-testid="birth-date"]', '1990-05-15');
|
||||
await page.fill('[data-testid="birth-time"]', '08:30');
|
||||
await page.click('[data-testid="gender-male"]');
|
||||
await page.click('[data-testid="generate-chart-button"]');
|
||||
await expect(page.locator('[data-testid="ziwei-chart"]')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('When 用户查看四化飞星', async () => {
|
||||
await expect(page.locator('[data-testid="transformations"]')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Then 四化飞星应正确显示', async () => {
|
||||
await expect(page.locator('[data-testid="hua-lu"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="hua-quan"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="hua-ke"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="hua-ji"]')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
uatTest('UAT-005-05: 查看命盘分析', async ({ page }) => {
|
||||
await test.step('Given 用户已生成紫微斗数命盘', async () => {
|
||||
await page.goto(`${testConfig.getBaseURL()}/ziwei`);
|
||||
await page.fill('[data-testid="birth-date"]', '1990-05-15');
|
||||
await page.fill('[data-testid="birth-time"]', '08:30');
|
||||
await page.click('[data-testid="gender-male"]');
|
||||
await page.click('[data-testid="generate-chart-button"]');
|
||||
await expect(page.locator('[data-testid="ziwei-chart"]')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('When 用户查看命盘分析', async () => {
|
||||
await expect(page.locator('[data-testid="chart-analysis"]')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Then 命盘分析应正确显示', async () => {
|
||||
const analysisText = await page.locator('[data-testid="chart-analysis"]').textContent();
|
||||
expect(analysisText).toBeTruthy();
|
||||
expect(analysisText!.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
uatTest('UAT-005-06: 保存命盘', async ({ page }) => {
|
||||
await test.step('Given 用户已生成紫微斗数命盘', async () => {
|
||||
await page.goto(`${testConfig.getBaseURL()}/ziwei`);
|
||||
await page.fill('[data-testid="birth-date"]', '1990-05-15');
|
||||
await page.fill('[data-testid="birth-time"]', '08:30');
|
||||
await page.click('[data-testid="gender-male"]');
|
||||
await page.click('[data-testid="generate-chart-button"]');
|
||||
await expect(page.locator('[data-testid="ziwei-chart"]')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('When 用户点击保存命盘按钮', async () => {
|
||||
await page.click('[data-testid="save-chart-button"]');
|
||||
});
|
||||
|
||||
await test.step('Then 命盘应保存成功', async () => {
|
||||
await expect(page.locator('.ant-message-success')).toBeVisible();
|
||||
await expect(page.locator('.ant-message-success')).toContainText('命盘保存成功');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
import { test as base, expect, Page } from '@playwright/test';
|
||||
import { LoginPage } from '../pages/login-page';
|
||||
import { DashboardPage } from '../pages/dashboard-page';
|
||||
import { UserManagementPage } from '../pages/user-management-page';
|
||||
import { testConfig } from '../core/test-config';
|
||||
|
||||
export type UATFixtures = {
|
||||
uatLogin: LoginPage;
|
||||
uatDashboard: DashboardPage;
|
||||
uatUserManagement: UserManagementPage;
|
||||
};
|
||||
|
||||
export const test = base.extend<UATFixtures>({
|
||||
uatLogin: async ({ page }, use) => {
|
||||
const loginPage = new LoginPage(page);
|
||||
await use(loginPage);
|
||||
},
|
||||
uatDashboard: async ({ page }, use) => {
|
||||
const dashboardPage = new DashboardPage(page);
|
||||
await use(dashboardPage);
|
||||
},
|
||||
uatUserManagement: async ({ page }, use) => {
|
||||
const userManagementPage = new UserManagementPage(page);
|
||||
await use(userManagementPage);
|
||||
}
|
||||
});
|
||||
|
||||
test.describe.configure({
|
||||
mode: 'serial',
|
||||
timeout: 60000
|
||||
});
|
||||
|
||||
export const UATTestSteps = {
|
||||
async completeUserRegistrationFlow(page: Page, username: string, password: string) {
|
||||
const loginPage = new LoginPage(page);
|
||||
|
||||
await test.step('用户打开登录页面', async () => {
|
||||
await page.goto(testConfig.getBaseURL());
|
||||
await expect(page).toHaveTitle(/登录/);
|
||||
});
|
||||
|
||||
await test.step('用户输入用户名和密码', async () => {
|
||||
await loginPage.login(username, password);
|
||||
});
|
||||
|
||||
await test.step('验证用户成功登录并跳转到仪表盘', async () => {
|
||||
await expect(page).toHaveURL(/.*dashboard/);
|
||||
});
|
||||
},
|
||||
|
||||
async completeUserManagementFlow(page: Page, username: string, email: string, role: string) {
|
||||
const dashboardPage = new DashboardPage(page);
|
||||
const userManagementPage = new UserManagementPage(page);
|
||||
|
||||
await test.step('用户导航到用户管理页面', async () => {
|
||||
await dashboardPage.navigateToUserManagement();
|
||||
await expect(page).toHaveURL(/.*users/);
|
||||
});
|
||||
|
||||
await test.step('用户点击新增用户按钮', async () => {
|
||||
await userManagementPage.clickAddUser();
|
||||
});
|
||||
|
||||
await test.step('用户填写用户信息', async () => {
|
||||
await userManagementPage.fillUserForm({
|
||||
username,
|
||||
email,
|
||||
password: 'Test@123456',
|
||||
confirmPassword: 'Test@123456',
|
||||
role,
|
||||
status: 'ACTIVE'
|
||||
});
|
||||
});
|
||||
|
||||
await test.step('用户提交表单', async () => {
|
||||
await userManagementPage.submitUserForm();
|
||||
});
|
||||
|
||||
await test.step('验证用户创建成功', async () => {
|
||||
await expect(page.locator('.ant-message-success')).toBeVisible();
|
||||
});
|
||||
},
|
||||
|
||||
async completeAlmanacQueryFlow(page: Page, date: string) {
|
||||
await test.step('用户打开黄历查询页面', async () => {
|
||||
await page.goto(`${testConfig.getBaseURL()}/almanac`);
|
||||
});
|
||||
|
||||
await test.step('用户选择日期', async () => {
|
||||
await page.fill('[data-testid="date-picker"]', date);
|
||||
});
|
||||
|
||||
await test.step('用户点击查询按钮', async () => {
|
||||
await page.click('[data-testid="query-button"]');
|
||||
});
|
||||
|
||||
await test.step('验证黄历信息显示', async () => {
|
||||
await expect(page.locator('[data-testid="almanac-result"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="suitable-activities"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="unsuitable-activities"]')).toBeVisible();
|
||||
});
|
||||
},
|
||||
|
||||
async completeFortuneAnalysisFlow(page: Page) {
|
||||
await test.step('用户打开运势分析页面', async () => {
|
||||
await page.goto(`${testConfig.getBaseURL()}/fortune`);
|
||||
});
|
||||
|
||||
await test.step('用户查看每日运势', async () => {
|
||||
await expect(page.locator('[data-testid="daily-fortune"]')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('用户查看每月运势', async () => {
|
||||
await page.click('[data-testid="monthly-fortune-tab"]');
|
||||
await expect(page.locator('[data-testid="monthly-fortune"]')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('用户查看每年运势', async () => {
|
||||
await page.click('[data-testid="yearly-fortune-tab"]');
|
||||
await expect(page.locator('[data-testid="yearly-fortune"]')).toBeVisible();
|
||||
});
|
||||
},
|
||||
|
||||
async completeZiweiChartGenerationFlow(page: Page, birthDate: string, birthTime: string) {
|
||||
await test.step('用户打开紫微斗数页面', async () => {
|
||||
await page.goto(`${testConfig.getBaseURL()}/ziwei`);
|
||||
});
|
||||
|
||||
await test.step('用户输入出生日期和时间', async () => {
|
||||
await page.fill('[data-testid="birth-date"]', birthDate);
|
||||
await page.fill('[data-testid="birth-time"]', birthTime);
|
||||
});
|
||||
|
||||
await test.step('用户选择性别', async () => {
|
||||
await page.click('[data-testid="gender-male"]');
|
||||
});
|
||||
|
||||
await test.step('用户点击生成命盘按钮', async () => {
|
||||
await page.click('[data-testid="generate-chart-button"]');
|
||||
});
|
||||
|
||||
await test.step('验证命盘生成成功', async () => {
|
||||
await expect(page.locator('[data-testid="ziwei-chart"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="palace-grid"]')).toBeVisible();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const UATAssertions = {
|
||||
assertPageTitle(page: Page, expectedTitle: string) {
|
||||
return expect(page).toHaveTitle(new RegExp(expectedTitle));
|
||||
},
|
||||
|
||||
assertElementVisible(page: Page, selector: string) {
|
||||
return expect(page.locator(selector)).toBeVisible();
|
||||
},
|
||||
|
||||
assertElementText(page: Page, selector: string, expectedText: string) {
|
||||
return expect(page.locator(selector)).toHaveText(expectedText);
|
||||
},
|
||||
|
||||
assertSuccessMessage(page: Page, message: string) {
|
||||
return expect(page.locator('.ant-message-success')).toContainText(message);
|
||||
},
|
||||
|
||||
assertErrorMessage(page: Page, message: string) {
|
||||
return expect(page.locator('.ant-message-error')).toContainText(message);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import { test, expect } from './test-fixtures';
|
||||
|
||||
test.describe('用户管理 - 完全Mock模式', () => {
|
||||
test.beforeEach(async ({ page, mockManager }) => {
|
||||
mockManager.enableMock();
|
||||
mockManager.configureMock({
|
||||
mode: 'full',
|
||||
delay: 100
|
||||
});
|
||||
|
||||
mockManager.presetTestData({
|
||||
users: [
|
||||
{ id: 1, username: 'testuser1', email: 'test1@example.com', status: 1, createTime: '2024-01-01 10:00:00' },
|
||||
{ id: 2, username: 'testuser2', email: 'test2@example.com', status: 1, createTime: '2024-01-02 10:00:00' },
|
||||
{ id: 3, username: 'testuser3', email: 'test3@example.com', status: 0, createTime: '2024-01-03 10:00:00' }
|
||||
]
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
await page.fill('input[placeholder="请输入用户名"]', 'admin');
|
||||
await page.fill('input[placeholder="请输入密码"]', 'admin123');
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForURL(/.*dashboard/, { timeout: 10000 });
|
||||
});
|
||||
|
||||
test.afterEach(async ({ mockManager }) => {
|
||||
mockManager.clearPresets();
|
||||
mockManager.disableMock();
|
||||
});
|
||||
|
||||
test('应该显示用户列表', async ({ page }) => {
|
||||
await page.goto('/users');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await expect(page.locator('.ant-table')).toBeVisible();
|
||||
});
|
||||
|
||||
test('应该能够搜索用户', async ({ page }) => {
|
||||
await page.goto('/users');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const searchInput = page.locator('input[placeholder*="搜索"]').first();
|
||||
await searchInput.fill('testuser1');
|
||||
|
||||
const searchButton = page.locator('button').filter({ hasText: /搜索|查询/ }).first();
|
||||
await searchButton.click();
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
});
|
||||
|
||||
test('应该能够打开新增用户对话框', async ({ page }) => {
|
||||
await page.goto('/users');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const addButton = page.locator('button').filter({ hasText: /新增|添加/ }).first();
|
||||
await addButton.click();
|
||||
|
||||
await expect(page.locator('.ant-modal')).toBeVisible();
|
||||
await expect(page.locator('.ant-modal').getByText('新增用户')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,257 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { MockManager } from './mock-manager';
|
||||
|
||||
test.describe('用户管理', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
const mockManager = new MockManager({
|
||||
enabled: true,
|
||||
mode: 'full',
|
||||
mockPaths: [],
|
||||
delay: 0,
|
||||
logCalls: true,
|
||||
validateResponses: true,
|
||||
dataSource: 'memory'
|
||||
});
|
||||
|
||||
mockManager.presetTestData({
|
||||
menus: [
|
||||
{
|
||||
id: 1,
|
||||
name: '仪表盘',
|
||||
code: 'dashboard',
|
||||
path: '/dashboard',
|
||||
icon: 'DashboardOutlined',
|
||||
sortOrder: 1,
|
||||
status: 'active',
|
||||
parentId: 0,
|
||||
component: 'views/Dashboard.vue',
|
||||
createBy: 'system',
|
||||
updateBy: 'system',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
children: []
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: '用户管理',
|
||||
code: 'user',
|
||||
path: '/users',
|
||||
icon: 'UserOutlined',
|
||||
sortOrder: 2,
|
||||
status: 'active',
|
||||
parentId: 0,
|
||||
component: 'views/UserManagement.vue',
|
||||
createBy: 'system',
|
||||
updateBy: 'system',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
children: []
|
||||
}
|
||||
],
|
||||
users: [
|
||||
{
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
realName: '管理员',
|
||||
email: 'admin@example.com',
|
||||
phone: '13800138000',
|
||||
gender: 'male',
|
||||
status: 'active',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
username: 'user1',
|
||||
realName: '用户1',
|
||||
email: 'user1@example.com',
|
||||
phone: '13800138001',
|
||||
gender: 'female',
|
||||
status: 'active',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z'
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
await mockManager.interceptAPIRequest(page);
|
||||
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const usernameInput = page.locator('input[placeholder="请输入用户名"]');
|
||||
const passwordInput = page.locator('input[placeholder="请输入密码"]');
|
||||
const loginButton = page.locator('button[type="submit"]');
|
||||
|
||||
await usernameInput.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await passwordInput.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await loginButton.waitFor({ state: 'visible', timeout: 10000 });
|
||||
|
||||
await usernameInput.fill('admin');
|
||||
await passwordInput.fill('admin123');
|
||||
await loginButton.click();
|
||||
|
||||
await page.waitForURL(/.*dashboard/, { timeout: 10000 });
|
||||
});
|
||||
|
||||
test('应该显示用户列表页面', async ({ page }) => {
|
||||
await page.goto('/users');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await expect(page.locator('[data-testid="page-title"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="add-user-button"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="search-button"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('应该显示用户数据表格', async ({ page }) => {
|
||||
await page.goto('/users');
|
||||
|
||||
const table = page.locator('.ant-table');
|
||||
await expect(table).toBeVisible();
|
||||
|
||||
await expect(page.getByText(/用户名/i)).toBeVisible();
|
||||
await expect(page.getByText(/邮箱/i)).toBeVisible();
|
||||
await expect(page.getByText(/手机号/i)).toBeVisible();
|
||||
await expect(page.getByText(/状态/i)).toBeVisible();
|
||||
await expect(page.getByText(/创建时间/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('应该能够创建新用户', async ({ page }) => {
|
||||
await page.goto('/users');
|
||||
|
||||
await page.getByRole('button', { name: /添加用户/i }).click();
|
||||
await expect(page).toHaveURL(/.*users\/create/);
|
||||
|
||||
await page.getByPlaceholder(/请输入用户名/i).fill('testuser');
|
||||
await page.getByPlaceholder(/请输入密码/i).fill('Test@123456');
|
||||
await page.getByPlaceholder(/请输入确认密码/i).fill('Test@123456');
|
||||
await page.getByPlaceholder(/请输入邮箱/i).fill('test@example.com');
|
||||
await page.getByPlaceholder(/请输入手机号/i).fill('13800138000');
|
||||
await page.getByPlaceholder(/请输入真实姓名/i).fill('测试用户');
|
||||
|
||||
await page.getByRole('button', { name: /提交/i }).click();
|
||||
|
||||
await expect(page).toHaveURL(/.*users/);
|
||||
await expect(page.getByText(/创建成功/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('创建用户时应该验证必填字段', async ({ page }) => {
|
||||
await page.goto('/users');
|
||||
await page.getByRole('button', { name: /添加用户/i }).click();
|
||||
|
||||
await page.getByRole('button', { name: /提交/i }).click();
|
||||
|
||||
await expect(page.getByText(/请输入用户名/i)).toBeVisible();
|
||||
await expect(page.getByText(/请输入密码/i)).toBeVisible();
|
||||
await expect(page.getByText(/请输入确认密码/i)).toBeVisible();
|
||||
await expect(page.getByText(/请输入邮箱/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('应该验证密码一致性', async ({ page }) => {
|
||||
await page.goto('/users');
|
||||
await page.getByRole('button', { name: /添加用户/i }).click();
|
||||
|
||||
await page.getByPlaceholder(/请输入用户名/i).fill('testuser');
|
||||
await page.getByPlaceholder(/请输入密码/i).fill('Test@123456');
|
||||
await page.getByPlaceholder(/请输入确认密码/i).fill('Different@123456');
|
||||
await page.getByPlaceholder(/请输入邮箱/i).fill('test@example.com');
|
||||
|
||||
await page.getByRole('button', { name: /提交/i }).click();
|
||||
|
||||
await expect(page.getByText(/两次输入的密码不一致/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('应该验证邮箱格式', async ({ page }) => {
|
||||
await page.goto('/users');
|
||||
await page.getByRole('button', { name: /添加用户/i }).click();
|
||||
|
||||
await page.getByPlaceholder(/请输入用户名/i).fill('testuser');
|
||||
await page.getByPlaceholder(/请输入密码/i).fill('Test@123456');
|
||||
await page.getByPlaceholder(/请输入确认密码/i).fill('Test@123456');
|
||||
await page.getByPlaceholder(/请输入邮箱/i).fill('invalid-email');
|
||||
|
||||
await page.getByRole('button', { name: /提交/i }).click();
|
||||
|
||||
await expect(page.getByText(/请输入正确的邮箱格式/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('应该能够编辑用户', async ({ page }) => {
|
||||
await page.goto('/users');
|
||||
|
||||
const editButton = page.getByRole('button').filter({ hasText: /编辑/i }).first();
|
||||
if (await editButton.isVisible()) {
|
||||
await editButton.click();
|
||||
await expect(page).toHaveURL(/.*users\/\d+\/edit/);
|
||||
|
||||
const realNameInput = page.getByPlaceholder(/请输入真实姓名/i);
|
||||
await realNameInput.clear();
|
||||
await realNameInput.fill('更新后的用户名');
|
||||
|
||||
await page.getByRole('button', { name: /提交/i }).click();
|
||||
|
||||
await expect(page).toHaveURL(/.*users/);
|
||||
await expect(page.getByText(/更新成功/i)).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('应该能够删除用户', async ({ page }) => {
|
||||
await page.goto('/users');
|
||||
|
||||
const deleteButton = page.getByRole('button').filter({ hasText: /删除/i }).first();
|
||||
if (await deleteButton.isVisible()) {
|
||||
await deleteButton.click();
|
||||
|
||||
await expect(page.getByText(/确认删除/i)).toBeVisible();
|
||||
await page.getByRole('button', { name: /确认/i }).click();
|
||||
|
||||
await expect(page.getByText(/删除成功/i)).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('应该能够查看用户详情', async ({ page }) => {
|
||||
await page.goto('/users');
|
||||
|
||||
const detailButton = page.getByRole('button').filter({ hasText: /详情/i }).first();
|
||||
if (await detailButton.isVisible()) {
|
||||
await detailButton.click();
|
||||
await expect(page).toHaveURL(/.*users\/\d+\/detail/);
|
||||
await expect(page.getByText(/用户详情/i)).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('应该能够刷新用户列表', async ({ page }) => {
|
||||
await page.goto('/users');
|
||||
|
||||
await page.getByRole('button', { name: /刷新/i }).click();
|
||||
|
||||
await expect(page.getByText(/刷新成功/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('应该支持状态切换', async ({ page }) => {
|
||||
await page.goto('/users/create');
|
||||
|
||||
const statusSelect = page.locator('.ant-select').filter({ hasText: /状态/i });
|
||||
await statusSelect.click();
|
||||
|
||||
await expect(page.getByText(/启用/i)).toBeVisible();
|
||||
await expect(page.getByText(/禁用/i)).toBeVisible();
|
||||
|
||||
await page.getByText(/禁用/i).click();
|
||||
await expect(statusSelect).toContainText(/禁用/i);
|
||||
});
|
||||
|
||||
test('应该支持性别选择', async ({ page }) => {
|
||||
await page.goto('/users/create');
|
||||
|
||||
const genderSelect = page.locator('.ant-select').filter({ hasText: /性别/i });
|
||||
await genderSelect.click();
|
||||
|
||||
await expect(page.getByText(/男/i)).toBeVisible();
|
||||
await expect(page.getByText(/女/i)).toBeVisible();
|
||||
await expect(page.getByText(/未知/i)).toBeVisible();
|
||||
|
||||
await page.getByText(/女/i).click();
|
||||
await expect(genderSelect).toContainText(/女/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,566 @@
|
||||
import { Page, expect } from '@playwright/test';
|
||||
import { testLogger } from '../core/test-logger';
|
||||
|
||||
export async function waitForElement(page: Page, selector: string, timeout: number = 10000): Promise<void> {
|
||||
testLogger.debug(`等待元素: ${selector}, 超时: ${timeout}ms`);
|
||||
|
||||
try {
|
||||
await page.waitForSelector(selector, {
|
||||
state: 'visible',
|
||||
timeout
|
||||
});
|
||||
|
||||
testLogger.debug(`元素已可见: ${selector}`);
|
||||
} catch (error) {
|
||||
testLogger.error(`等待元素超时: ${selector}`, error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForElementHidden(page: Page, selector: string, timeout: number = 10000): Promise<void> {
|
||||
testLogger.debug(`等待元素隐藏: ${selector}, 超时: ${timeout}ms`);
|
||||
|
||||
try {
|
||||
await page.waitForSelector(selector, {
|
||||
state: 'hidden',
|
||||
timeout
|
||||
});
|
||||
|
||||
testLogger.debug(`元素已隐藏: ${selector}`);
|
||||
} catch (error) {
|
||||
testLogger.error(`等待元素隐藏超时: ${selector}`, error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForText(page: Page, selector: string, text: string, timeout: number = 10000): Promise<void> {
|
||||
testLogger.debug(`等待文本: ${selector} 包含 "${text}", 超时: ${timeout}ms`);
|
||||
|
||||
try {
|
||||
const locator = page.locator(selector);
|
||||
await expect(locator).toHaveText(text, { timeout });
|
||||
|
||||
testLogger.debug(`文本已出现: ${selector} 包含 "${text}"`);
|
||||
} catch (error) {
|
||||
testLogger.error(`等待文本超时: ${selector} 包含 "${text}"`, error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForURL(page: Page, urlPattern: string | RegExp, timeout: number = 10000): Promise<void> {
|
||||
testLogger.debug(`等待URL匹配: ${urlPattern}, 超时: ${timeout}ms`);
|
||||
|
||||
try {
|
||||
await page.waitForURL(urlPattern, { timeout });
|
||||
|
||||
testLogger.debug(`URL已匹配: ${page.url()}`);
|
||||
} catch (error) {
|
||||
testLogger.error(`等待URL超时: ${urlPattern}`, error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function clickElement(page: Page, selector: string, options?: { timeout?: number; force?: boolean }): Promise<void> {
|
||||
testLogger.debug(`点击元素: ${selector}`);
|
||||
|
||||
try {
|
||||
const locator = page.locator(selector);
|
||||
await locator.click(options);
|
||||
|
||||
testLogger.debug(`元素点击成功: ${selector}`);
|
||||
} catch (error) {
|
||||
testLogger.error(`点击元素失败: ${selector}`, error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fillInput(page: Page, selector: string, value: string, options?: { timeout?: number }): Promise<void> {
|
||||
testLogger.debug(`填充输入框: ${selector}, 值: ${value}`);
|
||||
|
||||
try {
|
||||
const locator = page.locator(selector);
|
||||
await locator.fill(value, options);
|
||||
|
||||
testLogger.debug(`输入框填充成功: ${selector}`);
|
||||
} catch (error) {
|
||||
testLogger.error(`填充输入框失败: ${selector}`, error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function selectOption(page: Page, selector: string, value: string | string[]): Promise<void> {
|
||||
testLogger.debug(`选择下拉选项: ${selector}, 值: ${value}`);
|
||||
|
||||
try {
|
||||
const locator = page.locator(selector);
|
||||
await locator.selectOption(value);
|
||||
|
||||
testLogger.debug(`下拉选项选择成功: ${selector}`);
|
||||
} catch (error) {
|
||||
testLogger.error(`选择下拉选项失败: ${selector}`, error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkCheckbox(page: Page, selector: string): Promise<void> {
|
||||
testLogger.debug(`勾选复选框: ${selector}`);
|
||||
|
||||
try {
|
||||
const locator = page.locator(selector);
|
||||
await locator.check();
|
||||
|
||||
testLogger.debug(`复选框勾选成功: ${selector}`);
|
||||
} catch (error) {
|
||||
testLogger.error(`勾选复选框失败: ${selector}`, error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function uncheckCheckbox(page: Page, selector: string): Promise<void> {
|
||||
testLogger.debug(`取消勾选复选框: ${selector}`);
|
||||
|
||||
try {
|
||||
const locator = page.locator(selector);
|
||||
await locator.uncheck();
|
||||
|
||||
testLogger.debug(`复选框取消勾选成功: ${selector}`);
|
||||
} catch (error) {
|
||||
testLogger.error(`取消勾选复选框失败: ${selector}`, error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getText(page: Page, selector: string): Promise<string> {
|
||||
testLogger.debug(`获取元素文本: ${selector}`);
|
||||
|
||||
try {
|
||||
const locator = page.locator(selector);
|
||||
const text = await locator.textContent();
|
||||
|
||||
testLogger.debug(`元素文本: ${selector} = ${text}`);
|
||||
|
||||
return text || '';
|
||||
} catch (error) {
|
||||
testLogger.error(`获取元素文本失败: ${selector}`, error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAttribute(page: Page, selector: string, attributeName: string): Promise<string | null> {
|
||||
testLogger.debug(`获取元素属性: ${selector}, 属性名: ${attributeName}`);
|
||||
|
||||
try {
|
||||
const locator = page.locator(selector);
|
||||
const attribute = await locator.getAttribute(attributeName);
|
||||
|
||||
testLogger.debug(`元素属性: ${selector}[${attributeName}] = ${attribute}`);
|
||||
|
||||
return attribute;
|
||||
} catch (error) {
|
||||
testLogger.error(`获取元素属性失败: ${selector}`, error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function isVisible(page: Page, selector: string): Promise<boolean> {
|
||||
try {
|
||||
const locator = page.locator(selector);
|
||||
return await locator.isVisible({ timeout: 5000 });
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function isEnabled(page: Page, selector: string): Promise<boolean> {
|
||||
try {
|
||||
const locator = page.locator(selector);
|
||||
return await locator.isEnabled({ timeout: 5000 });
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function isHidden(page: Page, selector: string): Promise<boolean> {
|
||||
try {
|
||||
const locator = page.locator(selector);
|
||||
return await locator.isHidden({ timeout: 5000 });
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function isDisabled(page: Page, selector: string): Promise<boolean> {
|
||||
try {
|
||||
const locator = page.locator(selector);
|
||||
return await locator.isDisabled({ timeout: 5000 });
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function scrollToElement(page: Page, selector: string): Promise<void> {
|
||||
testLogger.debug(`滚动到元素: ${selector}`);
|
||||
|
||||
try {
|
||||
const locator = page.locator(selector);
|
||||
await locator.scrollIntoViewIfNeeded();
|
||||
|
||||
testLogger.debug(`滚动到元素成功: ${selector}`);
|
||||
} catch (error) {
|
||||
testLogger.error(`滚动到元素失败: ${selector}`, error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function hoverElement(page: Page, selector: string): Promise<void> {
|
||||
testLogger.debug(`悬停在元素上: ${selector}`);
|
||||
|
||||
try {
|
||||
const locator = page.locator(selector);
|
||||
await locator.hover();
|
||||
|
||||
testLogger.debug(`悬停成功: ${selector}`);
|
||||
} catch (error) {
|
||||
testLogger.error(`悬停失败: ${selector}`, error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function doubleClickElement(page: Page, selector: string): Promise<void> {
|
||||
testLogger.debug(`双击元素: ${selector}`);
|
||||
|
||||
try {
|
||||
const locator = page.locator(selector);
|
||||
await locator.dblclick();
|
||||
|
||||
testLogger.debug(`双击成功: ${selector}`);
|
||||
} catch (error) {
|
||||
testLogger.error(`双击失败: ${selector}`, error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function rightClickElement(page: Page, selector: string): Promise<void> {
|
||||
testLogger.debug(`右键点击元素: ${selector}`);
|
||||
|
||||
try {
|
||||
const locator = page.locator(selector);
|
||||
await locator.click({ button: 'right' });
|
||||
|
||||
testLogger.debug(`右键点击成功: ${selector}`);
|
||||
} catch (error) {
|
||||
testLogger.error(`右键点击失败: ${selector}`, error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadFile(page: Page, selector: string, filePath: string): Promise<void> {
|
||||
testLogger.debug(`上传文件: ${selector}, 路径: ${filePath}`);
|
||||
|
||||
try {
|
||||
const locator = page.locator(selector);
|
||||
await locator.setInputFiles(filePath);
|
||||
|
||||
testLogger.debug(`文件上传成功: ${filePath}`);
|
||||
} catch (error) {
|
||||
testLogger.error(`文件上传失败: ${filePath}`, error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearInput(page: Page, selector: string): Promise<void> {
|
||||
testLogger.debug(`清空输入框: ${selector}`);
|
||||
|
||||
try {
|
||||
const locator = page.locator(selector);
|
||||
await locator.clear();
|
||||
|
||||
testLogger.debug(`输入框已清空: ${selector}`);
|
||||
} catch (error) {
|
||||
testLogger.error(`清空输入框失败: ${selector}`, error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function pressKey(page: Page, key: string): Promise<void> {
|
||||
testLogger.debug(`按键: ${key}`);
|
||||
|
||||
try {
|
||||
await page.keyboard.press(key);
|
||||
|
||||
testLogger.debug(`按键成功: ${key}`);
|
||||
} catch (error) {
|
||||
testLogger.error(`按键失败: ${key}`, error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function typeText(page: Page, selector: string, text: string, delay?: number): Promise<void> {
|
||||
testLogger.debug(`输入文本: ${selector}, 文本: ${text}`);
|
||||
|
||||
try {
|
||||
const locator = page.locator(selector);
|
||||
await locator.type(text, { delay });
|
||||
|
||||
testLogger.debug(`文本输入成功: ${selector}`);
|
||||
} catch (error) {
|
||||
testLogger.error(`文本输入失败: ${selector}`, error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForNetworkIdle(page: Page, timeout: number = 30000): Promise<void> {
|
||||
testLogger.debug(`等待网络空闲, 超时: ${timeout}ms`);
|
||||
|
||||
try {
|
||||
await page.waitForLoadState('networkidle', { timeout });
|
||||
|
||||
testLogger.debug('网络已空闲');
|
||||
} catch (error) {
|
||||
testLogger.error('等待网络空闲超时', error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForLoadState(page: Page, state: 'load' | 'domcontentloaded' | 'networkidle' = 'load', timeout: number = 30000): Promise<void> {
|
||||
testLogger.debug(`等待加载状态: ${state}, 超时: ${timeout}ms`);
|
||||
|
||||
try {
|
||||
await page.waitForLoadState(state, { timeout });
|
||||
|
||||
testLogger.debug(`加载状态已达到: ${state}`);
|
||||
} catch (error) {
|
||||
testLogger.error(`等待加载状态超时: ${state}`, error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeScript(page: Page, script: string, ...args: any[]): Promise<any> {
|
||||
testLogger.debug('执行JavaScript脚本');
|
||||
|
||||
try {
|
||||
const result = await page.evaluate(script, ...args);
|
||||
|
||||
testLogger.debug('JavaScript脚本执行成功');
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
testLogger.error('JavaScript脚本执行失败', error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function takeScreenshot(page: Page, name: string, fullPage: boolean = false): Promise<string> {
|
||||
testLogger.debug(`截图: ${name}, 全页: ${fullPage}`);
|
||||
|
||||
try {
|
||||
const path = `test-results/screenshots/${name}-${Date.now()}.png`;
|
||||
await page.screenshot({ path, fullPage });
|
||||
|
||||
testLogger.debug(`截图已保存: ${path}`);
|
||||
|
||||
return path;
|
||||
} catch (error) {
|
||||
testLogger.error(`截图失败: ${name}`, error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForTimeout(ms: number): Promise<void> {
|
||||
testLogger.debug(`等待 ${ms}ms`);
|
||||
await new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export async function retryOperation<T>(
|
||||
operation: () => Promise<T>,
|
||||
maxRetries: number = 3,
|
||||
delay: number = 1000,
|
||||
description: string = '操作'
|
||||
): Promise<T> {
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
testLogger.debug(`${description} 尝试 ${attempt}/${maxRetries}`);
|
||||
const result = await operation();
|
||||
|
||||
if (attempt > 1) {
|
||||
testLogger.info(`${description} 在第 ${attempt} 次尝试后成功`);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
lastError = error as Error;
|
||||
testLogger.warn(`${description} 第 ${attempt} 次尝试失败: ${error}`);
|
||||
|
||||
if (attempt < maxRetries) {
|
||||
await waitForTimeout(delay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error(`${description} 在 ${maxRetries} 次尝试后仍然失败`);
|
||||
}
|
||||
|
||||
export async function waitUntil(
|
||||
condition: () => boolean | Promise<boolean>,
|
||||
timeout: number = 10000,
|
||||
interval: number = 100,
|
||||
description: string = '条件'
|
||||
): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
|
||||
while (Date.now() - startTime < timeout) {
|
||||
const result = await condition();
|
||||
|
||||
if (result) {
|
||||
testLogger.debug(`${description} 已满足`);
|
||||
return;
|
||||
}
|
||||
|
||||
await waitForTimeout(interval);
|
||||
}
|
||||
|
||||
throw new Error(`${description} 在 ${timeout}ms 内未满足`);
|
||||
}
|
||||
|
||||
export function generateRandomString(length: number = 10): string {
|
||||
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let result = '';
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += characters.charAt(Math.floor(Math.random() * characters.length));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function generateRandomEmail(): string {
|
||||
const username = generateRandomString(8).toLowerCase();
|
||||
const domains = ['example.com', 'test.com', 'demo.com'];
|
||||
const domain = domains[Math.floor(Math.random() * domains.length)];
|
||||
|
||||
return `${username}@${domain}`;
|
||||
}
|
||||
|
||||
export function generateRandomPhoneNumber(): string {
|
||||
const prefix = ['138', '139', '150', '151', '186', '188'];
|
||||
const selectedPrefix = prefix[Math.floor(Math.random() * prefix.length)];
|
||||
const suffix = Math.floor(Math.random() * 100000000).toString().padStart(8, '0');
|
||||
|
||||
return `${selectedPrefix}${suffix}`;
|
||||
}
|
||||
|
||||
export function generateRandomId(): string {
|
||||
return `${Date.now()}-${generateRandomString(6)}`;
|
||||
}
|
||||
|
||||
export function formatDate(date: Date, format: string = 'YYYY-MM-DD'): string {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0');
|
||||
|
||||
return format
|
||||
.replace('YYYY', year.toString())
|
||||
.replace('MM', month)
|
||||
.replace('DD', day)
|
||||
.replace('HH', hours)
|
||||
.replace('mm', minutes)
|
||||
.replace('ss', seconds);
|
||||
}
|
||||
|
||||
export function parseDate(dateString: string, format: string = 'YYYY-MM-DD'): Date {
|
||||
const parts = dateString.match(/(\d+)/g);
|
||||
|
||||
if (!parts) {
|
||||
throw new Error(`无效的日期格式: ${dateString}`);
|
||||
}
|
||||
|
||||
const year = parseInt(parts[0], 10);
|
||||
const month = parseInt(parts[1], 10) - 1;
|
||||
const day = parseInt(parts[2], 10);
|
||||
|
||||
return new Date(year, month, day);
|
||||
}
|
||||
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export function debounce(func: Function, wait: number): Function {
|
||||
let timeout: NodeJS.Timeout | null = null;
|
||||
|
||||
return function(...args: any[]) {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
func.apply(this, args);
|
||||
}, wait);
|
||||
};
|
||||
}
|
||||
|
||||
export function throttle(func: Function, limit: number): Function {
|
||||
let inThrottle: boolean = false;
|
||||
|
||||
return function(...args: any[]) {
|
||||
if (!inThrottle) {
|
||||
func.apply(this, args);
|
||||
inThrottle = true;
|
||||
|
||||
setTimeout(() => {
|
||||
inThrottle = false;
|
||||
}, limit);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function deepClone<T>(obj: T): T {
|
||||
return JSON.parse(JSON.stringify(obj));
|
||||
}
|
||||
|
||||
export function isEmpty(value: any): boolean {
|
||||
if (value === null || value === undefined) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof value === 'string' || Array.isArray(value)) {
|
||||
return value.length === 0;
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
return Object.keys(value).length === 0;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isNotEmpty(value: any): boolean {
|
||||
return !isEmpty(value);
|
||||
}
|
||||
|
||||
export function pick<T extends Record<string, any>, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> {
|
||||
const result = {} as Pick<T, K>;
|
||||
|
||||
for (const key of keys) {
|
||||
if (key in obj) {
|
||||
result[key] = obj[key];
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function omit<T extends Record<string, any>, K extends keyof T>(obj: T, keys: K[]): Omit<T, K> {
|
||||
const result = { ...obj };
|
||||
|
||||
for (const key of keys) {
|
||||
delete result[key];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
Reference in New Issue
Block a user