refactor(frontend): 重命名前端项目为 gym-manage-web

This commit is contained in:
张翔
2026-04-17 18:37:45 +08:00
parent deb961c427
commit 45bb89fc7f
140 changed files with 2 additions and 2 deletions
+39
View File
@@ -0,0 +1,39 @@
# 测试环境配置示例
# 复制此文件为 .env 并根据实际情况修改配置
# 测试基础URL
TEST_BASE_URL=http://localhost:3001
# Playwright配置
PLAYWRIGHT_HEADLESS=false
# 前端配置
VITE_BASE_URL=http://localhost:3001
# CI/CD环境配置
CI=false
# 测试数据库配置(可选)
TEST_DB_HOST=localhost
TEST_DB_PORT=5432
TEST_DB_NAME=novalon_manage_test
TEST_DB_USER=test
TEST_DB_PASSWORD=test
# 测试超时配置(可选)
TEST_TIMEOUT=120000
TEST_ACTION_TIMEOUT=30000
TEST_NAVIGATION_TIMEOUT=60000
# 测试重试配置(可选)
TEST_RETRIES=3
# 测试并行度配置(可选)
TEST_WORKERS=4
# 测试报告配置(可选)
TEST_REPORT_FOLDER=playwright-report
TEST_RESULTS_FOLDER=test-results
# API签名密钥配置
VITE_SIGNATURE_SECRET=your-secret-key-here
+10
View File
@@ -0,0 +1,10 @@
# 测试环境配置
VITE_API_BASE_URL=http://localhost:8084
VITE_APP_TITLE=Novalon管理系统 - 测试环境
# 测试用户配置
TEST_USER_PASSWORD=Test@123
# Playwright配置
HEADLESS=true
SLOW_MO=0
+25
View File
@@ -0,0 +1,25 @@
module.exports = {
root: true,
env: {
browser: true,
es2021: true,
node: true
},
extends: [
'eslint:recommended',
'plugin:vue/vue3-recommended',
'plugin:@typescript-eslint/recommended'
],
parser: 'vue-eslint-parser',
parserOptions: {
ecmaVersion: 'latest',
parser: '@typescript-eslint/parser',
sourceType: 'module'
},
plugins: ['vue', '@typescript-eslint'],
rules: {
'vue/multi-word-component-names': 'off',
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }]
}
}
+11
View File
@@ -0,0 +1,11 @@
node_modules
dist
.DS_Store
*.log
.env
.env.local
.env.*.local
coverage
.nyc_output
debug-*.png
e2e/debug/
+49
View File
@@ -0,0 +1,49 @@
# 多阶段构建优化Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
# 安装 pnpm
RUN npm install -g pnpm@8.15.0
# 复制 package.json 和 lock 文件
COPY package.json pnpm-lock.yaml ./
# 安装依赖(利用Docker缓存层)
RUN pnpm install --frozen-lockfile
# 复制源代码
COPY . .
# 构建生产版本
RUN pnpm run build:prod
# 生产阶段
FROM nginx:alpine
# 设置时区
RUN apk add --no-cache tzdata
ENV TZ=Asia/Shanghai
# 创建非root用户
RUN addgroup -g 1001 -S novalon && \
adduser -S novalon -u 1001 -G novalon
# 复制自定义 nginx 配置
COPY nginx.conf /etc/nginx/conf.d/default.conf
# 复制构建产物并设置权限
COPY --from=builder --chown=novalon:novalon /app/dist /usr/share/nginx/html
# 设置nginx运行用户
RUN sed -i 's/user nginx;/user novalon;/' /etc/nginx/nginx.conf
# 暴露端口
EXPOSE 80
# 健康检查
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD curl -f http://localhost:80 || exit 1
# 启动 nginx
CMD ["nginx", "-g", "daemon off;"]
+21
View File
@@ -0,0 +1,21 @@
FROM node:20-alpine
WORKDIR /app
# 安装 pnpm
RUN npm install -g pnpm@8.15.0
# 复制 package.json 和 lock 文件
COPY package.json pnpm-lock.yaml ./
# 安装依赖
RUN pnpm install
# 复制源代码
COPY . .
# 暴露端口
EXPOSE 3002
# 启动开发服务器
CMD ["pnpm", "run", "dev"]
+29
View File
@@ -0,0 +1,29 @@
FROM mcr.microsoft.com/playwright:v1.58.2-jammy
WORKDIR /app
# 安装依赖
COPY package*.json ./
RUN npm ci
# 复制测试文件
COPY e2e ./e2e
COPY playwright.config.ts ./
COPY tsconfig.json ./
# 创建测试结果目录
RUN mkdir -p /app/test-results /app/playwright-report
# 安装Playwright浏览器
RUN npx playwright install --with-deps chromium
# 设置环境变量
ENV CI=true
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
# 运行测试
CMD ["npx", "playwright", "test", "--reporter=json", "--reporter=html", "--reporter=junit"]
# 健康检查
HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
CMD test -f /app/playwright-report/index.html || exit 1
+60
View File
@@ -0,0 +1,60 @@
# E2E测试说明
## 测试结构
本项目的E2E测试采用分层测试策略:
### 冒烟测试(smoke/
快速验证基础功能是否正常工作。
- `login-logout.spec.ts` - 登录登出基础流程
### 核心旅程测试(journeys/
验证关键业务端到端流程。
- `admin-complete-workflow.spec.ts` - 管理员完整工作流
- `user-permission-boundary.spec.ts` - 用户权限边界验证
- `file-management-workflow.spec.ts` - 文件上传下载流程
- `audit-workflow.spec.ts` - 审计日志查看流程
## 运行测试
### 运行冒烟测试
```bash
npm run test:e2e:smoke
```
### 运行核心旅程测试
```bash
npm run test:e2e:journeys
```
### 运行所有测试
```bash
npm run test:e2e
```
## 测试数据
测试使用的用户账号:
- 管理员:username: `admin`, password: `Test@123`
- 普通用户:username: `user`, password: `Test@123`
## 测试策略
- **冒烟测试**:每次代码提交时运行,快速反馈
- **核心旅程测试**:PR合并前运行,验证关键业务流程
- **单元测试**:补充功能覆盖率,目标80%
## 维护指南
1. 新增核心业务功能时,在 `journeys/` 目录下添加测试
2. 新增基础功能时,在 `smoke/` 目录下添加测试
3. 保持测试文件数量精简,避免重复测试
4. 优先使用单元测试覆盖功能细节
@@ -0,0 +1,65 @@
import { test, expect } from '@playwright/test';
test.describe('API连通性测试', () => {
test('验证网关服务健康状态', async ({ page }) => {
await test.step('检查网关健康状态', async () => {
const response = await page.request.get('http://localhost:8080/actuator/health');
expect(response.status()).toBe(200);
const data = await response.json();
expect(data.status).toBe('UP');
});
await test.step('检查应用服务路由', async () => {
const response = await page.request.get('http://localhost:8080/api/auth/health');
expect(response.status()).toBe(200);
});
});
test('验证前端与后端连通性', async ({ page }) => {
await test.step('加载前端应用', async () => {
await page.goto('/');
await page.waitForLoadState('networkidle');
// 验证页面标题
const title = await page.title();
expect(title).toContain('Novalon');
});
await test.step('检查API请求', async () => {
// 监听网络请求
const apiRequests = [];
page.on('request', request => {
if (request.url().includes('/api/')) {
apiRequests.push({
url: request.url(),
method: request.method()
});
}
});
// 触发一些前端操作来生成API请求
await page.goto('/login');
await page.waitForLoadState('networkidle');
// 验证是否有API请求发出
expect(apiRequests.length).toBeGreaterThan(0);
});
});
test('验证数据库连接状态', async ({ page }) => {
await test.step('检查数据库健康状态', async () => {
// 通过应用服务检查数据库连接
const response = await page.request.get('http://localhost:8084/actuator/health');
expect(response.status()).toBe(200);
const data = await response.json();
expect(data.status).toBe('UP');
// 检查数据库组件状态
if (data.components && data.components.db) {
expect(data.components.db.status).toBe('UP');
}
});
});
});
+197
View File
@@ -0,0 +1,197 @@
import { test, expect } from '@playwright/test';
test.describe('认证和授权测试', () => {
let authToken: string;
let userId: number;
test('用户登录测试', async ({ page }) => {
await test.step('准备登录数据', async () => {
console.log('准备登录测试数据...');
});
await test.step('发送登录请求', async () => {
const response = await page.request.post('http://localhost:8080/api/auth/login', {
headers: {
'Content-Type': 'application/json'
},
data: {
username: 'admin',
password: 'admin123'
}
});
expect(response.status()).toBe(200);
const data = await response.json();
expect(data).toHaveProperty('token');
expect(data).toHaveProperty('userId');
expect(data).toHaveProperty('username');
authToken = data.token;
userId = data.userId;
console.log('登录成功,获取到Token:', authToken.substring(0, 20) + '...');
});
await test.step('验证Token有效性', async () => {
const response = await page.request.get('http://localhost:8080/api/users', {
headers: {
'Authorization': `Bearer ${authToken}`
}
});
expect(response.status()).toBe(200);
console.log('Token验证成功,可以访问受保护的资源');
});
});
test('用户信息查询测试', async ({ page }) => {
await test.step('先登录获取Token', async () => {
const loginResponse = await page.request.post('http://localhost:8080/api/auth/login', {
headers: {
'Content-Type': 'application/json'
},
data: {
username: 'admin',
password: 'admin123'
}
});
const loginData = await loginResponse.json();
authToken = loginData.token;
userId = loginData.userId;
});
await test.step('查询用户列表', async () => {
const response = await page.request.get('http://localhost:8080/api/users', {
headers: {
'Authorization': `Bearer ${authToken}`
}
});
expect(response.status()).toBe(200);
const users = await response.json();
expect(Array.isArray(users)).toBe(true);
expect(users.length).toBeGreaterThan(0);
console.log(`查询到 ${users.length} 个用户`);
});
await test.step('查询指定用户信息', async () => {
const response = await page.request.get(`http://localhost:8080/api/users/${userId}`, {
headers: {
'Authorization': `Bearer ${authToken}`
}
});
expect(response.status()).toBe(200);
const user = await response.json();
expect(user).toHaveProperty('id');
expect(user).toHaveProperty('username');
expect(user.id).toBe(userId);
console.log(`查询到用户信息: ${user.username}`);
});
});
test('权限验证测试', async ({ page }) => {
await test.step('先登录获取Token', async () => {
const loginResponse = await page.request.post('http://localhost:8080/api/auth/login', {
headers: {
'Content-Type': 'application/json'
},
data: {
username: 'admin',
password: 'admin123'
}
});
const loginData = await loginResponse.json();
authToken = loginData.token;
});
await test.step('测试访问受保护的API', async () => {
const protectedEndpoints = [
'/api/users',
'/api/roles',
'/api/menus',
'/api/config'
];
for (const endpoint of protectedEndpoints) {
const response = await page.request.get(`http://localhost:8080${endpoint}`, {
headers: {
'Authorization': `Bearer ${authToken}`
}
});
console.log(`访问 ${endpoint}: ${response.status()}`);
expect([200, 404]).toContain(response.status());
}
});
await test.step('测试无Token访问受保护API', async () => {
const response = await page.request.get('http://localhost:8080/api/users');
expect(response.status()).toBe(401);
console.log('无Token访问受保护API返回401,权限验证正常');
});
});
test('前端登录流程测试', async ({ page }) => {
await test.step('访问登录页面', async () => {
await page.goto('/login');
await page.waitForLoadState('networkidle');
// 验证登录页面元素
const usernameInput = page.locator('input[type="text"], input[placeholder*="用户名"], input[placeholder*="账号"]');
const passwordInput = page.locator('input[type="password"]');
const loginButton = page.locator('button:has-text("登录")');
expect(await usernameInput.count()).toBeGreaterThan(0);
expect(await passwordInput.count()).toBeGreaterThan(0);
expect(await loginButton.count()).toBeGreaterThan(0);
console.log('登录页面元素验证通过');
});
await test.step('填写登录表单', async () => {
const usernameInput = page.locator('input[type="text"], input[placeholder*="用户名"], input[placeholder*="账号"]').first();
const passwordInput = page.locator('input[type="password"]').first();
await usernameInput.fill('admin');
await passwordInput.fill('admin123');
console.log('登录表单填写完成');
});
await test.step('提交登录表单', async () => {
const loginButton = page.locator('button:has-text("登录")').first();
// 监听响应
const responsePromise = page.waitForResponse(response =>
response.url().includes('/api/auth/login') && response.request().method() === 'POST'
);
await loginButton.click();
try {
const response = await responsePromise;
console.log('登录请求状态:', response.status());
if (response.status() === 200) {
const data = await response.json();
expect(data).toHaveProperty('token');
console.log('前端登录成功');
}
} catch (error) {
console.log('登录请求可能超时,但这是预期的行为');
}
// 等待一段时间,观察页面变化
await page.waitForTimeout(2000);
});
});
});
+16
View File
@@ -0,0 +1,16 @@
import { test as setup } from '@playwright/test';
const authFile = 'playwright/.auth/user.json';
setup('authenticate', async ({ page }) => {
await page.goto('/login');
await page.waitForLoadState('networkidle');
await page.locator('input[placeholder*="用户名"]').fill('admin');
await page.locator('input[placeholder*="密码"]').fill('admin123');
await page.locator('button:has-text("登录")').click();
await page.waitForURL('**/dashboard', { timeout: 30000 });
await page.context().storageState({ path: authFile });
});
+86
View File
@@ -0,0 +1,86 @@
import { test, expect } from '@playwright/test';
test.describe('基础UI功能测试', () => {
test('前端应用基本功能验证', async ({ page }) => {
// 测试1: 应用首页加载
await test.step('加载应用首页', async () => {
await page.goto('/');
await page.waitForLoadState('networkidle');
// 验证页面标题
const title = await page.title();
expect(title).toContain('Novalon');
});
// 测试2: 登录页面渲染
await test.step('验证登录页面元素', async () => {
await page.goto('/login');
await page.waitForLoadState('networkidle');
// 验证登录表单元素
await expect(page.locator('input[type="text"]')).toBeVisible();
await expect(page.locator('input[type="password"]')).toBeVisible();
await expect(page.locator('button:has-text("登录")')).toBeVisible();
});
// 测试3: 页面导航
await test.step('验证页面导航功能', async () => {
// 检查页面是否有基本的导航元素 - 使用更灵活的选择器
const navigationSelectors = [
'nav', '.navbar', '.menu', '.el-menu', '.el-header',
'.layout-header', '.app-header', '[class*="header"]',
'[class*="nav"]', '[class*="menu"]'
];
let hasNavigation = false;
for (const selector of navigationSelectors) {
const count = await page.locator(selector).count();
if (count > 0) {
hasNavigation = true;
break;
}
}
// 如果找不到传统导航元素,检查是否有其他页面结构
if (!hasNavigation) {
const hasAppContainer = await page.locator('#app, .app, .container').count() > 0;
const hasBodyContent = await page.locator('body').textContent() !== '';
hasNavigation = hasAppContainer && hasBodyContent;
}
expect(hasNavigation).toBeTruthy();
});
// 测试4: 响应式设计验证
await test.step('验证响应式设计', async () => {
// 设置移动端视口
await page.setViewportSize({ width: 375, height: 667 });
await page.waitForTimeout(500);
// 验证页面在移动端仍然可访问
await expect(page.locator('body')).toBeVisible();
});
});
test('应用静态资源加载', async ({ page }) => {
await page.goto('/');
// 验证CSS加载
const cssLoaded = await page.evaluate(() => {
return document.styleSheets.length > 0;
});
expect(cssLoaded).toBeTruthy();
// 验证JavaScript加载
const jsLoaded = await page.evaluate(() => {
return typeof window !== 'undefined';
});
expect(jsLoaded).toBeTruthy();
// 验证Vue应用挂载
const vueMounted = await page.evaluate(() => {
return !!document.querySelector('#app');
});
expect(vueMounted).toBeTruthy();
});
});
@@ -0,0 +1,72 @@
import { test, expect } from '@playwright/test';
test.describe('参数配置功能测试', () => {
let authToken: string;
test.beforeAll(async ({ request }) => {
const response = await request.post('http://localhost:8080/api/auth/login', {
headers: {
'Content-Type': 'application/json'
},
data: {
username: 'admin',
password: 'admin123'
}
});
expect(response.status()).toBe(200);
const data = await response.json();
authToken = data.token;
});
test('参数配置列表显示测试', async ({ page }) => {
await test.step('导航到参数配置页面', async () => {
await page.goto('http://localhost:3002/login');
const usernameInput = page.locator('input[type="text"], input[placeholder*="用户名"], input[placeholder*="账号"]').first();
const passwordInput = page.locator('input[type="password"]').first();
const loginButton = page.locator('button:has-text("登录")').first();
await usernameInput.fill('admin');
await passwordInput.fill('admin123');
await loginButton.click();
await page.waitForTimeout(2000);
// 点击系统管理菜单
const systemMenu = page.locator('.el-sub-menu:has-text("系统管理")').first();
if (await systemMenu.count() > 0) {
await systemMenu.click();
await page.waitForTimeout(500);
}
// 点击参数配置
const configManagement = page.locator('.el-menu-item:has-text("参数配置")').first();
if (await configManagement.count() > 0) {
await configManagement.click();
await page.waitForTimeout(1000);
}
});
await test.step('验证参数配置列表显示', async () => {
// 检查是否有参数配置列表或表格
const tableSelectors = [
'table',
'.el-table',
'[class*="table"]',
'.config-list'
];
let foundTable = false;
for (const selector of tableSelectors) {
const count = await page.locator(selector).count();
if (count > 0) {
foundTable = true;
break;
}
}
expect(foundTable).toBe(true);
});
});
});
+429
View File
@@ -0,0 +1,429 @@
import { FullConfig, FullResult, Reporter, Suite, TestCase, TestResult } from '@playwright/test/reporter';
import * as fs from 'fs';
import * as path from 'path';
class CustomReporter implements Reporter {
private results: Map<string, TestCase[]> = new Map();
private suiteResults: Map<string, Suite> = new Map();
private startTime: number = Date.now();
private testResults: TestResult[] = [];
onBegin(config: FullConfig) {
console.log(`🚀 开始测试执行: ${config.projects.map(p => p.name).join(', ')}`);
this.startTime = Date.now();
}
onTestBegin(test: TestCase, result: TestResult) {
console.log(`📝 开始测试: ${test.title}`);
}
onTestEnd(test: TestCase, result: TestResult) {
console.log(`✅ 测试完成: ${test.title} - ${result.status}`);
this.testResults.push(result);
}
onEnd(result: FullResult) {
const endTime = Date.now();
const duration = endTime - this.startTime;
console.log(`🎉 测试执行完成`);
console.log(`⏱️ 总耗时: ${this.formatDuration(duration)}`);
const stats = this.calculateStats(result);
this.generateConsoleReport(stats);
this.generateHtmlReport(result, stats);
this.generateJsonReport(result, stats);
}
private calculateStats(result: FullResult): TestStats {
const allTests = this.testResults;
if (allTests.length === 0) {
return {
total: 0,
passed: 0,
failed: 0,
skipped: 0,
flaky: 0,
passRate: 0,
failRate: 0,
skipRate: 0,
flakyRate: 0,
totalDuration: 0,
avgDuration: 0,
slowestTests: [],
failedTests: [],
};
}
const passed = allTests.filter(t => t.status === 'passed');
const failed = allTests.filter(t => t.status === 'failed');
const skipped = allTests.filter(t => t.status === 'skipped');
const flaky = allTests.filter(t => t.status === 'passed' && t.retry >= 1);
const totalDuration = allTests.reduce((sum, t) => sum + t.duration, 0);
const avgDuration = totalDuration / allTests.length;
const passRate = (passed.length / allTests.length) * 100;
const failRate = (failed.length / allTests.length) * 100;
const skipRate = (skipped.length / allTests.length) * 100;
const flakyRate = (flaky.length / allTests.length) * 100;
return {
total: allTests.length,
passed: passed.length,
failed: failed.length,
skipped: skipped.length,
flaky: flaky.length,
passRate,
failRate,
skipRate,
flakyRate,
totalDuration,
avgDuration,
slowestTests: allTests
.filter(t => t.duration > 0)
.sort((a, b) => b.duration - a.duration)
.slice(0, 10),
failedTests: failed,
};
}
private generateConsoleReport(stats: TestStats) {
console.log('');
console.log('═══════════════════════════════════════════');
console.log('📊 测试统计报告');
console.log('═══════════════════════════════════════════');
console.log('');
console.log(`📈 总测试数: ${stats.total}`);
console.log(`✅ 通过: ${stats.passed} (${stats.passRate.toFixed(2)}%)`);
console.log(`❌ 失败: ${stats.failed} (${stats.failRate.toFixed(2)}%)`);
console.log(`⏭️ 跳过: ${stats.skipped} (${stats.skipRate.toFixed(2)}%)`);
console.log(`🔄 不稳定: ${stats.flaky} (${stats.flakyRate.toFixed(2)}%)`);
console.log('');
console.log(`⏱️ 总耗时: ${this.formatDuration(stats.totalDuration)}`);
console.log(`⏱️ 平均耗时: ${this.formatDuration(stats.avgDuration)}`);
console.log('');
console.log('🐌 最慢的10个测试:');
stats.slowestTests.forEach((test, index) => {
console.log(` ${index + 1}. ${test.title} - ${this.formatDuration(test.duration || 0)}`);
});
console.log('');
if (stats.failedTests.length > 0) {
console.log('❌ 失败的测试:');
stats.failedTests.forEach((test, index) => {
console.log(` ${index + 1}. ${test.title || '未命名测试'}`);
if (test.location?.file) {
console.log(` 位置: ${test.location.file}:${test.location.line || 0}`);
}
if (test.error?.message) {
console.log(` 错误: ${test.error.message}`);
}
});
console.log('');
}
}
private generateHtmlReport(result: FullResult, stats: TestStats) {
const html = `
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>测试报告 - Novalon管理系统</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
margin: 0;
padding: 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.container {
max-width: 1200px;
margin: 0 auto;
background: white;
border-radius: 10px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
padding: 30px;
}
.header {
text-align: center;
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 2px solid #e0e0e0;
}
.header h1 {
margin: 0;
color: #333;
font-size: 28px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.stat-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px;
border-radius: 10px;
text-align: center;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.stat-card h3 {
margin: 0 0 10px 0;
font-size: 14px;
opacity: 0.9;
}
.stat-card .value {
font-size: 32px;
font-weight: bold;
margin: 0;
}
.stat-card .label {
font-size: 12px;
opacity: 0.8;
margin-top: 5px;
}
.stat-card.passed {
background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
}
.stat-card.failed {
background: linear-gradient(135deg, #ef4444 0%, #f44336 100%);
}
.stat-card.flaky {
background: linear-gradient(135deg, #f59e0b 0%, #f093fb 100%);
}
.section {
margin-bottom: 30px;
}
.section h2 {
color: #333;
border-bottom: 2px solid #667eea;
padding-bottom: 10px;
margin-bottom: 20px;
}
.test-list {
list-style: none;
padding: 0;
}
.test-item {
padding: 15px;
margin-bottom: 10px;
border-left: 4px solid #ddd;
background: #f9f9f9;
border-radius: 5px;
}
.test-item.passed {
border-left-color: #38ef7d;
background: #f0fff4;
}
.test-item.failed {
border-left-color: #ef4444;
background: #fff5f5;
}
.test-item.skipped {
border-left-color: #f59e0b;
background: #fef9c3;
}
.test-item.flaky {
border-left-color: #f093fb;
background: #fef3c7;
}
.test-item .test-name {
font-weight: bold;
margin-bottom: 5px;
color: #333;
}
.test-item .test-duration {
color: #666;
font-size: 12px;
}
.test-item .test-error {
color: #ef4444;
font-size: 12px;
margin-top: 5px;
padding: 10px;
background: #fee;
border-radius: 3px;
}
.progress-bar {
height: 20px;
background: #e0e0e0;
border-radius: 10px;
overflow: hidden;
margin: 20px 0;
}
.progress-bar-fill {
height: 100%;
background: linear-gradient(90deg, #11998e 0%, #38ef7d 100%);
transition: width 0.5s ease;
}
.footer {
text-align: center;
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #e0e0e0;
color: #666;
font-size: 12px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🧪 Novalon管理系统测试报告</h1>
<p>生成时间: ${new Date().toLocaleString('zh-CN')}</p>
</div>
<div class="stats-grid">
<div class="stat-card passed">
<h3>通过测试</h3>
<div class="value">${stats.passed}</div>
<div class="label">${stats.passRate.toFixed(2)}%</div>
</div>
<div class="stat-card failed">
<h3>失败测试</h3>
<div class="value">${stats.failed}</div>
<div class="label">${stats.failRate.toFixed(2)}%</div>
</div>
<div class="stat-card flaky">
<h3>不稳定测试</h3>
<div class="value">${stats.flaky}</div>
<div class="label">${stats.flakyRate.toFixed(2)}%</div>
</div>
<div class="stat-card">
<h3>总测试数</h3>
<div class="value">${stats.total}</div>
<div class="label">100%</div>
</div>
</div>
<div class="progress-bar">
<div class="progress-bar-fill" style="width: ${stats.passRate}%"></div>
</div>
<div class="section">
<h2>📈 测试统计</h2>
<ul class="test-list">
<li class="test-item">
<div class="test-name">总耗时</div>
<div class="test-duration">${this.formatDuration(stats.totalDuration)}</div>
</li>
<li class="test-item">
<div class="test-name">平均耗时</div>
<div class="test-duration">${this.formatDuration(stats.avgDuration)}</div>
</li>
<li class="test-item">
<div class="test-name">跳过测试</div>
<div class="test-duration">${stats.skipped} (${stats.skipRate.toFixed(2)}%)</div>
</li>
</ul>
</div>
${stats.failedTests.length > 0 ? `
<div class="section">
<h2>❌ 失败测试详情</h2>
<ul class="test-list">
${stats.failedTests.map(test => `
<li class="test-item failed">
<div class="test-name">${test.title}</div>
<div class="test-duration">${this.formatDuration(test.duration || 0)}</div>
<div class="test-error">
<strong>错误:</strong> ${test.error?.message || '未知错误'}
</div>
</li>
`).join('')}
</ul>
</div>
` : ''}
<div class="section">
<h2>🐌 最慢的10个测试</h2>
<ul class="test-list">
${stats.slowestTests.map((test, index) => `
<li class="test-item ${test.status}">
<div class="test-name">${index + 1}. ${test.title}</div>
<div class="test-duration">${this.formatDuration(test.duration || 0)}</div>
</li>
`).join('')}
</ul>
</div>
<div class="footer">
<p>🧪 Novalon管理系统 - 自动化测试报告</p>
<p>生成时间: ${new Date().toLocaleString('zh-CN')}</p>
</div>
</div>
</body>
</html>
`;
const reportPath = path.join(process.cwd(), 'test-results', 'custom-report.html');
fs.writeFileSync(reportPath, html, 'utf-8');
console.log(`📄 HTML报告已生成: ${reportPath}`);
}
private generateJsonReport(result: FullResult, stats: TestStats) {
const report = {
summary: {
timestamp: new Date().toISOString(),
total: stats.total,
passed: stats.passed,
failed: stats.failed,
skipped: stats.skipped,
flaky: stats.flaky,
passRate: stats.passRate,
failRate: stats.failRate,
skipRate: stats.skipRate,
flakyRate: stats.flakyRate,
totalDuration: stats.totalDuration,
avgDuration: stats.avgDuration,
},
failedTests: stats.failedTests.map(test => ({
title: test.title,
location: test.location,
error: test.error?.message,
duration: test.duration,
})),
slowestTests: stats.slowestTests.map(test => ({
title: test.title,
duration: test.duration,
})),
};
const reportPath = path.join(process.cwd(), 'test-results', 'custom-report.json');
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2), 'utf-8');
console.log(`📄 JSON报告已生成: ${reportPath}`);
}
private formatDuration(ms: number): string {
if (ms < 1000) {
return `${ms}ms`;
} else if (ms < 60000) {
return `${(ms / 1000).toFixed(1)}s`;
} else {
return `${(ms / 60000).toFixed(1)}m`;
}
}
}
interface TestStats {
total: number;
passed: number;
failed: number;
skipped: number;
flaky: number;
passRate: number;
failRate: number;
skipRate: number;
flakyRate: number;
totalDuration: number;
avgDuration: number;
slowestTests: TestCase[];
}
export default CustomReporter;
@@ -0,0 +1,72 @@
import { test, expect } from '@playwright/test';
test.describe('字典管理功能测试', () => {
let authToken: string;
test.beforeAll(async ({ request }) => {
const response = await request.post('http://localhost:8080/api/auth/login', {
headers: {
'Content-Type': 'application/json'
},
data: {
username: 'admin',
password: 'admin123'
}
});
expect(response.status()).toBe(200);
const data = await response.json();
authToken = data.token;
});
test('字典管理列表显示测试', async ({ page }) => {
await test.step('导航到字典管理页面', async () => {
await page.goto('http://localhost:3002/login');
const usernameInput = page.locator('input[type="text"], input[placeholder*="用户名"], input[placeholder*="账号"]').first();
const passwordInput = page.locator('input[type="password"]').first();
const loginButton = page.locator('button:has-text("登录")').first();
await usernameInput.fill('admin');
await passwordInput.fill('admin123');
await loginButton.click();
await page.waitForTimeout(2000);
// 点击系统管理菜单
const systemMenu = page.locator('.el-sub-menu:has-text("系统管理")').first();
if (await systemMenu.count() > 0) {
await systemMenu.click();
await page.waitForTimeout(500);
}
// 点击字典管理
const dictManagement = page.locator('.el-menu-item:has-text("字典管理")').first();
if (await dictManagement.count() > 0) {
await dictManagement.click();
await page.waitForTimeout(1000);
}
});
await test.step('验证字典管理列表显示', async () => {
// 检查是否有字典管理列表或表格
const tableSelectors = [
'table',
'.el-table',
'[class*="table"]',
'.dict-list'
];
let foundTable = false;
for (const selector of tableSelectors) {
const count = await page.locator(selector).count();
if (count > 0) {
foundTable = true;
break;
}
}
expect(foundTable).toBe(true);
});
});
});
+119
View File
@@ -0,0 +1,119 @@
import { test as base } from '@playwright/test';
export interface TestUser {
username: string;
password: string;
email: string;
phone?: string;
}
export interface TestRole {
roleName: string;
roleKey: string;
roleSort?: string;
status?: string;
remark?: string;
}
export interface TestMenu {
menuName: string;
parentId: number;
orderNum: number;
menuType: string;
component?: string;
perms?: string;
status?: number;
}
type TestData = {
adminUser: TestUser;
regularUser: TestUser;
testRole: TestRole;
testMenu: TestMenu;
generateTestUser: () => TestUser;
generateTestRole: () => TestRole;
generateTestMenu: () => TestMenu;
};
export const test = base.extend<TestData>({
adminUser: async ({}, use) => {
const user: TestUser = {
username: 'admin',
password: 'password',
email: 'admin@example.com',
phone: '13800138000',
};
await use(user);
},
regularUser: async ({}, use) => {
const user: TestUser = {
username: 'testuser',
password: 'Test123!@#',
email: 'testuser@example.com',
phone: '13800138001',
};
await use(user);
},
testRole: async ({}, use) => {
const role: TestRole = {
roleName: '测试角色',
roleKey: 'test_role',
roleSort: '1',
status: '1',
remark: '测试角色备注',
};
await use(role);
},
testMenu: async ({}, use) => {
const menu: TestMenu = {
menuName: '测试菜单',
parentId: 0,
orderNum: 1,
menuType: 'M',
component: 'test',
perms: 'test:view',
status: 1,
};
await use(menu);
},
generateTestUser: async ({}, use) => {
const timestamp = Date.now();
const user: TestUser = {
username: `testuser_${timestamp}`,
password: 'Test123!@#',
email: `test_${timestamp}@example.com`,
phone: `138${String(timestamp).slice(-8)}`,
};
await use(() => user);
},
generateTestRole: async ({}, use) => {
const timestamp = Date.now();
const role: TestRole = {
roleName: `测试角色_${timestamp}`,
roleKey: `test_role_${timestamp}`,
roleSort: '1',
status: '1',
remark: `测试角色备注_${timestamp}`,
};
await use(() => role);
},
generateTestMenu: async ({}, use) => {
const timestamp = Date.now();
const menu: TestMenu = {
menuName: `测试菜单_${timestamp}`,
parentId: 0,
orderNum: 1,
menuType: 'M',
component: `test_${timestamp}`,
perms: `test:view_${timestamp}`,
status: 1,
};
await use(() => menu);
},
});
@@ -0,0 +1 @@
This is a test file for E2E testing purposes.
+567
View File
@@ -0,0 +1,567 @@
import { FullConfig } from '@playwright/test';
import { spawn, ChildProcess } from 'child_process';
import path from 'path';
import { fileURLToPath } from 'url';
import { existsSync } from 'fs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
let backendProcess: ChildProcess | null = null;
let gatewayProcess: ChildProcess | null = null;
let healthCheckInterval: NodeJS.Timeout | null = null;
function renderProgressBar(label: string, current: number, total: number, width: number = 30): void {
const ratio = Math.min(current / total, 1);
const filled = Math.round(ratio * width);
const empty = width - filled;
const bar = '█'.repeat(filled) + '░'.repeat(empty);
const percent = (ratio * 100).toFixed(0);
process.stdout.write(`\r ${label} [${bar}] ${percent}% (${current}/${total}s)`);
if (ratio >= 1) {
process.stdout.write('\n');
}
}
async function checkBackendHealth(): Promise<boolean> {
try {
const response = await fetch('http://localhost:8084/actuator/health', {
signal: AbortSignal.timeout(5000)
} as any);
if (response.ok) {
const data = await response.json();
return data.status === 'UP';
}
return false;
} catch (error) {
return false;
}
}
async function checkGatewayHealth(): Promise<boolean> {
try {
const response = await fetch('http://localhost:8080/actuator/health', {
signal: AbortSignal.timeout(5000)
} as any);
if (response.ok) {
const data = await response.json();
return data.status === 'UP';
}
return false;
} catch (error) {
return false;
}
}
async function checkFrontendHealth(): Promise<boolean> {
try {
const response = await fetch('http://localhost:3002', {
signal: AbortSignal.timeout(5000)
} as any);
return response.ok;
} catch (error) {
return false;
}
}
function startHealthMonitoring() {
if (healthCheckInterval) {
clearInterval(healthCheckInterval);
}
healthCheckInterval = setInterval(async () => {
const backendHealthy = await checkBackendHealth();
const gatewayHealthy = await checkGatewayHealth();
const frontendHealthy = await checkFrontendHealth();
if (!backendHealthy) {
console.error('⚠️ 后端服务健康检查失败!');
}
if (!gatewayHealthy) {
console.error('⚠️ 网关服务健康检查失败!');
}
if (!frontendHealthy) {
console.error('⚠️ 前端服务健康检查失败!');
}
}, 30000);
}
function stopHealthMonitoring() {
if (healthCheckInterval) {
clearInterval(healthCheckInterval);
healthCheckInterval = null;
}
}
async function globalSetup(config: FullConfig) {
console.log('🚀 开始全局测试环境设置...');
process.env.NODE_ENV = 'test';
process.env.PLAYWRIGHT_HEADLESS = 'false';
const backendAlreadyRunning = await checkBackendHealth();
if (backendAlreadyRunning) {
console.log('✅ 后端服务已在运行,跳过启动');
} else {
const backendDir = path.resolve(__dirname, '../../novalon-manage-api/manage-app');
const jarFile = path.join(backendDir, 'target/manage-app-1.0.0.jar');
let backendCommand: string;
let backendArgs: string[];
if (existsSync(jarFile)) {
console.log('📦 使用JAR文件启动后端服务...');
console.log(` JAR文件: ${jarFile}`);
backendCommand = 'java';
backendArgs = [
'-jar',
jarFile,
'--spring.profiles.active=test',
'-Xms256m',
'-Xmx512m'
];
} else {
console.log('📦 使用Maven启动后端服务...');
console.log(' 提示: 运行 "mvn clean package -DskipTests" 构建JAR文件以获得更快的启动速度');
backendCommand = 'mvn';
backendArgs = ['spring-boot:run', '-Dspring-boot.run.profiles=test'];
}
console.log(` 目录: ${backendDir}`);
console.log(` 命令: ${backendCommand} ${backendArgs.join(' ')}`);
backendProcess = spawn(backendCommand, backendArgs, {
cwd: backendDir,
stdio: 'pipe',
shell: true,
detached: false,
env: { ...process.env, SPRING_PROFILES_ACTIVE: 'test' }
});
if (backendProcess.stdout) {
backendProcess.stdout.on('data', (data) => {
const output = data.toString();
if (output.includes('Started ManageApplication') || output.includes('Tomcat started on port')) {
console.log('✅ 后端服务启动成功');
}
});
}
if (backendProcess.stderr) {
backendProcess.stderr.on('data', (data) => {
const output = data.toString();
if (output.includes('ERROR') || output.includes('Exception')) {
console.error('❌ 后端服务启动错误:', output);
}
});
}
backendProcess.on('error', (error) => {
console.error('❌ 后端服务启动失败:', error);
});
backendProcess.on('exit', (code, signal) => {
if (code !== 0 && code !== null) {
console.error(`❌ 后端服务异常退出,退出码: ${code}, 信号: ${signal}`);
}
});
console.log('⏳ 等待后端服务就绪...');
await waitForBackendReady();
}
const gatewayAlreadyRunning = await checkGatewayHealth();
if (gatewayAlreadyRunning) {
console.log('✅ 网关服务已在运行,跳过启动');
} else {
const gatewayDir = path.resolve(__dirname, '../../novalon-manage-api/manage-gateway');
const gatewayJarFile = path.join(gatewayDir, 'target/manage-gateway-1.0.0.jar');
let gatewayCommand: string;
let gatewayArgs: string[];
if (existsSync(gatewayJarFile)) {
console.log('🚪 使用JAR文件启动网关服务...');
console.log(` JAR文件: ${gatewayJarFile}`);
gatewayCommand = 'java';
gatewayArgs = [
'-jar',
gatewayJarFile,
'--spring.profiles.active=dev',
'-Xms128m',
'-Xmx256m'
];
} else {
console.log('🚪 使用Maven启动网关服务...');
console.log(' 提示: 运行 "mvn clean package -DskipTests" 构建JAR文件以获得更快的启动速度');
gatewayCommand = 'mvn';
gatewayArgs = ['spring-boot:run', '-Dspring-boot.run.profiles=dev'];
}
console.log(` 目录: ${gatewayDir}`);
console.log(` 命令: ${gatewayCommand} ${gatewayArgs.join(' ')}`);
gatewayProcess = spawn(gatewayCommand, gatewayArgs, {
cwd: gatewayDir,
stdio: 'pipe',
shell: true,
detached: false,
env: { ...process.env, SPRING_PROFILES_ACTIVE: 'dev' }
});
if (gatewayProcess.stdout) {
gatewayProcess.stdout.on('data', (data) => {
const output = data.toString();
if (output.includes('Started GatewayApplication') || output.includes('Netty started on port')) {
console.log('✅ 网关服务启动成功');
}
});
}
if (gatewayProcess.stderr) {
gatewayProcess.stderr.on('data', (data) => {
const output = data.toString();
if (output.includes('ERROR') || output.includes('Exception')) {
console.error('❌ 网关服务启动错误:', output);
}
});
}
gatewayProcess.on('error', (error) => {
console.error('❌ 网关服务启动失败:', error);
});
gatewayProcess.on('exit', (code, signal) => {
if (code !== 0 && code !== null) {
console.error(`❌ 网关服务异常退出,退出码: ${code}, 信号: ${signal}`);
}
});
console.log('⏳ 等待网关服务就绪...');
await waitForGatewayReady();
}
console.log('🔍 验证所有服务连通性...');
await verifyAllServices();
console.log('🧹 清理测试数据...');
await cleanupTestData();
startHealthMonitoring();
console.log('✅ 全局测试环境设置完成');
}
async function verifyAllServices(): Promise<void> {
console.log(' 验证后端服务...');
const backendOk = await checkBackendHealth();
if (!backendOk) {
throw new Error('❌ 后端服务验证失败');
}
console.log(' ✅ 后端服务正常');
console.log(' 验证网关服务...');
const gatewayOk = await checkGatewayHealth();
if (!gatewayOk) {
throw new Error('❌ 网关服务验证失败');
}
console.log(' ✅ 网关服务正常');
console.log(' 验证网关到后端的连通性...');
try {
const response = await fetch('http://localhost:8080/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: 'admin', password: 'Test@123' }),
signal: AbortSignal.timeout(10000) as any
});
if (!response.ok) {
console.log(`⚠️ 网关到后端连通性验证失败,状态码: ${response.status},跳过验证继续测试`);
// 跳过验证,继续测试
return;
}
const data = await response.json();
if (!data.token) {
console.log('⚠️ 网关到后端连通性验证失败,未返回token,跳过验证继续测试');
// 跳过验证,继续测试
return;
}
console.log(' ✅ 网关到后端连通性正常');
} catch (error) {
console.log(`⚠️ 网关到后端连通性验证失败: ${error},跳过验证继续测试`);
// 跳过验证,继续测试
}
console.log('✅ 所有服务验证通过');
}
async function waitForBackendReady(): Promise<void> {
const maxRetries = 90;
const retryInterval = 1000;
for (let i = 0; i < maxRetries; i++) {
renderProgressBar('⏳ 后端服务启动中', i, maxRetries);
try {
const response = await fetch('http://localhost:8084/actuator/health', {
signal: AbortSignal.timeout(5000) as any
});
if (response.ok) {
const data = await response.json();
if (data.status === 'UP') {
process.stdout.write('\r' + ' '.repeat(80) + '\r');
console.log(`✅ 后端服务健康检查通过 (尝试 ${i + 1}/${maxRetries})`);
try {
const loginTest = await fetch('http://localhost:8084/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: 'admin', password: 'Test@123' }),
signal: AbortSignal.timeout(10000) as any
});
if (loginTest.ok) {
console.log('✅ 后端服务连通性验证通过(登录API可用)');
return;
} else {
console.log(`⚠️ 后端服务连通性验证失败,状态码: ${loginTest.status}`);
}
} catch (error) {
console.log('⚠️ 后端服务连通性验证失败,继续等待...');
}
}
}
} catch (error) {
// 服务还未就绪,继续等待
}
if (i < maxRetries - 1) {
await new Promise(resolve => setTimeout(resolve, retryInterval));
}
}
throw new Error('❌ 后端服务启动超时');
}
async function waitForGatewayReady(): Promise<void> {
const maxRetries = 90;
const retryInterval = 1000;
for (let i = 0; i < maxRetries; i++) {
renderProgressBar('⏳ 网关服务启动中', i, maxRetries);
try {
const response = await fetch('http://localhost:8080/actuator/health', {
signal: AbortSignal.timeout(5000) as any
});
if (response.ok) {
const data = await response.json();
if (data.status === 'UP') {
process.stdout.write('\r' + ' '.repeat(80) + '\r');
console.log(`✅ 网关服务健康检查通过 (尝试 ${i + 1}/${maxRetries})`);
try {
const loginTest = await fetch('http://localhost:8080/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: 'admin', password: 'Test@123' }),
signal: AbortSignal.timeout(10000) as any
});
if (loginTest.ok) {
console.log('✅ 网关服务连通性验证通过(登录API可用)');
return;
} else {
console.log(`⚠️ 网关服务连通性验证失败,状态码: ${loginTest.status}`);
}
} catch (error) {
console.log('⚠️ 网关服务连通性验证失败,继续等待...');
}
}
}
} catch (error) {
// 服务还未就绪,继续等待
}
if (i < maxRetries - 1) {
await new Promise(resolve => setTimeout(resolve, retryInterval));
}
}
throw new Error('❌ 网关服务启动超时');
}
async function waitForFrontendReady(): Promise<void> {
const maxRetries = 90;
const retryInterval = 1000;
for (let i = 0; i < maxRetries; i++) {
renderProgressBar('⏳ 前端服务启动中', i, maxRetries);
try {
const response = await fetch('http://localhost:3002', {
signal: AbortSignal.timeout(5000) as any
});
if (response.ok) {
process.stdout.write('\r' + ' '.repeat(80) + '\r');
console.log(`✅ 前端服务健康检查通过 (尝试 ${i + 1}/${maxRetries})`);
return;
}
} catch (error) {
// 服务还未就绪,继续等待
}
if (i < maxRetries - 1) {
await new Promise(resolve => setTimeout(resolve, retryInterval));
}
}
throw new Error('❌ 前端服务启动超时');
}
async function cleanupTestData(): Promise<void> {
try {
// 登录获取token(通过网关)
const loginResponse = await fetch('http://localhost:8080/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
username: 'admin',
password: 'Test@123'
})
});
if (!loginResponse.ok) {
console.log('⚠️ 无法登录,跳过数据清理');
return;
}
const loginData = await loginResponse.json();
const token = loginData.token;
// 获取所有用户
const usersResponse = await fetch('http://localhost:8080/api/users', {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (usersResponse.ok) {
const users = await usersResponse.json();
// 删除测试创建的用户(保留ID 1-10的初始用户)
for (const user of users) {
if (user.id > 10) {
try {
await fetch(`http://localhost:8080/api/users/${user.id}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`
}
});
console.log(` 删除用户: ${user.username}`);
} catch (error) {
console.log(` ⚠️ 无法删除用户 ${user.username}`);
}
}
}
}
// 获取所有角色
const rolesResponse = await fetch('http://localhost:8080/api/roles', {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (rolesResponse.ok) {
const roles = await rolesResponse.json();
// 删除测试创建的角色(保留ID 1-4的初始角色)
for (const role of roles) {
if (role.id > 4) {
try {
await fetch(`http://localhost:8080/api/roles/${role.id}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`
}
});
console.log(` 删除角色: ${role.roleName}`);
} catch (error) {
console.log(` ⚠️ 无法删除角色 ${role.roleName}`);
}
}
}
}
console.log('✅ 测试数据清理完成');
} catch (error) {
console.log('⚠️ 数据清理失败,继续执行测试');
console.error('清理错误:', error);
}
}
async function globalTeardown() {
console.log('🧹 开始全局测试环境清理...');
stopHealthMonitoring();
if (backendProcess) {
console.log('🛑 停止后端服务...');
backendProcess.kill('SIGTERM');
await new Promise<void>((resolve) => {
if (backendProcess) {
backendProcess.on('exit', () => {
console.log('✅ 后端服务已停止');
resolve();
});
setTimeout(() => {
if (backendProcess) {
backendProcess.kill('SIGKILL');
console.log('⚠️ 强制停止后端服务');
resolve();
}
}, 10000);
} else {
resolve();
}
});
}
if (gatewayProcess) {
console.log('🛑 停止网关服务...');
gatewayProcess.kill('SIGTERM');
await new Promise<void>((resolve) => {
if (gatewayProcess) {
gatewayProcess.on('exit', () => {
console.log('✅ 网关服务已停止');
resolve();
});
setTimeout(() => {
if (gatewayProcess) {
gatewayProcess.kill('SIGKILL');
console.log('⚠️ 强制停止网关服务');
resolve();
}
}, 10000);
} else {
resolve();
}
});
}
console.log('✅ 全局测试环境清理完成');
}
export default globalSetup;
export { globalTeardown };
+3
View File
@@ -0,0 +1,3 @@
import { globalTeardown } from './global-setup';
export default globalTeardown;
@@ -0,0 +1,194 @@
import { Page } from '@playwright/test';
export class TestDataManager {
private readonly page: Page;
private testData: Map<string, any> = new Map();
private cleanupCallbacks: Array<() => Promise<void>> = [];
constructor(page: Page) {
this.page = page;
}
generateUniquePrefix(prefix: string): string {
const timestamp = Date.now();
const random = Math.random().toString(36).substring(2, 8);
return `${prefix}_${timestamp}_${random}`;
}
generateTestEmail(prefix: string = 'test'): string {
const uniquePart = this.generateUniquePrefix(prefix);
return `${uniquePart}@novalon-test.com`;
}
generateTestUsername(prefix: string = 'testuser'): string {
return this.generateUniquePrefix(prefix);
}
generateTestFileName(prefix: string = 'testfile'): string {
const uniquePart = this.generateUniquePrefix(prefix);
return `${uniquePart}.txt`;
}
generateTestConfigName(prefix: string = 'testconfig'): string {
return this.generateUniquePrefix(prefix);
}
generateTestDictName(prefix: string = 'testdict'): string {
return this.generateUniquePrefix(prefix);
}
generateTestNotificationTitle(prefix: string = 'testnotify'): string {
return this.generateUniquePrefix(prefix);
}
generateTestContent(prefix: string = 'content'): string {
const timestamp = new Date().toLocaleString('zh-CN');
return `测试内容_${prefix}_${timestamp}`;
}
set(key: string, value: any): void {
this.testData.set(key, value);
}
get(key: string): any {
return this.testData.get(key);
}
has(key: string): boolean {
return this.testData.has(key);
}
remove(key: string): boolean {
return this.testData.delete(key);
}
clear(): void {
this.testData.clear();
}
registerCleanup(callback: () => Promise<void>): void {
this.cleanupCallbacks.push(callback);
}
async cleanup(): Promise<void> {
console.log('Starting test data cleanup...');
for (const callback of this.cleanupCallbacks) {
try {
await callback();
} catch (error) {
console.error('Cleanup callback failed:', error);
}
}
this.cleanupCallbacks = [];
this.testData.clear();
console.log('Test data cleanup completed');
}
async cleanupTestConfigs(): Promise<void> {
console.log('Cleaning up test configurations...');
try {
await this.page.goto('/system/config');
await this.page.waitForLoadState('networkidle');
const testRows = this.page.locator('.el-table__row').filter({ hasText: 'test' });
const count = await testRows.count();
for (let i = 0; i < count; i++) {
const row = testRows.nth(i);
const deleteButton = row.locator('.el-button--danger').first();
if (await deleteButton.isVisible()) {
await deleteButton.click();
const confirmButton = this.page.getByRole('button', { name: '确定' });
await confirmButton.click();
await this.page.waitForTimeout(500);
}
}
console.log(`Cleaned up ${count} test configurations`);
} catch (error) {
console.error('Failed to cleanup test configurations:', error);
}
}
async cleanupTestNotifications(): Promise<void> {
console.log('Cleaning up test notifications...');
try {
await this.page.goto('/system/notice');
await this.page.waitForLoadState('networkidle');
const testRows = this.page.locator('.el-table__row').filter({ hasText: '测试通知' });
const count = await testRows.count();
for (let i = 0; i < count; i++) {
const row = testRows.nth(i);
const deleteButton = row.locator('.el-button--danger').first();
if (await deleteButton.isVisible()) {
await deleteButton.click();
const confirmButton = this.page.getByRole('button', { name: '确定' });
await confirmButton.click();
await this.page.waitForTimeout(500);
}
}
console.log(`Cleaned up ${count} test notifications`);
} catch (error) {
console.error('Failed to cleanup test notifications:', error);
}
}
async cleanupTestFiles(): Promise<void> {
console.log('Cleaning up test files...');
try {
await this.page.goto('/files');
await this.page.waitForLoadState('networkidle');
const testRows = this.page.locator('.el-table__row').filter({ hasText: 'test' });
const count = await testRows.count();
for (let i = 0; i < count; i++) {
const row = testRows.nth(i);
const deleteButton = row.locator('.el-button--danger').first();
if (await deleteButton.isVisible()) {
await deleteButton.click();
const confirmButton = this.page.getByRole('button', { name: '确定' });
await confirmButton.click();
await this.page.waitForTimeout(500);
}
}
console.log(`Cleaned up ${count} test files`);
} catch (error) {
console.error('Failed to cleanup test files:', error);
}
}
createTestFileContent(fileName: string): string {
const timestamp = new Date().toISOString();
return `Test file created at ${timestamp}\nFilename: ${fileName}\nThis is a test file for E2E testing purposes.`;
}
async setupTestData(): Promise<void> {
console.log('Setting up test data...');
this.set('setupTime', new Date().toISOString());
}
getTestSummary(): Record<string, any> {
return {
testDataCount: this.testData.size,
cleanupCallbacksCount: this.cleanupCallbacks.length,
testDataKeys: Array.from(this.testData.keys()),
setupTime: this.get('setupTime'),
};
}
}
@@ -0,0 +1,192 @@
import { Page, expect } from '@playwright/test';
export class TestStabilityHelper {
private readonly page: Page;
private readonly maxRetries: number = 3;
private readonly retryDelay: number = 1000;
constructor(page: Page) {
this.page = page;
}
async waitForNetworkIdle(timeout: number = 30000): Promise<void> {
try {
await this.page.waitForLoadState('networkidle', { timeout });
} catch (error) {
console.log('Network idle timeout, continuing anyway');
}
}
async waitForElementVisible(selector: string, timeout: number = 10000): Promise<void> {
await this.retry(async () => {
const element = this.page.locator(selector);
await expect(element).toBeVisible({ timeout });
});
}
async safeClick(selector: string): Promise<void> {
await this.retry(async () => {
const element = this.page.locator(selector);
await element.waitFor({ state: 'visible', timeout: 10000 });
await element.click({ timeout: 5000 });
});
}
async safeFill(selector: string, value: string): Promise<void> {
await this.retry(async () => {
const element = this.page.locator(selector);
await element.waitFor({ state: 'visible', timeout: 10000 });
await element.clear();
await element.fill(value);
});
}
async safeSelect(selector: string, value: string): Promise<void> {
await this.retry(async () => {
const element = this.page.locator(selector);
await element.waitFor({ state: 'visible', timeout: 10000 });
await element.selectOption(value);
});
}
async waitForURL(urlPattern: RegExp | string, timeout: number = 30000): Promise<void> {
await this.retry(async () => {
await this.page.waitForURL(urlPattern, { timeout });
});
}
async handleModal(): Promise<void> {
try {
const modal = this.page.locator('.el-dialog, .el-message-box');
const isVisible = await modal.isVisible({ timeout: 2000 });
if (isVisible) {
const confirmButton = modal.locator('.el-button--primary').first();
const cancelButton = modal.locator('.el-button--default').first();
if (await confirmButton.isVisible({ timeout: 1000 })) {
await confirmButton.click();
} else if (await cancelButton.isVisible({ timeout: 1000 })) {
await cancelButton.click();
}
}
} catch (error) {
console.log('No modal found or modal handling failed');
}
}
async waitForLoadingComplete(): Promise<void> {
try {
const loading = this.page.locator('.el-loading-mask, .loading');
await loading.waitFor({ state: 'hidden', timeout: 10000 });
} catch (error) {
console.log('Loading element not found or timeout');
}
}
async safeNavigate(url: string): Promise<void> {
await this.retry(async () => {
await this.page.goto(url, { waitUntil: 'networkidle', timeout: 30000 });
});
}
async waitForTableData(tableSelector: string, minRows: number = 1): Promise<void> {
await this.retry(async () => {
const table = this.page.locator(tableSelector);
await expect(table).toBeVisible({ timeout: 10000 });
const rows = table.locator('.el-table__row');
const rowCount = await rows.count();
expect(rowCount).toBeGreaterThanOrEqual(minRows);
});
}
async safeScrollIntoView(selector: string): Promise<void> {
const element = this.page.locator(selector);
await element.scrollIntoViewIfNeeded();
await this.page.waitForTimeout(500);
}
async clearLocalStorage(): Promise<void> {
await this.page.evaluate(() => {
localStorage.clear();
});
}
async clearSessionStorage(): Promise<void> {
await this.page.evaluate(() => {
sessionStorage.clear();
});
}
async takeScreenshot(name: string): Promise<void> {
await this.page.screenshot({ path: `test-results/screenshots/${name}.png`, fullPage: true });
}
async getErrorMessage(): Promise<string | null> {
try {
const errorElement = this.page.locator('.el-message--error, .error-message');
const isVisible = await errorElement.isVisible({ timeout: 2000 });
if (isVisible) {
return await errorElement.textContent();
}
return null;
} catch (error) {
return null;
}
}
async hasErrorMessage(): Promise<boolean> {
const errorMessage = await this.getErrorMessage();
return errorMessage !== null;
}
private async retry<T>(fn: () => Promise<T>): Promise<T> {
let lastError: Error | undefined;
for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error as Error;
console.log(`Attempt ${attempt} failed, retrying...`, error);
if (attempt < this.maxRetries) {
await this.page.waitForTimeout(this.retryDelay);
}
}
}
throw lastError || new Error('All retry attempts failed');
}
async waitForElementNotVisible(selector: string, timeout: number = 10000): Promise<void> {
await this.retry(async () => {
const element = this.page.locator(selector);
await expect(element).not.toBeVisible({ timeout });
});
}
async safeHover(selector: string): Promise<void> {
await this.retry(async () => {
const element = this.page.locator(selector);
await element.waitFor({ state: 'visible', timeout: 10000 });
await element.hover({ timeout: 5000 });
});
}
async waitForText(selector: string, text: string, timeout: number = 10000): Promise<void> {
await this.retry(async () => {
const element = this.page.locator(selector);
await expect(element).toContainText(text, { timeout });
});
}
async waitForTextNotPresent(selector: string, text: string, timeout: number = 10000): Promise<void> {
await this.retry(async () => {
const element = this.page.locator(selector);
await expect(element).not.toContainText(text, { timeout });
});
}
}
+23
View File
@@ -0,0 +1,23 @@
import { Page } from '@playwright/test';
export async function loginAsAdmin(page: Page) {
await page.goto('/login');
await page.waitForLoadState('networkidle');
await page.locator('input[placeholder*="用户名"]').fill('admin');
await page.locator('input[placeholder*="密码"]').fill('Test@123');
await page.locator('button:has-text("登录")').click();
await page.waitForURL('**/dashboard', { timeout: 30000 });
const token = await page.evaluate(() => {
return localStorage.getItem('token') || '';
});
return token;
}
export async function saveAuthState(page: Page) {
const storage = await page.context().storageState();
return storage;
}
@@ -0,0 +1,203 @@
import { test, expect } from '@playwright/test';
test.describe('管理员完整工作流', () => {
test.describe.configure({ mode: 'serial' });
const timestamp = Date.now();
const roleName = `测试角色_${timestamp}`;
const roleKey = `test_role_${timestamp}`;
const username = `testuser_${timestamp}`;
test('创建角色并分配权限', async ({ page }) => {
await test.step('导航到角色管理', async () => {
await page.goto('/dashboard');
await page.waitForLoadState('networkidle');
await page.locator('text=系统管理').click();
await page.waitForTimeout(500);
await page.locator('text=角色管理').click();
await page.waitForLoadState('networkidle');
await expect(page).toHaveURL(/.*roles/, { timeout: 10000 });
});
await test.step('点击创建角色按钮', async () => {
await page.locator('button:has-text("新增角色")').click();
await page.waitForSelector('.el-dialog', { state: 'visible', timeout: 5000 });
});
await test.step('填写角色信息', async () => {
const dialog = page.locator('.el-dialog');
await dialog.locator('input').first().fill(roleName);
await dialog.locator('input').nth(1).fill(roleKey);
await dialog.locator('.el-input-number .el-input__inner').fill('99');
});
await test.step('提交表单', async () => {
await page.locator('.el-dialog button:has-text("确定")').click();
await page.waitForSelector('.el-dialog', { state: 'hidden', timeout: 10000 });
await expect(page.locator('.el-message--success')).toBeVisible({ timeout: 5000 });
});
});
test('创建用户并分配角色', async ({ page }) => {
await test.step('导航到用户管理', async () => {
await page.goto('/dashboard');
await page.waitForLoadState('networkidle');
await page.locator('text=系统管理').click();
await page.waitForTimeout(500);
await page.locator('text=用户管理').click();
await page.waitForLoadState('networkidle');
await expect(page).toHaveURL(/.*users/, { timeout: 10000 });
});
await test.step('点击创建用户按钮', async () => {
await page.locator('button:has-text("新增用户")').click();
await page.waitForSelector('.el-dialog', { state: 'visible', timeout: 5000 });
});
await test.step('填写用户信息', async () => {
const dialog = page.locator('.el-dialog');
await dialog.locator('input').first().fill(username);
await dialog.locator('input[type="password"]').fill('Test@123');
await dialog.locator('input').nth(2).fill(`测试用户${timestamp}`);
await dialog.locator('input').nth(3).fill(`test_${timestamp}@example.com`);
await dialog.locator('input').nth(4).fill('13800138000');
});
await test.step('提交表单', async () => {
await page.locator('.el-dialog button:has-text("确定")').click();
await page.waitForSelector('.el-dialog', { state: 'hidden', timeout: 10000 });
await expect(page.locator('.el-message--success')).toBeVisible({ timeout: 5000 });
});
await test.step('搜索新创建的用户', async () => {
await page.waitForTimeout(1000);
const searchInput = page.locator('input[placeholder*="搜索"]');
await searchInput.waitFor({ state: 'visible', timeout: 5000 });
await searchInput.fill(username);
await page.locator('button:has-text("搜索")').click();
await page.waitForLoadState('networkidle');
await page.waitForTimeout(2000);
});
await test.step('分配角色', async () => {
const userRow = page.locator(`tr:has-text("${username}")`);
await expect(userRow).toBeVisible({ timeout: 10000 });
await userRow.locator('button:has-text("分配角色")').click();
await page.waitForSelector('.el-dialog:has-text("分配角色")', { state: 'visible', timeout: 5000 });
const transfer = page.locator('.el-transfer');
const leftPanel = transfer.locator('.el-transfer-panel').first();
const rightPanel = transfer.locator('.el-transfer-panel').last();
const rightPanelItems = await rightPanel.locator('.el-checkbox').all();
let hasSuperAdminRole = false;
for (const item of rightPanelItems) {
const text = await item.textContent();
if (text?.includes('超级管理员')) {
hasSuperAdminRole = true;
break;
}
}
if (!hasSuperAdminRole) {
const leftPanelItems = await leftPanel.locator('.el-checkbox').all();
let superAdminCheckbox = null;
for (const item of leftPanelItems) {
const text = await item.textContent();
if (text?.includes('超级管理员')) {
superAdminCheckbox = item;
break;
}
}
if (superAdminCheckbox) {
const isChecked = await superAdminCheckbox.locator('input').isChecked();
if (!isChecked) {
await superAdminCheckbox.click();
await page.waitForTimeout(500);
}
const moveToRightButton = transfer.locator('.el-transfer__buttons button').nth(1);
if (await moveToRightButton.isEnabled()) {
await moveToRightButton.click();
await page.waitForTimeout(500);
}
}
}
await page.locator('.el-dialog:has-text("分配角色") button:has-text("确定")').click();
await page.waitForSelector('.el-dialog:has-text("分配角色")', { state: 'hidden', timeout: 10000 });
await expect(page.locator('.el-message--success').last()).toBeVisible({ timeout: 5000 });
});
});
test('验证新用户登录', async ({ page }) => {
await test.step('管理员登出', async () => {
await page.goto('/dashboard');
await page.waitForLoadState('networkidle');
const avatarButton = page.locator('.el-avatar').first();
await avatarButton.click({ timeout: 10000 });
await page.waitForTimeout(500);
await page.locator('text=退出登录').click();
await page.waitForURL(/.*login/, { timeout: 10000 });
});
await test.step('新用户登录', async () => {
await page.goto('/login');
await page.locator('input[placeholder*="用户名"]').fill(username);
await page.locator('input[placeholder*="密码"]').fill('Test@123');
await page.locator('button:has-text("登录")').click();
await page.waitForURL('**/dashboard', { timeout: 30000 });
});
await test.step('验证用户已登录', async () => {
await expect(page).toHaveURL(/.*dashboard/);
});
});
test.skip('清理测试数据', async ({ page }) => {
await test.step('管理员重新登录', async () => {
await page.goto('/dashboard');
await page.waitForLoadState('networkidle');
const avatarButton = page.locator('.el-avatar').first();
if (await avatarButton.isVisible()) {
await avatarButton.click();
await page.waitForTimeout(500);
await page.locator('text=退出登录').click();
}
await page.goto('/login');
await page.locator('input[placeholder*="用户名"]').fill('admin');
await page.locator('input[placeholder*="密码"]').fill('Test@123');
await page.locator('button:has-text("登录")').click();
await page.waitForURL('**/dashboard');
});
await test.step('删除测试用户', async () => {
await page.goto('/users');
await page.locator('input[placeholder*="搜索"]').fill(username);
await page.locator('button:has-text("搜索")').click();
await page.waitForTimeout(1000);
await page.locator('button:has-text("删除")').first().click();
await page.locator('button:has-text("确定")').click();
await expect(page.locator('.el-message--success')).toBeVisible({ timeout: 5000 });
});
await test.step('删除测试角色', async () => {
await page.goto('/roles');
await page.locator('input[placeholder*="搜索"]').fill(roleName);
await page.locator('button:has-text("搜索")').click();
await page.waitForTimeout(1000);
await page.locator('button:has-text("删除")').first().click();
await page.locator('button:has-text("确定")').click();
await expect(page.locator('.el-message--success')).toBeVisible({ timeout: 5000 });
});
});
});
@@ -0,0 +1,106 @@
import { test, expect } from '@playwright/test';
test.describe('审计工作流', () => {
test('执行操作并查看操作日志', async ({ page }) => {
await test.step('执行用户管理操作', async () => {
await page.goto('/users');
await page.waitForTimeout(1000);
});
await test.step('执行角色管理操作', async () => {
await page.goto('/roles');
await page.waitForTimeout(1000);
});
await test.step('执行菜单管理操作', async () => {
await page.goto('/menus');
await page.waitForTimeout(1000);
});
await test.step('导航到操作日志', async () => {
await page.goto('/dashboard');
await page.waitForLoadState('networkidle');
await page.locator('text=审计日志').click();
await page.waitForTimeout(1000);
await page.locator('.el-menu-item:has-text("操作日志")').click();
await page.waitForLoadState('networkidle');
await page.waitForTimeout(1000);
await expect(page).toHaveURL(/.*oplog/, { timeout: 10000 });
await expect(page.locator('.el-table')).toBeVisible({ timeout: 10000 });
});
await test.step('验证操作日志记录', async () => {
await page.waitForTimeout(2000);
const logContent = await page.locator('.el-table').textContent();
expect(logContent).toMatch(/用户管理|角色管理|菜单管理/);
});
});
test('查看登录日志', async ({ page }) => {
await test.step('导航到登录日志', async () => {
await page.goto('/dashboard');
await page.waitForLoadState('networkidle');
await page.locator('text=审计日志').click();
await page.waitForTimeout(1000);
await page.locator('.el-menu-item:has-text("登录日志")').click();
await page.waitForLoadState('networkidle');
await page.waitForTimeout(1000);
await expect(page).toHaveURL(/.*loginlog/, { timeout: 10000 });
});
await test.step('验证登录日志显示', async () => {
await expect(page.locator('.el-table')).toBeVisible({ timeout: 10000 });
const logContent = await page.locator('.el-table').textContent();
expect(logContent).toBeTruthy();
expect(logContent.length).toBeGreaterThan(0);
});
});
test('搜索和筛选日志', async ({ page }) => {
await test.step('导航到操作日志', async () => {
await page.goto('/dashboard');
await page.waitForLoadState('networkidle');
await page.locator('text=审计日志').click();
await page.waitForTimeout(1000);
await page.locator('.el-menu-item:has-text("操作日志")').click();
await page.waitForLoadState('networkidle');
await page.waitForTimeout(1000);
await expect(page.locator('.el-table')).toBeVisible({ timeout: 10000 });
});
await test.step('按模块筛选', async () => {
const moduleSelect = page.locator('.el-select:has-text("模块")');
if (await moduleSelect.isVisible()) {
await moduleSelect.click();
await page.locator('.el-select-dropdown__item:has-text("用户管理")').click();
await page.waitForTimeout(1000);
}
});
await test.step('按时间范围筛选', async () => {
const dateRangePicker = page.locator('.el-date-editor');
if (await dateRangePicker.isVisible()) {
await dateRangePicker.click();
await page.waitForTimeout(500);
}
});
await test.step('搜索特定内容', async () => {
const searchInput = page.locator('input[placeholder*="搜索"]');
if (await searchInput.isVisible()) {
await searchInput.fill('admin');
await page.locator('button:has-text("搜索")').click();
await page.waitForTimeout(1000);
}
});
});
});
@@ -0,0 +1,140 @@
import { test, expect } from '@playwright/test';
import { SystemConfigPage } from '../pages/SystemConfigPage';
test.describe('系统配置工作流', () => {
let configPage: SystemConfigPage;
const timestamp = Date.now();
const configKey = `test_config_${timestamp}`;
const configName = `测试配置_${timestamp}`;
const configValue = `测试值_${timestamp}`;
test.beforeEach(async ({ page }) => {
configPage = new SystemConfigPage(page);
});
test('查看系统配置列表', async ({ page }) => {
await test.step('导航到系统配置页面', async () => {
await configPage.goto();
});
await test.step('验证表格显示', async () => {
await expect(configPage.table).toBeVisible({ timeout: 10000 });
});
await test.step('验证数据加载', async () => {
const rowCount = await configPage.getTableRowCount();
console.log(`系统配置列表包含 ${rowCount} 条记录`);
expect(rowCount).toBeGreaterThanOrEqual(0);
});
});
test('新增系统配置', async ({ page }) => {
await test.step('导航到系统配置页面', async () => {
await configPage.goto();
});
await test.step('点击新增配置按钮', async () => {
await configPage.addButton.click();
await configPage.dialog.waitFor({ state: 'visible', timeout: 5000 });
});
await test.step('填写配置表单', async () => {
await configPage.configNameInput.fill(configName);
await configPage.configKeyInput.fill(configKey);
await configPage.configValueInput.fill(configValue);
});
await test.step('提交表单', async () => {
await configPage.saveButton.click();
await page.waitForLoadState('networkidle');
});
await test.step('验证创建成功', async () => {
await expect(configPage.dialog).not.toBeVisible({ timeout: 5000 });
console.log(`配置 ${configName} 创建完成`);
});
});
test('编辑系统配置', async ({ page }) => {
await test.step('导航到系统配置页面', async () => {
await configPage.goto();
});
await test.step('等待数据加载', async () => {
await expect(configPage.table).toBeVisible({ timeout: 10000 });
});
await test.step('点击编辑按钮', async () => {
const rows = await configPage.getTableRowCount();
if (rows > 0) {
const firstRow = configPage.table.locator('tr').first();
const editBtn = firstRow.getByRole('button', { name: '编辑' });
if (await editBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
await editBtn.click();
await configPage.dialog.waitFor({ state: 'visible', timeout: 5000 });
await test.step('修改配置值', async () => {
const newValue = `更新值_${timestamp}`;
await configPage.configValueInput.clear();
await configPage.configValueInput.fill(newValue);
});
await test.step('提交表单', async () => {
await configPage.saveButton.click();
await page.waitForLoadState('networkidle');
});
await test.step('验证更新成功', async () => {
await expect(configPage.dialog).not.toBeVisible({ timeout: 5000 });
console.log(`配置已更新`);
});
} else {
console.log('未找到编辑按钮,跳过编辑测试');
}
} else {
console.log('当前没有配置记录,跳过编辑测试');
}
});
});
test('删除系统配置', async ({ page }) => {
await test.step('导航到系统配置页面', async () => {
await configPage.goto();
});
await test.step('等待数据加载', async () => {
await expect(configPage.table).toBeVisible({ timeout: 10000 });
});
await test.step('点击删除按钮', async () => {
const rows = await configPage.getTableRowCount();
if (rows > 0) {
const firstRow = configPage.table.locator('tr').first();
const deleteBtn = firstRow.getByRole('button', { name: '删除' });
if (await deleteBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
await deleteBtn.click();
const confirmBtn = page.locator('.el-message-box');
await confirmBtn.waitFor({ state: 'visible', timeout: 3000 });
await test.step('确认删除', async () => {
const confirmBtn = page.locator('.el-message-box').getByRole('button', { name: '确定' });
if (await confirmBtn.isVisible({ timeout: 2000 }).catch(() => false)) {
await confirmBtn.click();
await page.waitForLoadState('networkidle');
}
});
await test.step('验证删除成功', async () => {
const messageBox = page.locator('.el-message-box');
await expect(messageBox).not.toBeVisible({ timeout: 5000 });
console.log(`配置已删除`);
});
} else {
console.log('未找到删除按钮,跳过删除测试');
}
} else {
console.log('当前没有配置记录,跳过删除测试');
}
});
});
});
@@ -0,0 +1,138 @@
import { test, expect } from '@playwright/test';
import { DictionaryManagementPage } from '../pages/DictionaryManagementPage';
test.describe('字典管理工作流', () => {
let dictPage: DictionaryManagementPage;
const timestamp = Date.now();
const dictType = `test_dict_${timestamp}`;
const dictName = `测试字典_${timestamp}`;
test.beforeEach(async ({ page }) => {
dictPage = new DictionaryManagementPage(page);
});
test('查看字典列表', async ({ page }) => {
await test.step('导航到字典管理页面', async () => {
await dictPage.goto();
});
await test.step('验证表格显示', async () => {
await expect(dictPage.table).toBeVisible({ timeout: 10000 });
});
await test.step('验证数据加载', async () => {
const rowCount = await dictPage.getDictCount();
console.log(`字典列表包含 ${rowCount} 条记录`);
expect(rowCount).toBeGreaterThanOrEqual(0);
});
});
test('新增字典', async ({ page }) => {
await test.step('导航到字典管理页面', async () => {
await dictPage.goto();
});
await test.step('点击新增字典按钮', async () => {
await dictPage.createDictButton.click();
await dictPage.dialog.waitFor({ state: 'visible', timeout: 5000 });
});
await test.step('填写字典表单', async () => {
await dictPage.dictNameInput.fill(dictName);
await dictPage.dictTypeInput.fill(dictType);
});
await test.step('提交表单', async () => {
await dictPage.saveButton.click();
await page.waitForLoadState('networkidle');
});
await test.step('验证创建成功', async () => {
await expect(dictPage.dialog).not.toBeVisible({ timeout: 5000 });
console.log(`字典 ${dictName} 创建完成`);
});
});
test('编辑字典', async ({ page }) => {
await test.step('导航到字典管理页面', async () => {
await dictPage.goto();
});
await test.step('等待数据加载', async () => {
await expect(dictPage.table).toBeVisible({ timeout: 10000 });
});
await test.step('点击编辑按钮', async () => {
const rows = await dictPage.getDictCount();
if (rows > 0) {
const firstRow = dictPage.table.locator('tr').first();
const editBtn = firstRow.getByRole('button', { name: '编辑' });
if (await editBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
await editBtn.click();
await dictPage.dialog.waitFor({ state: 'visible', timeout: 5000 });
await test.step('修改字典名称', async () => {
const newName = `更新字典_${timestamp}`;
await dictPage.dictNameInput.clear();
await dictPage.dictNameInput.fill(newName);
});
await test.step('提交表单', async () => {
await dictPage.saveButton.click();
await page.waitForLoadState('networkidle');
});
await test.step('验证更新成功', async () => {
await expect(dictPage.dialog).not.toBeVisible({ timeout: 5000 });
console.log(`字典已更新`);
});
} else {
console.log('未找到编辑按钮,跳过编辑测试');
}
} else {
console.log('当前没有字典记录,跳过编辑测试');
}
});
});
test('删除字典', async ({ page }) => {
await test.step('导航到字典管理页面', async () => {
await dictPage.goto();
});
await test.step('等待数据加载', async () => {
await expect(dictPage.table).toBeVisible({ timeout: 10000 });
});
await test.step('点击删除按钮', async () => {
const rows = await dictPage.getDictCount();
if (rows > 0) {
const firstRow = dictPage.table.locator('tr').first();
const deleteBtn = firstRow.getByRole('button', { name: '删除' });
if (await deleteBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
await deleteBtn.click();
const confirmBtn = page.locator('.el-message-box');
await confirmBtn.waitFor({ state: 'visible', timeout: 3000 });
await test.step('确认删除', async () => {
const confirmBtn = page.locator('.el-message-box').getByRole('button', { name: '确定' });
if (await confirmBtn.isVisible({ timeout: 2000 }).catch(() => false)) {
await confirmBtn.click();
await page.waitForLoadState('networkidle');
}
});
await test.step('验证删除成功', async () => {
const messageBox = page.locator('.el-message-box');
await expect(messageBox).not.toBeVisible({ timeout: 5000 });
console.log(`字典已删除`);
});
} else {
console.log('未找到删除按钮,跳过删除测试');
}
} else {
console.log('当前没有字典记录,跳过删除测试');
}
});
});
});
@@ -0,0 +1,253 @@
import { test, expect } from '@playwright/test';
test.describe('数据字典管理完整工作流', () => {
test.describe.configure({ mode: 'serial' });
const timestamp = Date.now();
const dictType = `test_dict_type_${timestamp}`;
const dictName = `测试字典_${timestamp}`;
const dictCode = `test_dict_code_${timestamp}`;
test('创建字典类型', async ({ page }) => {
await test.step('导航到数据字典管理', async () => {
await page.goto('/dashboard');
await page.waitForLoadState('networkidle');
await page.locator('text=系统管理').click();
await page.waitForTimeout(500);
await page.locator('text=数据字典').click();
await page.waitForLoadState('networkidle');
await expect(page).toHaveURL(/.*dicts/, { timeout: 10000 });
});
await test.step('切换到字典类型标签页', async () => {
await page.locator('.el-tabs__item:has-text("字典类型")').click();
await page.waitForLoadState('networkidle');
});
await test.step('点击新增字典类型按钮', async () => {
await page.locator('button:has-text("新增字典类型")').click();
await page.waitForSelector('.el-dialog', { state: 'visible', timeout: 5000 });
});
await test.step('填写字典类型信息', async () => {
const dialog = page.locator('.el-dialog');
await dialog.locator('input').first().fill(dictType);
await dialog.locator('input').nth(1).fill(`测试字典类型_${timestamp}`);
await dialog.locator('textarea').fill(`这是测试字典类型的备注信息,时间戳:${timestamp}`);
});
await test.step('提交字典类型表单', async () => {
await page.locator('.el-dialog button:has-text("确定")').click();
await page.waitForSelector('.el-dialog', { state: 'hidden', timeout: 10000 });
await expect(page.locator('.el-message--success')).toBeVisible({ timeout: 5000 });
});
await test.step('验证字典类型已创建', async () => {
await page.locator('input[placeholder="请输入字典类型"]').fill(dictType);
await page.locator('button:has-text("查询")').click();
await page.waitForLoadState('networkidle');
const dictTypeRow = page.locator(`tr:has-text("${dictType}")`);
await expect(dictTypeRow).toBeVisible({ timeout: 10000 });
});
});
test('创建字典数据', async ({ page }) => {
await test.step('导航到数据字典管理', async () => {
await page.goto('/dashboard');
await page.waitForLoadState('networkidle');
await page.locator('text=系统管理').click();
await page.waitForTimeout(500);
await page.locator('text=数据字典').click();
await page.waitForLoadState('networkidle');
});
await test.step('切换到字典数据标签页', async () => {
await page.locator('.el-tabs__item:has-text("字典数据")').click();
await page.waitForLoadState('networkidle');
});
await test.step('点击新增字典数据按钮', async () => {
await page.locator('button:has-text("新增字典数据")').click();
await page.waitForSelector('.el-dialog', { state: 'visible', timeout: 5000 });
});
await test.step('填写字典数据信息', async () => {
const dialog = page.locator('.el-dialog');
// 选择字典类型
await dialog.locator('.el-select').first().click();
await page.locator(`.el-select-dropdown:visible .el-select-dropdown__item:has-text("${dictType}")`).click();
await dialog.locator('input').nth(1).fill(dictName);
await dialog.locator('input').nth(2).fill(dictCode);
await dialog.locator('.el-input-number .el-input__inner').fill('99');
await dialog.locator('textarea').fill(`这是测试字典数据的备注信息,时间戳:${timestamp}`);
});
await test.step('提交字典数据表单', async () => {
await page.locator('.el-dialog button:has-text("确定")').click();
await page.waitForSelector('.el-dialog', { state: 'hidden', timeout: 10000 });
await expect(page.locator('.el-message--success')).toBeVisible({ timeout: 5000 });
});
await test.step('验证字典数据已创建', async () => {
await page.locator('input[placeholder="请输入字典名称"]').fill(dictName);
await page.locator('button:has-text("查询")').click();
await page.waitForLoadState('networkidle');
const dictDataRow = page.locator(`tr:has-text("${dictName}")`);
await expect(dictDataRow).toBeVisible({ timeout: 10000 });
await expect(dictDataRow.locator('td').nth(2)).toHaveText(dictCode);
});
});
test('编辑字典数据', async ({ page }) => {
const updatedName = `更新字典_${timestamp}`;
await test.step('导航到数据字典管理', async () => {
await page.goto('/dashboard');
await page.waitForLoadState('networkidle');
await page.locator('text=系统管理').click();
await page.waitForTimeout(500);
await page.locator('text=数据字典').click();
await page.waitForLoadState('networkidle');
});
await test.step('切换到字典数据标签页', async () => {
await page.locator('.el-tabs__item:has-text("字典数据")').click();
await page.waitForLoadState('networkidle');
});
await test.step('搜索并编辑字典数据', async () => {
await page.locator('input[placeholder="请输入字典名称"]').fill(dictName);
await page.locator('button:has-text("查询")').click();
await page.waitForLoadState('networkidle');
const dictDataRow = page.locator(`tr:has-text("${dictName}")`);
await dictDataRow.locator('button:has-text("编辑")').click();
await page.waitForSelector('.el-dialog', { state: 'visible', timeout: 5000 });
});
await test.step('修改字典数据信息', async () => {
const dialog = page.locator('.el-dialog');
await dialog.locator('input').nth(1).fill(updatedName);
await dialog.locator('textarea').fill(`这是更新后的字典数据备注,时间戳:${timestamp}`);
});
await test.step('提交更新', async () => {
await page.locator('.el-dialog button:has-text("确定")').click();
await page.waitForSelector('.el-dialog', { state: 'hidden', timeout: 10000 });
await expect(page.locator('.el-message--success')).toBeVisible({ timeout: 5000 });
});
await test.step('验证字典数据已更新', async () => {
await page.locator('input[placeholder="请输入字典名称"]').fill(updatedName);
await page.locator('button:has-text("查询")').click();
await page.waitForLoadState('networkidle');
const dictDataRow = page.locator(`tr:has-text("${updatedName}")`);
await expect(dictDataRow).toBeVisible({ timeout: 10000 });
});
});
test('删除字典数据', async ({ page }) => {
await test.step('导航到数据字典管理', async () => {
await page.goto('/dashboard');
await page.waitForLoadState('networkidle');
await page.locator('text=系统管理').click();
await page.waitForTimeout(500);
await page.locator('text=数据字典').click();
await page.waitForLoadState('networkidle');
});
await test.step('切换到字典数据标签页', async () => {
await page.locator('.el-tabs__item:has-text("字典数据")').click();
await page.waitForLoadState('networkidle');
});
await test.step('搜索并删除字典数据', async () => {
await page.locator('input[placeholder="请输入字典名称"]').fill(`更新字典_${timestamp}`);
await page.locator('button:has-text("查询")').click();
await page.waitForLoadState('networkidle');
const dictDataRow = page.locator(`tr:has-text("更新字典_${timestamp}")`);
await dictDataRow.locator('button:has-text("删除")').click();
await page.waitForSelector('.el-message-box', { state: 'visible', timeout: 5000 });
});
await test.step('确认删除', async () => {
await page.locator('.el-message-box button:has-text("确定")').click();
await page.waitForSelector('.el-message-box', { state: 'hidden', timeout: 10000 });
await expect(page.locator('.el-message--success')).toBeVisible({ timeout: 5000 });
});
await test.step('验证字典数据已删除', async () => {
await page.locator('input[placeholder="请输入字典名称"]').fill(`更新字典_${timestamp}`);
await page.locator('button:has-text("查询")').click();
await page.waitForLoadState('networkidle');
const emptyText = page.locator('text=暂无数据');
await expect(emptyText).toBeVisible({ timeout: 10000 });
});
});
test('字典管理功能验证', async ({ page }) => {
await test.step('验证字典管理页面访问权限', async () => {
await page.goto('/dicts');
await page.waitForLoadState('networkidle');
// 验证页面标题
await expect(page.locator('h1:has-text("数据字典管理")')).toBeVisible({ timeout: 5000 });
// 验证标签页
await expect(page.locator('.el-tabs__item:has-text("字典类型")')).toBeVisible();
await expect(page.locator('.el-tabs__item:has-text("字典数据")')).toBeVisible();
// 验证功能按钮
await expect(page.locator('button:has-text("新增字典类型")')).toBeVisible();
await expect(page.locator('button:has-text("新增字典数据")')).toBeVisible();
await expect(page.locator('button:has-text("查询")')).toBeVisible();
});
await test.step('验证字典类型搜索功能', async () => {
await page.locator('.el-tabs__item:has-text("字典类型")').click();
await page.waitForLoadState('networkidle');
const searchInput = page.locator('input[placeholder="请输入字典类型"]');
await expect(searchInput).toBeVisible();
const searchButton = page.locator('button:has-text("查询")');
await expect(searchButton).toBeVisible();
// 测试搜索功能
await searchInput.fill('test');
await searchButton.click();
await page.waitForLoadState('networkidle');
// 验证搜索结果
const table = page.locator('.el-table');
await expect(table).toBeVisible();
});
await test.step('验证字典数据搜索功能', async () => {
await page.locator('.el-tabs__item:has-text("字典数据")').click();
await page.waitForLoadState('networkidle');
const searchInput = page.locator('input[placeholder="请输入字典名称"]');
await expect(searchInput).toBeVisible();
const searchButton = page.locator('button:has-text("查询")');
await expect(searchButton).toBeVisible();
// 测试搜索功能
await searchInput.fill('test');
await searchButton.click();
await page.waitForLoadState('networkidle');
// 验证搜索结果
const table = page.locator('.el-table');
await expect(table).toBeVisible();
});
});
});
@@ -0,0 +1,72 @@
import { test, expect } from '@playwright/test';
import { ExceptionLogPage } from '../pages/ExceptionLogPage';
test.describe('异常日志工作流', () => {
let exceptionLogPage: ExceptionLogPage;
test.beforeEach(async ({ page }) => {
exceptionLogPage = new ExceptionLogPage(page);
});
test('查看异常日志列表', async ({ page }) => {
await test.step('导航到异常日志页面', async () => {
await exceptionLogPage.goto();
});
await test.step('验证表格显示', async () => {
await expect(exceptionLogPage.table).toBeVisible({ timeout: 10000 });
});
await test.step('验证数据加载', async () => {
const rowCount = await exceptionLogPage.getLogCount();
console.log(`异常日志列表包含 ${rowCount} 条记录`);
expect(rowCount).toBeGreaterThanOrEqual(0);
});
});
test('搜索异常日志', async ({ page }) => {
await test.step('导航到异常日志页面', async () => {
await exceptionLogPage.goto();
});
await test.step('输入搜索关键词', async () => {
const searchKeyword = 'NullPointerException';
await exceptionLogPage.search(searchKeyword);
});
await test.step('验证搜索结果', async () => {
await page.waitForLoadState('networkidle');
const rowCount = await exceptionLogPage.getLogCount();
console.log(`搜索结果包含 ${rowCount} 条记录`);
});
});
test('查看异常日志详情', async ({ page }) => {
await test.step('导航到异常日志页面', async () => {
await exceptionLogPage.goto();
});
await test.step('等待数据加载', async () => {
await expect(exceptionLogPage.table).toBeVisible({ timeout: 10000 });
});
await test.step('点击查看详情按钮', async () => {
const detailButton = page.locator('button:has-text("详情")').or(page.locator('.detail-button')).first();
if (await detailButton.isVisible({ timeout: 3000 }).catch(() => false)) {
await detailButton.click();
await test.step('验证详情对话框显示', async () => {
const dialog = page.locator('.el-dialog');
await expect(dialog).toBeVisible({ timeout: 5000 });
console.log('异常日志详情对话框已打开');
});
await test.step('关闭详情对话框', async () => {
await exceptionLogPage.closeDetailDialog();
});
} else {
console.log('当前没有异常日志记录,跳过详情查看测试');
}
});
});
});
@@ -0,0 +1,89 @@
import { test, expect } from '@playwright/test';
test.describe('文件管理工作流', () => {
test('文件上传流程', async ({ page }) => {
await test.step('导航到文件管理', async () => {
await page.goto('/dashboard');
await page.waitForLoadState('networkidle');
await page.locator('text=系统管理').click();
await page.waitForTimeout(500);
await page.locator('.el-menu-item:has-text("文件管理")').click();
await page.waitForLoadState('networkidle');
await page.waitForTimeout(1000);
await expect(page.locator('.el-table')).toBeVisible({ timeout: 10000 });
});
await test.step('上传文件', async () => {
const uploadButton = page.locator('button:has-text("上传")');
if (await uploadButton.isVisible()) {
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles({
name: 'test-file.txt',
mimeType: 'text/plain',
buffer: Buffer.from('Test file content'),
});
await page.waitForTimeout(2000);
}
});
await test.step('验证文件上传成功', async () => {
const successMessage = page.locator('.el-message--success');
if (await successMessage.isVisible()) {
expect(await successMessage.textContent()).toContain('成功');
}
});
});
test('文件搜索和筛选', async ({ page }) => {
await test.step('导航到文件管理', async () => {
await page.goto('/dashboard');
await page.locator('text=系统管理').click();
await page.locator('text=文件管理').click();
});
await test.step('搜索文件', async () => {
const searchInput = page.locator('input[placeholder*="搜索"]');
if (await searchInput.isVisible()) {
await searchInput.fill('test');
await page.locator('button:has-text("搜索")').click();
await page.waitForTimeout(1000);
}
});
await test.step('按类型筛选', async () => {
const typeFilter = page.locator('.el-select:has-text("类型")');
if (await typeFilter.isVisible()) {
await typeFilter.click();
await page.locator('.el-select-dropdown__item').first().click();
await page.waitForTimeout(1000);
}
});
});
test('文件删除流程', async ({ page }) => {
await test.step('导航到文件管理', async () => {
await page.goto('/dashboard');
await page.locator('text=系统管理').click();
await page.locator('text=文件管理').click();
});
await test.step('选择文件', async () => {
const fileCheckbox = page.locator('.el-checkbox').first();
if (await fileCheckbox.isVisible()) {
await fileCheckbox.click();
}
});
await test.step('删除文件', async () => {
const deleteButton = page.locator('button:has-text("删除")');
if (await deleteButton.isVisible()) {
await deleteButton.click();
await page.locator('button:has-text("确定")').click();
await page.waitForTimeout(1000);
}
});
});
});
@@ -0,0 +1,138 @@
import { test, expect } from '@playwright/test';
import { NotificationPage } from '../pages/NotificationPage';
test.describe('通知管理工作流', () => {
let noticePage: NotificationPage;
const timestamp = Date.now();
const noticeTitle = `测试通知_${timestamp}`;
const noticeContent = `这是测试通知内容_${timestamp}`;
test.beforeEach(async ({ page }) => {
noticePage = new NotificationPage(page);
});
test('查看通知列表', async ({ page }) => {
await test.step('导航到通知管理页面', async () => {
await noticePage.goto();
});
await test.step('验证表格显示', async () => {
await expect(noticePage.table).toBeVisible({ timeout: 10000 });
});
await test.step('验证数据加载', async () => {
const rowCount = await noticePage.getTableRowCount();
console.log(`通知列表包含 ${rowCount} 条记录`);
expect(rowCount).toBeGreaterThanOrEqual(0);
});
});
test('新增通知', async ({ page }) => {
await test.step('导航到通知管理页面', async () => {
await noticePage.goto();
});
await test.step('点击新增通知按钮', async () => {
await noticePage.addButton.click();
await noticePage.dialog.waitFor({ state: 'visible', timeout: 5000 });
});
await test.step('填写通知表单', async () => {
await noticePage.titleInput.fill(noticeTitle);
await noticePage.contentInput.fill(noticeContent);
});
await test.step('提交表单', async () => {
await noticePage.saveButton.click();
await page.waitForLoadState('networkidle');
});
await test.step('验证创建成功', async () => {
await expect(noticePage.dialog).not.toBeVisible({ timeout: 5000 });
console.log(`通知 ${noticeTitle} 创建完成`);
});
});
test('编辑通知', async ({ page }) => {
await test.step('导航到通知管理页面', async () => {
await noticePage.goto();
});
await test.step('等待数据加载', async () => {
await expect(noticePage.table).toBeVisible({ timeout: 10000 });
});
await test.step('点击编辑按钮', async () => {
const rows = await noticePage.getTableRowCount();
if (rows > 0) {
const firstRow = noticePage.table.locator('tr').first();
const editBtn = firstRow.getByRole('button', { name: '编辑' });
if (await editBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
await editBtn.click();
await noticePage.dialog.waitFor({ state: 'visible', timeout: 5000 });
await test.step('修改通知内容', async () => {
const newContent = `更新通知内容_${timestamp}`;
await noticePage.contentInput.clear();
await noticePage.contentInput.fill(newContent);
});
await test.step('提交表单', async () => {
await noticePage.saveButton.click();
await page.waitForLoadState('networkidle');
});
await test.step('验证更新成功', async () => {
await expect(noticePage.dialog).not.toBeVisible({ timeout: 5000 });
console.log(`通知已更新`);
});
} else {
console.log('未找到编辑按钮,跳过编辑测试');
}
} else {
console.log('当前没有通知记录,跳过编辑测试');
}
});
});
test('删除通知', async ({ page }) => {
await test.step('导航到通知管理页面', async () => {
await noticePage.goto();
});
await test.step('等待数据加载', async () => {
await expect(noticePage.table).toBeVisible({ timeout: 10000 });
});
await test.step('点击删除按钮', async () => {
const rows = await noticePage.getTableRowCount();
if (rows > 0) {
const firstRow = noticePage.table.locator('tr').first();
const deleteBtn = firstRow.getByRole('button', { name: '删除' });
if (await deleteBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
await deleteBtn.click();
const confirmBtn = page.locator('.el-message-box');
await confirmBtn.waitFor({ state: 'visible', timeout: 3000 });
await test.step('确认删除', async () => {
const confirmBtn = page.locator('.el-message-box').getByRole('button', { name: '确定' });
if (await confirmBtn.isVisible({ timeout: 2000 }).catch(() => false)) {
await confirmBtn.click();
await page.waitForLoadState('networkidle');
}
});
await test.step('验证删除成功', async () => {
const messageBox = page.locator('.el-message-box');
await expect(messageBox).not.toBeVisible({ timeout: 5000 });
console.log(`通知已删除`);
});
} else {
console.log('未找到删除按钮,跳过删除测试');
}
} else {
console.log('当前没有通知记录,跳过删除测试');
}
});
});
});
@@ -0,0 +1,169 @@
import { test, expect } from '@playwright/test';
test.describe('系统配置管理完整工作流', () => {
test.describe.configure({ mode: 'serial' });
const timestamp = Date.now();
const configKey = `test_config_${timestamp}`;
const configName = `测试配置_${timestamp}`;
const configValue = `test_value_${timestamp}`;
test('创建系统配置', async ({ page }) => {
await test.step('导航到系统配置管理', async () => {
await page.goto('/dashboard');
await page.waitForLoadState('networkidle');
await page.locator('text=系统管理').click();
await page.waitForTimeout(500);
await page.locator('text=系统配置').click();
await page.waitForLoadState('networkidle');
await expect(page).toHaveURL(/.*configs/, { timeout: 10000 });
});
await test.step('点击新增配置按钮', async () => {
await page.locator('button:has-text("新增配置")').click();
await page.waitForSelector('.el-dialog', { state: 'visible', timeout: 5000 });
});
await test.step('填写配置信息', async () => {
const dialog = page.locator('.el-dialog');
await dialog.locator('input').first().fill(configName);
await dialog.locator('input').nth(1).fill(configKey);
await dialog.locator('input').nth(2).fill(configValue);
await dialog.locator('textarea').fill(`这是测试配置的备注信息,用于验证配置管理功能。时间戳:${timestamp}`);
});
await test.step('提交配置表单', async () => {
await page.locator('.el-dialog button:has-text("确定")').click();
await page.waitForSelector('.el-dialog', { state: 'hidden', timeout: 10000 });
await expect(page.locator('.el-message--success')).toBeVisible({ timeout: 5000 });
});
await test.step('验证配置已创建', async () => {
await page.locator('input[placeholder="请输入配置名称"]').fill(configName);
await page.locator('button:has-text("查询")').click();
await page.waitForLoadState('networkidle');
const configRow = page.locator(`tr:has-text("${configName}")`);
await expect(configRow).toBeVisible({ timeout: 10000 });
await expect(configRow.locator('td').nth(1)).toHaveText(configKey);
await expect(configRow.locator('td').nth(2)).toHaveText(configValue);
});
});
test('编辑系统配置', async ({ page }) => {
const updatedValue = `updated_value_${timestamp}`;
await test.step('导航到系统配置管理', async () => {
await page.goto('/dashboard');
await page.waitForLoadState('networkidle');
await page.locator('text=系统管理').click();
await page.waitForTimeout(500);
await page.locator('text=系统配置').click();
await page.waitForLoadState('networkidle');
});
await test.step('搜索并编辑配置', async () => {
await page.locator('input[placeholder="请输入配置名称"]').fill(configName);
await page.locator('button:has-text("查询")').click();
await page.waitForLoadState('networkidle');
const configRow = page.locator(`tr:has-text("${configName}")`);
await configRow.locator('button:has-text("编辑")').click();
await page.waitForSelector('.el-dialog', { state: 'visible', timeout: 5000 });
});
await test.step('修改配置值', async () => {
const dialog = page.locator('.el-dialog');
await dialog.locator('input').nth(2).fill(updatedValue);
await dialog.locator('textarea').fill(`这是更新后的配置备注,时间戳:${timestamp}`);
});
await test.step('提交更新', async () => {
await page.locator('.el-dialog button:has-text("确定")').click();
await page.waitForSelector('.el-dialog', { state: 'hidden', timeout: 10000 });
await expect(page.locator('.el-message--success')).toBeVisible({ timeout: 5000 });
});
await test.step('验证配置已更新', async () => {
await page.locator('input[placeholder="请输入配置名称"]').fill(configName);
await page.locator('button:has-text("查询")').click();
await page.waitForLoadState('networkidle');
const configRow = page.locator(`tr:has-text("${configName}")`);
await expect(configRow.locator('td').nth(2)).toHaveText(updatedValue);
});
});
test('删除系统配置', async ({ page }) => {
await test.step('导航到系统配置管理', async () => {
await page.goto('/dashboard');
await page.waitForLoadState('networkidle');
await page.locator('text=系统管理').click();
await page.waitForTimeout(500);
await page.locator('text=系统配置').click();
await page.waitForLoadState('networkidle');
});
await test.step('搜索并删除配置', async () => {
await page.locator('input[placeholder="请输入配置名称"]').fill(configName);
await page.locator('button:has-text("查询")').click();
await page.waitForLoadState('networkidle');
const configRow = page.locator(`tr:has-text("${configName}")`);
await configRow.locator('button:has-text("删除")').click();
await page.waitForSelector('.el-message-box', { state: 'visible', timeout: 5000 });
});
await test.step('确认删除', async () => {
await page.locator('.el-message-box button:has-text("确定")').click();
await page.waitForSelector('.el-message-box', { state: 'hidden', timeout: 10000 });
await expect(page.locator('.el-message--success')).toBeVisible({ timeout: 5000 });
});
await test.step('验证配置已删除', async () => {
await page.locator('input[placeholder="请输入配置名称"]').fill(configName);
await page.locator('button:has-text("查询")').click();
await page.waitForLoadState('networkidle');
const emptyText = page.locator('text=暂无数据');
await expect(emptyText).toBeVisible({ timeout: 10000 });
});
});
test('配置管理权限验证', async ({ page }) => {
await test.step('验证配置管理页面访问权限', async () => {
await page.goto('/configs');
await page.waitForLoadState('networkidle');
// 验证页面标题
await expect(page.locator('h1:has-text("系统配置管理")')).toBeVisible({ timeout: 5000 });
// 验证功能按钮可见性
await expect(page.locator('button:has-text("新增配置")')).toBeVisible();
await expect(page.locator('button:has-text("查询")')).toBeVisible();
// 验证表格列头
await expect(page.locator('th:has-text("配置名称")')).toBeVisible();
await expect(page.locator('th:has-text("配置键")')).toBeVisible();
await expect(page.locator('th:has-text("配置值")')).toBeVisible();
await expect(page.locator('th:has-text("操作")')).toBeVisible();
});
await test.step('验证配置搜索功能', async () => {
const searchInput = page.locator('input[placeholder="请输入配置名称"]');
await expect(searchInput).toBeVisible();
const searchButton = page.locator('button:has-text("查询")');
await expect(searchButton).toBeVisible();
// 测试搜索功能
await searchInput.fill('test');
await searchButton.click();
await page.waitForLoadState('networkidle');
// 验证搜索结果
const table = page.locator('.el-table');
await expect(table).toBeVisible();
});
});
});
@@ -0,0 +1,116 @@
import { test, expect } from '@playwright/test';
test.describe('用户权限边界验证', () => {
test('管理员可以访问所有管理功能', async ({ page }) => {
await test.step('验证可以访问用户管理', async () => {
await page.goto('/users');
await page.waitForLoadState('networkidle');
await expect(page).toHaveURL(/.*users/);
await expect(page.locator('.el-table')).toBeVisible({ timeout: 10000 });
});
await test.step('验证可以访问角色管理', async () => {
await page.goto('/roles');
await page.waitForLoadState('networkidle');
await expect(page).toHaveURL(/.*roles/);
await expect(page.locator('.el-table')).toBeVisible({ timeout: 10000 });
});
await test.step('验证可以访问菜单管理', async () => {
await page.goto('/menus');
await page.waitForLoadState('networkidle');
await expect(page).toHaveURL(/.*menus/);
await expect(page.locator('.el-table')).toBeVisible({ timeout: 10000 });
});
});
test('普通用户登录后可以访问页面但API操作受限', async ({ page }) => {
await test.step('管理员登出', async () => {
await page.goto('/dashboard');
await page.waitForLoadState('networkidle');
const avatarButton = page.locator('.el-avatar').first();
await avatarButton.click({ timeout: 10000 });
await page.waitForTimeout(500);
await page.locator('text=退出登录').click();
await page.waitForURL(/.*login/, { timeout: 10000 });
});
await test.step('普通用户登录', async () => {
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:has-text("登录")');
await usernameInput.waitFor({ state: 'visible' });
await usernameInput.fill('user');
await passwordInput.waitFor({ state: 'visible' });
await passwordInput.fill('Test@123');
await loginButton.waitFor({ state: 'visible' });
await loginButton.click();
await page.waitForURL('**/dashboard', { timeout: 30000 });
});
await test.step('验证普通用户可以访问用户管理页面', async () => {
await page.goto('/users');
await page.waitForLoadState('networkidle');
await expect(page).toHaveURL(/.*users/);
});
await test.step('验证普通用户无法创建用户', async () => {
const createButton = page.locator('button:has-text("新增用户")');
if (await createButton.isVisible()) {
await createButton.click();
await page.waitForTimeout(2000);
const errorMessage = page.locator('.el-message--error');
const hasError = await errorMessage.isVisible().catch(() => false);
expect(hasError || await page.locator('.el-dialog').isVisible()).toBeTruthy();
}
});
});
test('权限不足时API返回403错误', async ({ page }) => {
await test.step('管理员登出', async () => {
await page.goto('/dashboard');
await page.waitForLoadState('networkidle');
const avatarButton = page.locator('.el-avatar').first();
await avatarButton.click({ timeout: 10000 });
await page.waitForTimeout(500);
await page.locator('text=退出登录').click();
await page.waitForURL(/.*login/, { timeout: 10000 });
});
await test.step('普通用户登录', async () => {
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:has-text("登录")');
await usernameInput.waitFor({ state: 'visible' });
await usernameInput.fill('user');
await passwordInput.waitFor({ state: 'visible' });
await passwordInput.fill('Test@123');
await loginButton.waitFor({ state: 'visible' });
await loginButton.click();
await page.waitForURL('**/dashboard', { timeout: 30000 });
});
await test.step('尝试访问受限API', async () => {
const response = await page.request.get('/api/users?page=0&size=10');
expect([200, 401, 403]).toContain(response.status());
});
});
});
@@ -0,0 +1,72 @@
import { test, expect } from '@playwright/test';
test.describe('菜单管理功能测试', () => {
let authToken: string;
test.beforeAll(async ({ request }) => {
const response = await request.post('http://localhost:8080/api/auth/login', {
headers: {
'Content-Type': 'application/json'
},
data: {
username: 'admin',
password: 'admin123'
}
});
expect(response.status()).toBe(200);
const data = await response.json();
authToken = data.token;
});
test('菜单列表显示测试', async ({ page }) => {
await test.step('导航到菜单管理页面', async () => {
await page.goto('http://localhost:3002/login');
const usernameInput = page.locator('input[type="text"], input[placeholder*="用户名"], input[placeholder*="账号"]').first();
const passwordInput = page.locator('input[type="password"]').first();
const loginButton = page.locator('button:has-text("登录")').first();
await usernameInput.fill('admin');
await passwordInput.fill('admin123');
await loginButton.click();
await page.waitForTimeout(2000);
// 点击系统管理菜单
const systemMenu = page.locator('.el-sub-menu:has-text("系统管理")').first();
if (await systemMenu.count() > 0) {
await systemMenu.click();
await page.waitForTimeout(500);
}
// 点击菜单管理
const menuManagement = page.locator('.el-menu-item:has-text("菜单管理")').first();
if (await menuManagement.count() > 0) {
await menuManagement.click();
await page.waitForTimeout(1000);
}
});
await test.step('验证菜单列表显示', async () => {
// 检查是否有菜单列表或表格
const tableSelectors = [
'table',
'.el-table',
'[class*="table"]',
'.menu-list'
];
let foundTable = false;
for (const selector of tableSelectors) {
const count = await page.locator(selector).count();
if (count > 0) {
foundTable = true;
break;
}
}
expect(foundTable).toBe(true);
});
});
});
+130
View File
@@ -0,0 +1,130 @@
import { Page, Locator } from '@playwright/test';
export class DashboardPage {
readonly page: Page;
readonly userInfo: Locator;
readonly userManagementLink: Locator;
readonly roleManagementLink: Locator;
readonly menuManagementLink: Locator;
readonly systemConfigLink: Locator;
readonly noticeManagementLink: Locator;
readonly fileManagementLink: Locator;
readonly operationLogLink: Locator;
readonly loginLogLink: Locator;
readonly dictionaryLink: Locator;
constructor(page: Page) {
this.page = page;
this.userInfo = page.locator('.el-avatar');
this.userManagementLink = page.locator('.el-menu-item:has-text("用户管理")');
this.roleManagementLink = page.locator('.el-menu-item:has-text("角色管理")');
this.menuManagementLink = page.locator('.el-menu-item:has-text("菜单管理")');
this.systemConfigLink = page.locator('.el-menu-item:has-text("参数配置")');
this.noticeManagementLink = page.locator('.el-menu-item:has-text("通知公告")');
this.fileManagementLink = page.locator('.el-menu-item:has-text("文件列表")');
this.operationLogLink = page.locator('.el-menu-item:has-text("操作日志")');
this.loginLogLink = page.locator('.el-menu-item:has-text("登录日志")');
this.dictionaryLink = page.locator('.el-menu-item:has-text("字典管理")');
}
async goto() {
await this.page.goto('/dashboard');
await this.page.waitForLoadState('networkidle');
}
async navigateToUserManagement() {
const systemMenu = this.page.locator('.el-sub-menu__title:has-text("系统管理")');
await systemMenu.click();
await this.page.waitForTimeout(1000);
await this.userManagementLink.click();
await this.page.waitForURL('**/users', { timeout: 30000 });
await this.page.waitForLoadState('networkidle');
}
async navigateToRoleManagement() {
const systemMenu = this.page.locator('.el-sub-menu__title:has-text("系统管理")');
await systemMenu.click();
await this.page.waitForTimeout(1000);
await this.roleManagementLink.click();
await this.page.waitForURL('**/roles', { timeout: 30000 });
await this.page.waitForLoadState('networkidle');
}
async navigateToMenuManagement() {
const systemMenu = this.page.locator('.el-sub-menu__title:has-text("系统管理")');
await systemMenu.click();
await this.page.waitForTimeout(1000);
await this.menuManagementLink.click();
await this.page.waitForURL('**/menus', { timeout: 30000 });
await this.page.waitForLoadState('networkidle');
}
async navigateToSystemConfig() {
const configMenu = this.page.locator('.el-sub-menu__title:has-text("系统配置")');
await configMenu.click();
await this.page.waitForTimeout(1000);
await this.systemConfigLink.click();
await this.page.waitForURL('**/sys/config', { timeout: 30000 });
await this.page.waitForLoadState('networkidle');
}
async navigateToNoticeManagement() {
const notifyMenu = this.page.locator('.el-sub-menu__title:has-text("通知中心")');
await notifyMenu.click();
await this.page.waitForTimeout(1000);
await this.noticeManagementLink.click();
await this.page.waitForURL('**/notice', { timeout: 30000 });
await this.page.waitForLoadState('networkidle');
}
async navigateToFileManagement() {
const fileMenu = this.page.locator('.el-sub-menu__title:has-text("文件管理")');
await fileMenu.click();
await this.page.waitForTimeout(1000);
await this.fileManagementLink.click();
await this.page.waitForURL('**/files', { timeout: 30000 });
await this.page.waitForLoadState('networkidle');
}
async navigateToAudit() {
const auditMenu = this.page.locator('.el-sub-menu__title:has-text("审计中心")');
await auditMenu.click();
await this.page.waitForTimeout(1000);
}
async navigateToOperationLog() {
await this.navigateToAudit();
await this.operationLogLink.click();
await this.page.waitForURL('**/oplog', { timeout: 30000 });
await this.page.waitForLoadState('networkidle');
}
async navigateToLoginLog() {
await this.navigateToAudit();
await this.loginLogLink.click();
await this.page.waitForURL('**/loginlog', { timeout: 30000 });
await this.page.waitForLoadState('networkidle');
}
async navigateToNotification() {
const notifyMenu = this.page.locator('.el-sub-menu__title:has-text("通知中心")');
await notifyMenu.click();
await this.page.waitForTimeout(1000);
await this.noticeManagementLink.click();
await this.page.waitForURL('**/notification', { timeout: 30000 });
await this.page.waitForLoadState('networkidle');
}
async navigateToDictionary() {
const configMenu = this.page.locator('.el-sub-menu__title:has-text("系统配置")');
await configMenu.click();
await this.page.waitForTimeout(1000);
await this.dictionaryLink.click();
await this.page.waitForURL('**/dict', { timeout: 30000 });
await this.page.waitForLoadState('networkidle');
}
async getUsername(): Promise<string | null> {
return await this.userInfo.textContent();
}
}
@@ -0,0 +1,96 @@
import { Page, Locator, expect } from '@playwright/test';
export class DictionaryManagementPage {
readonly page: Page;
readonly table: Locator;
readonly createDictButton: Locator;
readonly saveButton: Locator;
readonly dialog: Locator;
readonly dictNameInput: Locator;
readonly dictTypeInput: Locator;
readonly statusSelect: Locator;
readonly remarkInput: Locator;
constructor(page: Page) {
this.page = page;
this.table = page.locator('.el-table');
this.createDictButton = page.getByRole('button', { name: '新增字典' });
this.saveButton = page.getByRole('button', { name: '确定' });
this.dialog = page.locator('.el-dialog');
this.dictNameInput = page.locator('.el-dialog').getByRole('textbox', { name: '字典名称' });
this.dictTypeInput = page.locator('.el-dialog').getByRole('textbox', { name: '字典类型' });
this.statusSelect = page.locator('.el-dialog').getByRole('combobox', { name: '状态' });
this.remarkInput = page.locator('.el-dialog').getByRole('textbox', { name: '备注' });
}
async goto() {
try {
console.log('导航到字典管理页面...');
await this.page.goto('/dict');
await this.page.waitForLoadState('networkidle');
await this.table.waitFor({ state: 'visible', timeout: 10000 });
await expect(this.page).toHaveURL(/.*dict/);
console.log('字典管理页面加载完成');
} catch (error) {
await this.page.screenshot({ path: `test-results/dict-management-error-${Date.now()}.png` });
console.error('导航到字典管理页面失败:', error);
throw new Error(`导航到字典管理页面失败: ${error instanceof Error ? error.message : String(error)}`);
}
}
async createDict(dictName: string, dictType: string, status: string = '0', remark?: string) {
await this.createDictButton.click();
await this.page.waitForTimeout(500);
await this.dictNameInput.fill(dictName);
await this.dictTypeInput.fill(dictType);
if (status) {
await this.statusSelect.click();
await this.page.waitForTimeout(300);
await this.page.getByRole('option', { name: status === '0' ? '正常' : '停用' }).click();
}
if (remark) {
await this.remarkInput.fill(remark);
}
await this.saveButton.click();
await this.page.waitForLoadState('networkidle');
}
async editDict(dictName: string, newDictName: string) {
const row = this.table.locator('tr').filter({ hasText: dictName }).first();
const editBtn = row.getByRole('button', { name: '编辑' });
await editBtn.click();
await this.page.waitForTimeout(500);
await this.dictNameInput.clear();
await this.dictNameInput.fill(newDictName);
await this.saveButton.click();
await this.page.waitForLoadState('networkidle');
}
async deleteDict(dictName: string) {
const row = this.table.locator('tr').filter({ hasText: dictName }).first();
const deleteBtn = row.getByRole('button', { name: '删除' });
await deleteBtn.click();
await this.page.waitForTimeout(500);
const confirmBtn = this.page.locator('.el-message-box').getByRole('button', { name: '确定' });
await confirmBtn.click();
await this.page.waitForLoadState('networkidle');
}
async getDictCount() {
const rows = await this.table.locator('.el-table__row').count();
return rows;
}
async containsText(text: string): Promise<boolean> {
return await this.table.getByText(text).count() > 0;
}
}
@@ -0,0 +1,101 @@
import { Page, Locator, expect } from '@playwright/test';
export class ExceptionLogPage {
readonly page: Page;
readonly table: Locator;
readonly searchInput: Locator;
readonly searchButton: Locator;
readonly exportButton: Locator;
readonly refreshButton: Locator;
readonly detailButton: Locator;
readonly successMessage: Locator;
constructor(page: Page) {
this.page = page;
this.table = page.locator('.el-table').or(page.locator('.exception-log-table'));
this.searchInput = page.locator('input[placeholder*="搜索"]').or(page.locator('input[name*="keyword"]'));
this.searchButton = page.getByRole('button', { name: '搜索' }).or(page.locator('button:has-text("搜索")'));
this.exportButton = page.getByRole('button', { name: '导出' }).or(page.locator('button:has-text("导出")'));
this.refreshButton = page.getByRole('button', { name: '刷新' }).or(page.locator('button:has-text("刷新")'));
this.detailButton = page.getByRole('button', { name: '详情' }).or(page.locator('.detail-button'));
this.successMessage = page.locator('.el-message--success').or(page.locator('.success-message'));
}
async goto() {
try {
console.log('导航到异常日志页面...');
await this.page.goto('/exceptionlog');
await this.page.waitForLoadState('networkidle');
await this.table.waitFor({ state: 'visible', timeout: 10000 });
await expect(this.page).toHaveURL(/.*exceptionlog/);
console.log('异常日志页面加载完成');
} catch (error) {
await this.page.screenshot({ path: `test-results/exception-log-error-${Date.now()}.png` });
console.error('导航到异常日志页面失败:', error);
throw new Error(`导航到异常日志页面失败: ${error instanceof Error ? error.message : String(error)}`);
}
}
async search(keyword: string) {
await this.searchInput.fill(keyword);
await this.searchButton.click();
await this.page.waitForTimeout(1000);
}
async clearSearch() {
await this.searchInput.fill('');
await this.searchButton.click();
await this.page.waitForTimeout(1000);
}
async exportData() {
await this.exportButton.click();
}
async refresh() {
await this.refreshButton.click();
await this.page.waitForLoadState('networkidle');
}
async viewDetail(exceptionId: string) {
const exceptionRow = this.table.locator('tbody tr').filter({ hasText: exceptionId });
await exceptionRow.locator('.detail-button').or(this.page.getByRole('button', { name: '详情' })).click();
}
async closeDetailDialog() {
await this.page.getByRole('button', { name: '关闭' }).or(this.page.locator('.el-dialog .close-button')).click();
}
async containsText(text: string): Promise<boolean> {
return await this.table.getByText(text).count() > 0;
}
async getTableRowCount(): Promise<number> {
return await this.table.locator('tbody tr').count();
}
async isSuccessMessageVisible(): Promise<boolean> {
try {
return await this.successMessage.isVisible({ timeout: 3000 });
} catch {
return false;
}
}
async reload() {
await this.page.reload();
}
async verifyTableContains(text: string): Promise<void> {
const contains = await this.containsText(text);
if (!contains) {
throw new Error(`Table does not contain text: ${text}`);
}
}
async getLogCount(): Promise<number> {
return await this.table.locator('tbody tr').count();
}
}
@@ -0,0 +1,106 @@
import { Page, expect } from '@playwright/test';
export class FileManagementPage {
readonly page: Page;
readonly uploadButton;
readonly fileInput;
readonly table;
readonly deleteButton;
readonly downloadButton;
readonly searchInput;
constructor(page: Page) {
this.page = page;
this.uploadButton = page.locator('.el-upload--text').first();
this.fileInput = page.locator('input[type="file"]');
this.table = page.locator('.el-table');
this.deleteButton = page.getByRole('button', { name: '删除' });
this.downloadButton = page.getByRole('button', { name: '下载' });
this.searchInput = page.locator('.search-bar .el-input__inner');
}
async goto() {
try {
console.log('导航到文件管理页面...');
await this.page.goto('/files');
await this.page.waitForLoadState('networkidle');
await this.table.waitFor({ state: 'visible', timeout: 10000 });
await expect(this.page).toHaveURL(/.*files/);
console.log('文件管理页面加载完成');
} catch (error) {
await this.page.screenshot({ path: `test-results/file-management-error-${Date.now()}.png` });
console.error('导航到文件管理页面失败:', error);
throw new Error(`导航到文件管理页面失败: ${error instanceof Error ? error.message : String(error)}`);
}
}
async uploadFile(filePath: string) {
await this.uploadButton.waitFor({ state: 'visible', timeout: 10000 });
await this.uploadButton.click();
const fileInput = this.page.locator('input[type="file"]');
await fileInput.setInputFiles(filePath);
await this.page.waitForTimeout(1000);
}
async deleteFile(fileName: string) {
const row = this.table.locator('tr').filter({ hasText: fileName }).first();
await row.locator('.el-button--danger').click();
const confirmButton = this.page.getByRole('button', { name: '确定' });
await confirmButton.click();
await this.page.waitForLoadState('networkidle');
}
async downloadFile(fileName: string) {
const row = this.table.locator('tr').filter({ hasText: fileName }).first();
const downloadButton = row.locator('.el-button--primary').first();
await downloadButton.click();
}
async searchFile(keyword: string) {
await this.searchInput.fill(keyword);
await this.page.waitForTimeout(500);
}
async clearSearch() {
await this.searchInput.clear();
await this.page.waitForTimeout(500);
}
async verifyTableContains(text: string) {
await expect(this.table).toContainText(text);
}
async verifyTableNotContains(text: string) {
await expect(this.table).not.toContainText(text);
}
async getTableRowCount() {
const rows = await this.table.locator('.el-table__row').count();
return rows;
}
async clickUploadButton() {
await this.uploadButton.waitFor({ state: 'visible', timeout: 10000 });
await this.uploadButton.click();
}
async submitUpload() {
const confirmButton = this.page.getByRole('button', { name: '确定' }).or(this.page.locator('.el-dialog .el-button--primary'));
await confirmButton.click();
}
async clickDeleteButton(rowNumber: number) {
const row = this.table.locator(`tbody tr:nth-child(${rowNumber})`);
await row.locator('.el-button--danger').click();
}
async clickDownloadButton(rowNumber: number) {
const row = this.table.locator(`tbody tr:nth-child(${rowNumber})`);
await row.locator('.el-button--primary').first().click();
}
}
+63
View File
@@ -0,0 +1,63 @@
import { Page, expect } from '@playwright/test';
export class LoginLogPage {
readonly page: Page;
readonly searchInput;
readonly searchButton;
readonly table;
readonly exportButton;
constructor(page: Page) {
this.page = page;
this.searchInput = page.getByPlaceholder('搜索用户名或IP地址');
this.searchButton = page.getByRole('button', { name: '搜索' });
this.table = page.locator('.el-table');
this.exportButton = page.getByRole('button', { name: '导出' });
}
async goto() {
try {
console.log('导航到登录日志页面...');
await this.page.goto('/loginlog');
await this.page.waitForLoadState('networkidle');
await this.table.waitFor({ state: 'visible', timeout: 10000 });
await expect(this.page).toHaveURL(/.*loginlog/);
console.log('登录日志页面加载完成');
} catch (error) {
await this.page.screenshot({ path: `test-results/login-log-error-${Date.now()}.png` });
console.error('导航到登录日志页面失败:', error);
throw new Error(`导航到登录日志页面失败: ${error instanceof Error ? error.message : String(error)}`);
}
}
async searchByKeyword(keyword: string) {
await this.searchInput.fill(keyword);
await this.searchButton.click();
await this.page.waitForLoadState('networkidle');
}
async clearSearch() {
await this.searchInput.clear();
await this.searchButton.click();
await this.page.waitForLoadState('networkidle');
}
async verifyTableContains(text: string) {
await expect(this.table).toContainText(text);
}
async verifyTableNotContains(text: string) {
await expect(this.table).not.toContainText(text);
}
async getTableRowCount() {
const rows = await this.table.locator('.el-table__row').count();
return rows;
}
async exportData() {
await this.exportButton.click();
}
}
+108
View File
@@ -0,0 +1,108 @@
import { Page, Locator } from '@playwright/test';
export class LoginPage {
readonly page: Page;
readonly usernameInput: Locator;
readonly passwordInput: Locator;
readonly loginButton: Locator;
readonly errorMessage: Locator;
readonly logoutButton: Locator;
constructor(page: Page) {
this.page = page;
this.usernameInput = page.locator('input[placeholder="请输入用户名"]');
this.passwordInput = page.locator('input[placeholder="请输入密码"]');
this.loginButton = page.locator('button:has-text("登录")');
this.errorMessage = page.locator('.el-message--error .el-message__content');
this.logoutButton = page.getByRole('button', { name: '退出登录' });
}
async goto() {
await this.page.goto('/login');
await this.page.waitForLoadState('networkidle');
}
async login(username: string, password: string, maxRetries: number = 3) {
let lastError: Error | null = null;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
console.log(`Login attempt ${attempt}/${maxRetries}`);
try {
await this.usernameInput.fill(username);
await this.passwordInput.fill(password);
console.log('Filled username and password');
await this.loginButton.click();
console.log('Clicked login button');
await this.page.waitForURL(/\/(dashboard|\/)$/, { timeout: 30000 });
console.log('Successfully navigated to dashboard or home');
await this.page.waitForLoadState('networkidle');
console.log('Network idle achieved');
await this.page.waitForTimeout(2000);
console.log('Login completed successfully');
return;
} catch (error) {
lastError = error as Error;
console.log(`Login attempt ${attempt} failed:`, error);
const currentUrl = this.page.url();
console.log('Current URL:', currentUrl);
const errorMessage = await this.getErrorMessage();
if (errorMessage) {
console.log('Login error message:', errorMessage);
}
const token = await this.page.evaluate(() => localStorage.getItem('token'));
console.log('Token in localStorage:', token ? 'exists' : 'not found');
if (attempt < maxRetries) {
console.log(`Waiting 2 seconds before retry...`);
await this.page.waitForTimeout(2000);
await this.goto();
console.log('Navigated back to login page for retry');
}
}
}
console.log(`All ${maxRetries} login attempts failed`);
throw lastError || new Error('Login failed after all retries');
}
async getErrorMessage(): Promise<string | null> {
try {
await this.page.waitForSelector('.el-message--error', { timeout: 10000 });
await this.page.waitForTimeout(500);
const messageElement = await this.page.locator('.el-message--error .el-message__content').first();
const text = await messageElement.textContent();
return text;
} catch {
try {
await this.page.waitForSelector('.el-message', { timeout: 5000 });
await this.page.waitForTimeout(500);
const messageElement = await this.page.locator('.el-message .el-message__content').first();
const text = await messageElement.textContent();
return text;
} catch {
return null;
}
}
}
async logout() {
const avatar = this.page.locator('.el-avatar');
await avatar.click();
await this.page.waitForTimeout(1000);
const logoutButton = this.page.locator('.el-dropdown-menu').getByText('退出登录');
await logoutButton.click();
await this.page.waitForURL('**/login', { timeout: 10000 });
}
async isLoggedIn(): Promise<boolean> {
return this.page.url().includes('/dashboard') || this.page.url() === this.page.url().split('?')[0].split('#')[0];
}
}
@@ -0,0 +1,168 @@
import { Page, Locator, expect } from '@playwright/test';
export class MenuManagementPage {
readonly page: Page;
readonly table: Locator;
readonly createMenuButton: Locator;
readonly searchInput: Locator;
readonly searchButton: Locator;
readonly successMessage: Locator;
readonly treeContainer: Locator;
readonly expandAllButton: Locator;
readonly collapseAllButton: Locator;
constructor(page: Page) {
this.page = page;
this.table = page.locator('.el-table').or(page.locator('.menu-table'));
this.createMenuButton = page.getByRole('button', { name: '新增菜单' }).or(page.locator('button:has-text("新增菜单")'));
this.searchInput = page.locator('input[placeholder*="搜索"]').or(page.locator('input[name*="keyword"]'));
this.searchButton = page.getByRole('button', { name: '搜索' }).or(page.locator('button:has-text("搜索")'));
this.successMessage = page.locator('.el-message--success').or(page.locator('.success-message'));
this.treeContainer = page.locator('.el-tree').or(page.locator('.menu-tree'));
this.expandAllButton = page.getByRole('button', { name: '展开全部' }).or(page.locator('button:has-text("展开全部")'));
this.collapseAllButton = page.getByRole('button', { name: '折叠全部' }).or(page.locator('button:has-text("折叠全部")'));
}
async goto() {
try {
console.log('导航到菜单管理页面...');
await this.page.goto('/menus');
await this.page.waitForLoadState('networkidle');
await this.page.waitForSelector('.el-tree', { timeout: 10000 }).catch(() => {
return this.page.waitForSelector('.el-table', { timeout: 5000 });
});
await expect(this.page).toHaveURL(/.*menus/);
console.log('菜单管理页面加载完成');
} catch (error) {
await this.page.screenshot({ path: `test-results/menu-management-error-${Date.now()}.png` });
console.error('导航到菜单管理页面失败:', error);
throw new Error(`导航到菜单管理页面失败: ${error instanceof Error ? error.message : String(error)}`);
}
}
async clickCreateMenu() {
await this.createMenuButton.click();
await this.page.waitForTimeout(500);
}
async fillMenuForm(menuData: {
menuName: string;
menuType?: string;
path?: string;
component?: string;
permission?: string;
sort?: number;
visible?: string;
status?: string;
}) {
const dialog = this.page.locator('.el-dialog');
await dialog.locator('input').first().fill(menuData.menuName);
if (menuData.menuType) {
const menuTypeSelect = dialog.locator('.el-select').first();
await menuTypeSelect.click();
await this.page.waitForTimeout(300);
await this.page.getByRole('option', { name: menuData.menuType }).click();
}
if (menuData.path) {
const pathInput = dialog.locator('input[placeholder*="路径"]');
if (await pathInput.count() > 0) {
await pathInput.fill(menuData.path);
}
}
if (menuData.component) {
const componentInput = dialog.locator('input[placeholder*="组件"]');
if (await componentInput.count() > 0) {
await componentInput.fill(menuData.component);
}
}
if (menuData.permission) {
const permissionInput = dialog.locator('input[placeholder*="权限"]');
if (await permissionInput.count() > 0) {
await permissionInput.fill(menuData.permission);
}
}
if (menuData.sort !== undefined) {
const sortInput = dialog.locator('input[type="number"]');
if (await sortInput.count() > 0) {
await sortInput.fill(String(menuData.sort));
}
}
if (menuData.visible) {
const visibleRadio = dialog.locator(`input[value="${menuData.visible}"]`);
if (await visibleRadio.count() > 0) {
await visibleRadio.check();
}
}
if (menuData.status) {
const statusRadio = dialog.locator(`input[value="${menuData.status}"]`);
if (await statusRadio.count() > 0) {
await statusRadio.check();
}
}
}
async submitForm() {
await this.page.getByRole('button', { name: '确定' }).or(this.page.locator('button:has-text("确定")')).click();
}
async editMenu(menuName: string) {
const menuRow = this.table.locator('tbody tr').filter({ hasText: menuName });
await menuRow.getByRole('button', { name: '编辑' }).or(this.page.locator('.edit-button')).click();
}
async deleteMenu(menuName: string) {
const menuRow = this.table.locator('tbody tr').filter({ hasText: menuName });
await menuRow.getByRole('button', { name: '删除' }).or(this.page.locator('.delete-button')).click();
}
async confirmDelete() {
await this.page.getByRole('button', { name: '确定' }).or(this.page.locator('.confirm-dialog .confirm-button')).click();
}
async search(keyword: string) {
await this.searchInput.fill(keyword);
await this.searchButton.click();
}
async expandAll() {
await this.expandAllButton.click();
await this.page.waitForTimeout(500);
}
async collapseAll() {
await this.collapseAllButton.click();
await this.page.waitForTimeout(500);
}
async containsText(text: string): Promise<boolean> {
return await this.table.getByText(text).count() > 0;
}
async isSuccessMessageVisible(): Promise<boolean> {
try {
return await this.successMessage.isVisible({ timeout: 3000 });
} catch {
return false;
}
}
async getMenuCount(): Promise<number> {
return await this.table.locator('tbody tr').count();
}
async reload() {
await this.page.reload();
}
}
@@ -0,0 +1,88 @@
import { Page, expect } from '@playwright/test';
export class NotificationPage {
readonly page: Page;
readonly table;
readonly addButton;
readonly saveButton;
readonly cancelButton;
readonly dialog;
readonly titleInput;
readonly contentInput;
readonly noticeTypeSelect;
readonly statusSelect;
constructor(page: Page) {
this.page = page;
this.table = page.locator('.el-table');
this.addButton = page.getByRole('button', { name: '新增公告' });
this.saveButton = page.getByRole('button', { name: '确定' });
this.cancelButton = page.getByRole('button', { name: '取消' });
this.dialog = page.locator('.el-dialog');
this.titleInput = page.locator('.el-dialog').getByRole('textbox', { name: '公告标题' });
this.contentInput = page.locator('.el-dialog').getByRole('textbox', { name: '公告内容' });
this.noticeTypeSelect = page.locator('.el-dialog').getByRole('combobox', { name: '公告类型' });
this.statusSelect = page.locator('.el-dialog').getByRole('combobox', { name: '状态' });
}
async goto() {
try {
console.log('导航到通知管理页面...');
await this.page.goto('/notice');
await this.page.waitForLoadState('networkidle');
await this.table.waitFor({ state: 'visible', timeout: 10000 });
await expect(this.page).toHaveURL(/.*notice/);
console.log('通知管理页面加载完成');
} catch (error) {
await this.page.screenshot({ path: `test-results/notification-error-${Date.now()}.png` });
console.error('导航到通知管理页面失败:', error);
throw new Error(`导航到通知管理页面失败: ${error instanceof Error ? error.message : String(error)}`);
}
}
async addNotification(title: string, content: string) {
await this.addButton.click();
await this.page.waitForTimeout(500);
await this.titleInput.fill(title);
await this.contentInput.fill(content);
await this.saveButton.click();
await this.page.waitForLoadState('networkidle');
}
async editNotification(title: string, newContent: string) {
const row = this.table.locator('tr').filter({ hasText: title }).first();
const editBtn = row.getByRole('button', { name: '编辑' });
await editBtn.click();
await this.page.waitForTimeout(500);
await this.contentInput.clear();
await this.contentInput.fill(newContent);
await this.saveButton.click();
await this.page.waitForLoadState('networkidle');
}
async deleteNotification(title: string) {
const row = this.table.locator('tr').filter({ hasText: title }).first();
const deleteBtn = row.getByRole('button', { name: '删除' });
await deleteBtn.click();
await this.page.waitForTimeout(500);
const confirmBtn = this.page.locator('.el-message-box').getByRole('button', { name: '确定' });
await confirmBtn.click();
await this.page.waitForLoadState('networkidle');
}
async getTableRowCount() {
const rows = await this.table.locator('.el-table__row').count();
return rows;
}
async verifyTableContains(text: string) {
await expect(this.table).toContainText(text);
}
}
@@ -0,0 +1,63 @@
import { Page, expect } from '@playwright/test';
export class OperationLogPage {
readonly page: Page;
readonly searchInput;
readonly searchButton;
readonly table;
readonly exportButton;
constructor(page: Page) {
this.page = page;
this.searchInput = page.getByPlaceholder('搜索操作人或操作模块');
this.searchButton = page.getByRole('button', { name: '搜索' });
this.table = page.locator('.el-table');
this.exportButton = page.getByRole('button', { name: '导出' });
}
async goto() {
try {
console.log('导航到操作日志页面...');
await this.page.goto('/oplog');
await this.page.waitForLoadState('networkidle');
await this.table.waitFor({ state: 'visible', timeout: 10000 });
await expect(this.page).toHaveURL(/.*oplog/);
console.log('操作日志页面加载完成');
} catch (error) {
await this.page.screenshot({ path: `test-results/operation-log-error-${Date.now()}.png` });
console.error('导航到操作日志页面失败:', error);
throw new Error(`导航到操作日志页面失败: ${error instanceof Error ? error.message : String(error)}`);
}
}
async searchByKeyword(keyword: string) {
await this.searchInput.fill(keyword);
await this.searchButton.click();
await this.page.waitForLoadState('networkidle');
}
async clearSearch() {
await this.searchInput.clear();
await this.searchButton.click();
await this.page.waitForLoadState('networkidle');
}
async verifyTableContains(text: string) {
await expect(this.table).toContainText(text);
}
async verifyTableNotContains(text: string) {
await expect(this.table).not.toContainText(text);
}
async getTableRowCount() {
const rows = await this.table.locator('.el-table__row').count();
return rows;
}
async exportData() {
await this.exportButton.click();
}
}
@@ -0,0 +1,251 @@
import { Page, Locator, expect } from '@playwright/test';
export class RoleManagementPage {
readonly page: Page;
readonly table: Locator;
readonly createRoleButton: Locator;
readonly successMessage: Locator;
readonly roleNameInput: Locator;
readonly roleKeyInput: Locator;
readonly roleSortInput: Locator;
readonly statusSelect: Locator;
readonly remarkInput: Locator;
readonly permissionDialog: Locator;
readonly savePermissionButton: Locator;
readonly searchInput: Locator;
readonly searchButton: Locator;
readonly pagination: Locator;
readonly nextPageButton: Locator;
readonly prevPageButton: Locator;
constructor(page: Page) {
this.page = page;
this.table = page.locator('.el-table').first();
this.createRoleButton = page.getByRole('button', { name: '新增角色' }).or(page.locator('button:has-text("新增角色")'));
this.successMessage = page.locator('.el-message--success').or(page.locator('.success-message'));
this.roleNameInput = page.locator('input[placeholder*="角色名称"]').or(page.locator('input[name*="roleName"]'));
this.roleKeyInput = page.locator('input[placeholder*="角色权限字符串"]').or(page.locator('input[name*="roleKey"]'));
this.roleSortInput = page.locator('input[placeholder*="显示顺序"]').or(page.locator('input[name*="roleSort"]'));
this.statusSelect = page.locator('select[name*="status"]').or(page.locator('.el-select'));
this.remarkInput = page.locator('textarea[placeholder*="备注"]').or(page.locator('textarea[name*="remark"]'));
this.permissionDialog = page.locator('.permission-dialog').or(page.locator('.el-dialog'));
this.savePermissionButton = page.getByRole('button', { name: '保存' }).or(page.locator('.permission-dialog .save-button'));
this.searchInput = page.locator('input[placeholder*="搜索角色名称或标识"]').or(page.locator('input[name*="keyword"]'));
this.searchButton = page.getByRole('button', { name: '搜索' }).or(page.locator('button:has-text("搜索")'));
this.pagination = page.locator('.el-pagination').or(page.locator('.pagination'));
this.nextPageButton = page.locator('.el-pagination .btn-next').or(page.locator('.pagination .next-page'));
this.prevPageButton = page.locator('.el-pagination .btn-prev').or(page.locator('.pagination .prev-page'));
}
async goto() {
try {
console.log('导航到角色管理页面...');
await this.page.goto('/roles');
await this.page.waitForLoadState('networkidle');
await this.table.waitFor({ state: 'visible', timeout: 10000 });
await expect(this.page).toHaveURL(/.*roles/);
console.log('角色管理页面加载完成');
} catch (error) {
await this.page.screenshot({ path: `test-results/role-management-error-${Date.now()}.png` });
console.error('导航到角色管理页面失败:', error);
throw new Error(`导航到角色管理页面失败: ${error instanceof Error ? error.message : String(error)}`);
}
}
async waitForTableReady() {
await this.table.waitFor({ state: 'visible', timeout: 10000 });
await this.page.waitForFunction(
() => {
const rows = document.querySelectorAll('.el-table__body-wrapper tbody tr');
return rows.length > 0;
},
{ timeout: 5000 }
).catch(() => {
console.log('表格没有数据,继续执行');
});
}
async clickCreateRole() {
await this.createRoleButton.click();
await this.page.waitForTimeout(500);
}
async fillRoleForm(roleData: {
roleName: string;
roleKey: string;
roleSort?: string;
status?: string;
remark?: string;
}) {
await this.page.locator('.el-dialog').locator('input').first().fill(roleData.roleName);
await this.page.locator('.el-dialog').locator('input').nth(1).fill(roleData.roleKey);
if (roleData.roleSort) {
const sortInput = this.page.locator('.el-dialog').locator('.el-input-number');
if (await sortInput.count() > 0) {
const input = sortInput.locator('input');
await input.fill(roleData.roleSort);
}
}
if (roleData.status) {
const statusSelect = this.page.locator('.el-dialog').locator('.el-form-item').filter({ hasText: '状态' }).locator('.el-select');
if (await statusSelect.count() > 0) {
await statusSelect.click();
await this.page.waitForTimeout(500);
const statusText = roleData.status === 'ACTIVE' ? '正常' : '禁用';
const dropdown = this.page.locator('.el-select-dropdown');
if (await dropdown.count() > 0) {
const options = dropdown.locator('.el-select-dropdown__item');
const optionCount = await options.count();
for (let i = 0; i < optionCount; i++) {
const optionText = await options.nth(i).textContent();
if (optionText && optionText.includes(statusText)) {
await options.nth(i).click();
break;
}
}
}
await this.page.waitForTimeout(300);
}
}
if (roleData.remark) {
await this.page.locator('.el-dialog').locator('textarea').fill(roleData.remark);
}
}
async submitForm() {
const dialog = this.page.locator('.el-dialog');
const submitButton = dialog.getByRole('button', { name: '确定' }).or(dialog.locator('button:has-text("确定")'));
await submitButton.click();
await this.page.waitForTimeout(1000);
}
async waitForSuccessMessage(timeout: number = 10000): Promise<boolean> {
try {
const message = this.page.locator('.el-message--success').or(this.page.locator('.el-message'));
await message.waitFor({ state: 'visible', timeout });
return true;
} catch (error) {
console.log('等待成功消息超时,检查是否有错误消息');
try {
const errorMessage = this.page.locator('.el-message--error').or(this.page.locator('.el-message--warning'));
if (await errorMessage.count() > 0) {
const errorText = await errorMessage.first().textContent();
console.log('发现错误消息:', errorText);
}
} catch (e) {
console.log('没有发现错误消息');
}
return false;
}
}
async editRole(rowNumber: number) {
await this.table.locator(`tbody tr:nth-child(${rowNumber})`).getByRole('button', { name: '编辑' }).or(this.page.locator(`tbody tr:nth-child(${rowNumber}) .edit-button`)).click();
}
async deleteRole(rowNumber: number) {
await this.table.locator(`tbody tr:nth-child(${rowNumber})`).getByRole('button', { name: '删除' }).or(this.page.locator(`tbody tr:nth-child(${rowNumber}) .delete-button`)).click();
}
async confirmDelete() {
await this.page.getByRole('button', { name: '确定' }).or(this.page.locator('.confirm-dialog .confirm-button')).click();
}
async openPermissionDialog(rowNumber: number) {
await this.table.locator(`tbody tr:nth-child(${rowNumber})`).getByRole('button', { name: '权限' }).or(this.page.locator(`tbody tr:nth-child(${rowNumber}) .permission-button`)).click();
}
async selectPermission(permissionValue: string) {
await this.page.click(`input[type="checkbox"][value="${permissionValue}"]`);
}
async savePermissions() {
await this.savePermissionButton.click();
}
async containsText(text: string): Promise<boolean> {
return await this.table.getByText(text).count() > 0;
}
async isSuccessMessageVisible(): Promise<boolean> {
try {
return await this.successMessage.isVisible({ timeout: 3000 });
} catch {
return false;
}
}
async reload() {
await this.page.reload();
}
async getRoleName(rowNumber: number): Promise<string | null> {
return await this.table.locator(`tbody tr:nth-child(${rowNumber}) td:first-child`).textContent();
}
async clickPermissionButton(rowNumber: number) {
await this.table.locator(`tbody tr:nth-child(${rowNumber})`).getByRole('button', { name: '权限' }).or(this.page.locator(`tbody tr:nth-child(${rowNumber}) .permission-button`)).click();
}
async deselectPermission(permissionValue: string) {
const checkbox = this.page.locator(`input[type="checkbox"][value="${permissionValue}"]`);
if (await checkbox.isChecked()) {
await checkbox.click();
}
}
async search(keyword: string) {
await this.searchInput.fill(keyword);
await this.searchButton.click();
}
async clearSearch() {
await this.searchInput.fill('');
await this.searchButton.click();
}
async clickStatusButton(rowNumber: number) {
const row = this.table.locator(`tbody tr:nth-child(${rowNumber})`);
await row.locator('.el-button--text').filter({ hasText: /状态|启用|禁用/ }).first().click();
}
async getCurrentPage(): Promise<string> {
try {
const activePage = this.page.locator('.el-pager li.is-active');
if (await activePage.count() > 0) {
return await activePage.textContent() || '1';
}
const currentPage = this.page.locator('.el-pagination__current');
if (await currentPage.count() > 0) {
return await currentPage.textContent() || '1';
}
return '1';
} catch (error) {
console.log('获取当前页码失败,返回默认值');
return '1';
}
}
async nextPage() {
await this.nextPageButton.click();
}
async prevPage() {
await this.prevPageButton.click();
}
}
@@ -0,0 +1,87 @@
import { Page, expect } from '@playwright/test';
export class SystemConfigPage {
readonly page: Page;
readonly table;
readonly addButton;
readonly saveButton;
readonly cancelButton;
readonly dialog;
readonly configNameInput;
readonly configKeyInput;
readonly configValueInput;
constructor(page: Page) {
this.page = page;
this.table = page.locator('.el-table');
this.addButton = page.getByRole('button', { name: '新增配置' });
this.saveButton = page.getByRole('button', { name: '确定' });
this.cancelButton = page.getByRole('button', { name: '取消' });
this.dialog = page.locator('.el-dialog');
this.configNameInput = page.locator('.el-dialog').getByRole('textbox', { name: '参数名称' });
this.configKeyInput = page.locator('.el-dialog').getByRole('textbox', { name: '参数键名' });
this.configValueInput = page.locator('.el-dialog').getByRole('textbox', { name: '参数值' });
}
async goto() {
try {
console.log('导航到系统配置页面...');
await this.page.goto('/sys/config');
await this.page.waitForLoadState('networkidle');
await this.table.waitFor({ state: 'visible', timeout: 10000 });
await expect(this.page).toHaveURL(/.*config/);
console.log('系统配置页面加载完成');
} catch (error) {
await this.page.screenshot({ path: `test-results/system-config-error-${Date.now()}.png` });
console.error('导航到系统配置页面失败:', error);
throw new Error(`导航到系统配置页面失败: ${error instanceof Error ? error.message : String(error)}`);
}
}
async addConfig(configName: string, configKey: string, configValue: string) {
await this.addButton.click();
await this.page.waitForTimeout(500);
await this.configNameInput.fill(configName);
await this.configKeyInput.fill(configKey);
await this.configValueInput.fill(configValue);
await this.saveButton.click();
await this.page.waitForLoadState('networkidle');
}
async editConfig(configKey: string, newValue: string) {
const row = this.table.locator('tr').filter({ hasText: configKey }).first();
const editBtn = row.getByRole('button', { name: '编辑' });
await editBtn.click();
await this.page.waitForTimeout(500);
await this.configValueInput.clear();
await this.configValueInput.fill(newValue);
await this.saveButton.click();
await this.page.waitForLoadState('networkidle');
}
async deleteConfig(configKey: string) {
const row = this.table.locator('tr').filter({ hasText: configKey }).first();
const deleteBtn = row.getByRole('button', { name: '删除' });
await deleteBtn.click();
await this.page.waitForTimeout(500);
const confirmBtn = this.page.locator('.el-message-box').getByRole('button', { name: '确定' });
await confirmBtn.click();
await this.page.waitForLoadState('networkidle');
}
async getTableRowCount() {
const rows = await this.table.locator('.el-table__row').count();
return rows;
}
async verifyTableContains(text: string) {
await expect(this.table).toContainText(text);
}
}
@@ -0,0 +1,296 @@
import { Page, Locator, expect } from '@playwright/test';
export class UserManagementPage {
readonly page: Page;
readonly table: Locator;
readonly createUserButton: Locator;
readonly searchInput: Locator;
readonly searchButton: Locator;
readonly successMessage: Locator;
readonly pagination: Locator;
readonly nextPageButton: Locator;
readonly prevPageButton: Locator;
constructor(page: Page) {
this.page = page;
this.table = page.locator('.el-table').first();
this.createUserButton = page.getByRole('button', { name: '新增用户' }).or(page.locator('button:has-text("新增用户")'));
this.searchInput = page.locator('input[placeholder*="搜索用户名或邮箱"]').or(page.locator('input[name*="keyword"]'));
this.searchButton = page.getByRole('button', { name: '搜索' }).or(page.locator('button:has-text("搜索")'));
this.successMessage = page.locator('.el-message--success').or(page.locator('.success-message'));
this.pagination = page.locator('.el-pagination').or(page.locator('.pagination'));
this.nextPageButton = page.locator('.el-pagination .btn-next').or(page.locator('.pagination .next-page'));
this.prevPageButton = page.locator('.el-pagination .btn-prev').or(page.locator('.pagination .prev-page'));
}
async goto() {
try {
console.log('导航到用户管理页面...');
await this.page.goto('/users');
await this.page.waitForLoadState('networkidle');
await this.table.waitFor({ state: 'visible', timeout: 10000 });
await expect(this.page).toHaveURL(/.*users/);
console.log('用户管理页面加载完成');
} catch (error) {
await this.page.screenshot({ path: `test-results/user-management-error-${Date.now()}.png` });
console.error('导航到用户管理页面失败:', error);
throw new Error(`导航到用户管理页面失败: ${error instanceof Error ? error.message : String(error)}`);
}
}
async waitForTableReady() {
await this.table.waitFor({ state: 'visible', timeout: 10000 });
await this.page.waitForFunction(
() => {
const rows = document.querySelectorAll('.el-table__body-wrapper tbody tr');
return rows.length > 0;
},
{ timeout: 5000 }
).catch(() => {
console.log('表格没有数据,继续执行');
});
}
async clickCreateUser() {
await this.createUserButton.click();
await this.page.waitForTimeout(500);
}
async fillUserForm(userData: {
username: string;
nickname?: string;
email: string;
phone?: string;
password: string;
confirmPassword?: string;
status?: string;
}) {
const dialog = this.page.locator('.el-dialog');
const isCreateMode = !userData.hasOwnProperty('id');
// 表单字段顺序:
// 创建模式:用户名(0), 密码(1), 昵称(2), 邮箱(3), 手机号(4)
// 编辑模式:用户名(0), 昵称(1), 邮箱(2), 手机号(3)
await dialog.locator('input').first().fill(userData.username);
if (isCreateMode && userData.password) {
await dialog.locator('input[type="password"]').fill(userData.password);
}
if (userData.nickname) {
const nicknameIndex = isCreateMode ? 2 : 1;
await dialog.locator('input').nth(nicknameIndex).fill(userData.nickname);
}
if (userData.email) {
const emailIndex = isCreateMode ? 3 : 2;
await dialog.locator('input').nth(emailIndex).fill(userData.email);
}
if (userData.phone) {
const phoneIndex = isCreateMode ? 4 : 3;
await dialog.locator('input').nth(phoneIndex).fill(userData.phone);
}
if (userData.status) {
const statusSelect = dialog.locator('.el-form-item').filter({ hasText: '状态' }).locator('.el-select');
if (await statusSelect.count() > 0) {
await statusSelect.click();
await this.page.waitForTimeout(500);
const statusText = userData.status === '1' || userData.status === 'ACTIVE' ? '正常' : '禁用';
const dropdown = this.page.locator('.el-select-dropdown');
if (await dropdown.count() > 0) {
const options = dropdown.locator('.el-select-dropdown__item');
const optionCount = await options.count();
for (let i = 0; i < optionCount; i++) {
const optionText = await options.nth(i).textContent();
if (optionText && optionText.includes(statusText)) {
await options.nth(i).click();
break;
}
}
}
await this.page.waitForTimeout(300);
}
}
}
async submitForm() {
const dialog = this.page.locator('.el-dialog');
const submitButton = dialog.getByRole('button', { name: '确定' }).or(dialog.locator('button:has-text("确定")'));
await submitButton.click();
await this.page.waitForTimeout(1000);
}
async waitForSuccessMessage(timeout: number = 10000): Promise<boolean> {
try {
const message = this.page.locator('.el-message--success').or(this.page.locator('.el-message'));
await message.waitFor({ state: 'visible', timeout });
return true;
} catch (error) {
console.log('等待成功消息超时,检查是否有错误消息');
try {
const errorMessage = this.page.locator('.el-message--error').or(this.page.locator('.el-message--warning'));
if (await errorMessage.count() > 0) {
const errorText = await errorMessage.first().textContent();
console.log('发现错误消息:', errorText);
}
} catch (e) {
console.log('没有发现错误消息');
}
return false;
}
}
async editUser(rowNumber: number) {
await this.table.locator(`tbody tr:nth-child(${rowNumber})`).getByRole('button', { name: '编辑' }).or(this.page.locator(`tbody tr:nth-child(${rowNumber}) .edit-button`)).click();
}
async deleteUser(rowNumber: number) {
await this.table.locator(`tbody tr:nth-child(${rowNumber})`).getByRole('button', { name: '删除' }).or(this.page.locator(`tbody tr:nth-child(${rowNumber}) .delete-button`)).click();
}
async confirmDelete() {
await this.page.getByRole('button', { name: '确定' }).or(this.page.locator('.confirm-dialog .confirm-button')).click();
}
async search(keyword: string) {
await this.searchInput.fill(keyword);
await this.searchButton.click();
}
async nextPage() {
await this.nextPageButton.click();
}
async prevPage() {
await this.prevPageButton.click();
}
async getCurrentPage(): Promise<string> {
try {
const activePage = this.page.locator('.el-pager li.is-active');
if (await activePage.count() > 0) {
return await activePage.textContent() || '1';
}
const currentPage = this.page.locator('.el-pagination__current');
if (await currentPage.count() > 0) {
return await currentPage.textContent() || '1';
}
return '1';
} catch (error) {
console.log('获取当前页码失败,返回默认值');
return '1';
}
}
async getUserCount(): Promise<number> {
return await this.table.locator('tbody tr').count();
}
async getUserName(rowNumber: number): Promise<string | null> {
return await this.table.locator(`tbody tr:nth-child(${rowNumber}) td:first-child`).textContent();
}
async containsText(text: string): Promise<boolean> {
return await this.table.getByText(text).count() > 0;
}
async isSuccessMessageVisible(): Promise<boolean> {
try {
return await this.successMessage.isVisible({ timeout: 3000 });
} catch {
return false;
}
}
async reload() {
await this.page.reload();
}
async clickStatusButton(rowNumber: number) {
const row = this.table.locator(`tbody tr:nth-child(${rowNumber})`);
await row.locator('.el-tag').first().click();
await this.page.waitForTimeout(500);
const dropdown = this.page.locator('.el-dropdown');
if (await dropdown.count() > 0) {
const options = dropdown.locator('.el-dropdown-menu__item');
const optionCount = await options.count();
for (let i = 0; i < optionCount; i++) {
const optionText = await options.nth(i).textContent();
if (optionText && (optionText.includes('启用') || optionText.includes('禁用'))) {
await options.nth(i).click();
break;
}
}
}
await this.page.waitForTimeout(300);
}
async clickEditButton(rowNumber: number) {
await this.table.locator(`tbody tr:nth-child(${rowNumber})`).getByRole('button', { name: '编辑' }).or(this.page.locator(`tbody tr:nth-child(${rowNumber}) .edit-button`)).click();
}
async clickDeleteButton(rowNumber: number) {
await this.table.locator(`tbody tr:nth-child(${rowNumber})`).getByRole('button', { name: '删除' }).or(this.page.locator(`tbody tr:nth-child(${rowNumber}) .delete-button`)).click();
}
async fillNickname(nickname: string) {
const dialog = this.page.locator('.el-dialog');
await dialog.locator('input').nth(1).fill(nickname);
}
async selectRole(roleName: string) {
const dialog = this.page.locator('.el-dialog');
const roleSelect = dialog.locator('.el-select');
if (await roleSelect.count() > 0) {
await roleSelect.first().click();
await this.page.waitForTimeout(500);
const dropdown = this.page.locator('.el-select-dropdown');
if (await dropdown.count() > 0) {
const options = dropdown.locator('.el-select-dropdown__item');
const optionCount = await options.count();
for (let i = 0; i < optionCount; i++) {
const optionText = await options.nth(i).textContent();
if (optionText && optionText.includes(roleName)) {
await options.nth(i).click();
break;
}
}
}
await this.page.waitForTimeout(300);
}
}
async clearSearch() {
await this.searchInput.fill('');
await this.searchButton.click();
}
async getTableRowCount(): Promise<number> {
return await this.table.locator('tbody tr').count();
}
}
@@ -0,0 +1,39 @@
import { test, expect } from '@playwright/test';
test.describe('冒烟测试 - 基础流程', () => {
test('管理员登录和登出', async ({ page }) => {
await test.step('导航到登录页面', async () => {
await page.goto('/login');
await page.waitForLoadState('networkidle');
});
await test.step('输入登录信息', async () => {
await page.fill('input[type="text"]', 'admin');
await page.fill('input[type="password"]', 'Test@123');
});
await test.step('点击登录按钮', async () => {
await page.click('button:has-text("登录")');
await page.waitForURL(/.*dashboard/, { timeout: 10000 });
});
await test.step('验证登录成功', async () => {
await expect(page).toHaveURL(/.*dashboard/);
});
await test.step('点击用户菜单', async () => {
const avatarButton = page.locator('.el-avatar').first();
await avatarButton.click();
await page.waitForTimeout(500);
});
await test.step('点击退出登录', async () => {
await page.click('text=退出登录');
await page.waitForURL(/.*login/, { timeout: 10000 });
});
await test.step('验证登出成功', async () => {
await expect(page).toHaveURL(/.*login/);
});
});
});
+288
View File
@@ -0,0 +1,288 @@
export class RetryHelper {
static async retry<T>(
fn: () => Promise<T>,
options: {
maxAttempts?: number;
delay?: number;
backoff?: boolean;
onRetry?: (attempt: number, error: Error) => void;
} = {}
): Promise<T> {
const {
maxAttempts = 3,
delay = 1000,
backoff = true,
onRetry
} = options;
let lastError: Error | undefined;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error as Error;
if (attempt === maxAttempts) {
throw lastError;
}
if (onRetry) {
onRetry(attempt, lastError);
}
const currentDelay = backoff ? delay * attempt : delay;
await this.sleep(currentDelay);
}
}
throw lastError!;
}
static async retryWithCondition<T>(
fn: () => Promise<T>,
condition: (result: T) => boolean,
options: {
maxAttempts?: number;
delay?: number;
timeout?: number;
onRetry?: (attempt: number, lastResult: T) => void;
} = {}
): Promise<T> {
const {
maxAttempts = 10,
delay = 500,
timeout = 10000,
onRetry
} = options;
const startTime = Date.now();
let lastResult: T | undefined;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
lastResult = await fn();
if (condition(lastResult)) {
return lastResult;
}
if (Date.now() - startTime > timeout) {
throw new Error(`Timeout after ${timeout}ms waiting for condition to be met`);
}
if (onRetry && lastResult !== undefined) {
onRetry(attempt, lastResult);
}
await this.sleep(delay);
} catch (error) {
if (Date.now() - startTime > timeout) {
throw new Error(`Timeout after ${timeout}ms: ${error}`);
}
await this.sleep(delay);
}
}
throw new Error(`Condition not met after ${maxAttempts} attempts`);
}
static async retryElementAction<T>(
fn: () => Promise<T>,
options: {
maxAttempts?: number;
delay?: number;
ignoreErrors?: string[];
} = {}
): Promise<T> {
const {
maxAttempts = 3,
delay = 1000,
ignoreErrors = ['Timeout', 'Element not found', 'Element not visible']
} = options;
return this.retry(fn, {
maxAttempts,
delay,
backoff: true,
onRetry: (attempt, error) => {
const shouldIgnore = ignoreErrors.some(ignoredError =>
error.message.includes(ignoredError)
);
if (shouldIgnore) {
console.log(`Attempt ${attempt} failed with ignorable error: ${error.message}`);
}
}
});
}
static async retryNetworkRequest<T>(
fn: () => Promise<T>,
options: {
maxAttempts?: number;
delay?: number;
retryableStatuses?: number[];
} = {}
): Promise<T> {
const {
maxAttempts = 3,
delay = 2000,
retryableStatuses = [408, 429, 500, 502, 503, 504]
} = options;
return this.retry(fn, {
maxAttempts,
delay,
backoff: true,
onRetry: (attempt, error) => {
console.log(`Network request attempt ${attempt} failed: ${error.message}`);
}
});
}
static async retryClick(
clickFn: () => Promise<void>,
options: {
maxAttempts?: number;
delay?: number;
} = {}
): Promise<void> {
const { maxAttempts = 3, delay = 500 } = options;
return this.retry(clickFn, {
maxAttempts,
delay,
backoff: false,
onRetry: (attempt, error) => {
console.log(`Click attempt ${attempt} failed: ${error.message}`);
}
});
}
static async retryFill(
fillFn: () => Promise<void>,
options: {
maxAttempts?: number;
delay?: number;
} = {}
): Promise<void> {
const { maxAttempts = 3, delay = 500 } = options;
return this.retry(fillFn, {
maxAttempts,
delay,
backoff: false,
onRetry: (attempt, error) => {
console.log(`Fill attempt ${attempt} failed: ${error.message}`);
}
});
}
static async retryNavigation(
navigateFn: () => Promise<void>,
options: {
maxAttempts?: number;
delay?: number;
} = {}
): Promise<void> {
const { maxAttempts = 3, delay = 1000 } = options;
return this.retry(navigateFn, {
maxAttempts,
delay,
backoff: true,
onRetry: (attempt, error) => {
console.log(`Navigation attempt ${attempt} failed: ${error.message}`);
}
});
}
static async retryAssertion<T>(
assertionFn: () => Promise<T>,
options: {
maxAttempts?: number;
delay?: number;
} = {}
): Promise<T> {
const { maxAttempts = 5, delay = 500 } = options;
return this.retry(assertionFn, {
maxAttempts,
delay,
backoff: false,
onRetry: (attempt, error) => {
console.log(`Assertion attempt ${attempt} failed: ${error.message}`);
}
});
}
private static sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
static createRetryPolicy<T>(
fn: () => Promise<T>,
policy: {
maxAttempts: number;
initialDelay: number;
maxDelay?: number;
backoffMultiplier?: number;
retryCondition?: (error: Error) => boolean;
}
): () => Promise<T> {
const {
maxAttempts,
initialDelay,
maxDelay = 30000,
backoffMultiplier = 2,
retryCondition
} = policy;
return async () => {
let currentDelay = initialDelay;
let lastError: Error | undefined;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error as Error;
if (retryCondition && !retryCondition(lastError)) {
throw lastError;
}
if (attempt === maxAttempts) {
throw lastError;
}
console.log(`Attempt ${attempt}/${maxAttempts} failed: ${lastError.message}`);
await this.sleep(currentDelay);
currentDelay = Math.min(currentDelay * backoffMultiplier, maxDelay);
}
}
throw lastError!;
};
}
static async retryWithTimeout<T>(
fn: () => Promise<T>,
timeout: number,
options: {
maxAttempts?: number;
delay?: number;
} = {}
): Promise<T> {
const { maxAttempts = 3, delay = 1000 } = options;
return Promise.race([
this.retry(fn, { maxAttempts, delay }),
new Promise<T>((_, reject) =>
setTimeout(() => reject(new Error(`Operation timed out after ${timeout}ms`)), timeout)
)
]);
}
}
+221
View File
@@ -0,0 +1,221 @@
import { Page } from '@playwright/test';
export class TestDataCleanup {
readonly page: Page;
private createdUsers: string[] = [];
private createdRoles: string[] = [];
private createdMenus: string[] = [];
private createdDictTypes: string[] = [];
private createdDictData: string[] = [];
constructor(page: Page) {
this.page = page;
}
trackUser(username: string) {
this.createdUsers.push(username);
}
trackRole(roleName: string) {
this.createdRoles.push(roleName);
}
trackMenu(menuName: string) {
this.createdMenus.push(menuName);
}
trackDictType(dictType: string) {
this.createdDictTypes.push(dictType);
}
trackDictData(dictData: string) {
this.createdDictData.push(dictData);
}
async cleanupAll() {
await this.cleanupUsers();
await this.cleanupRoles();
await this.cleanupMenus();
await this.cleanupDictTypes();
await this.cleanupDictData();
}
async cleanupUsers() {
for (const username of this.createdUsers) {
try {
await this.deleteUser(username);
} catch (error) {
console.warn(`Failed to delete user ${username}:`, error);
}
}
this.createdUsers = [];
}
async cleanupRoles() {
for (const roleName of this.createdRoles) {
try {
await this.deleteRole(roleName);
} catch (error) {
console.warn(`Failed to delete role ${roleName}:`, error);
}
}
this.createdRoles = [];
}
async cleanupMenus() {
for (const menuName of this.createdMenus) {
try {
await this.deleteMenu(menuName);
} catch (error) {
console.warn(`Failed to delete menu ${menuName}:`, error);
}
}
this.createdMenus = [];
}
async cleanupDictTypes() {
for (const dictType of this.createdDictTypes) {
try {
await this.deleteDictType(dictType);
} catch (error) {
console.warn(`Failed to delete dict type ${dictType}:`, error);
}
}
this.createdDictTypes = [];
}
async cleanupDictData() {
for (const dictData of this.createdDictData) {
try {
await this.deleteDictData(dictData);
} catch (error) {
console.warn(`Failed to delete dict data ${dictData}:`, error);
}
}
this.createdDictData = [];
}
private async deleteUser(username: string) {
try {
await this.page.goto('/users');
await this.page.waitForLoadState('networkidle', { timeout: 10000 });
const searchInput = this.page.locator('input[placeholder*="搜索"], input[name*="keyword"], .el-input__inner').first();
await searchInput.fill(username);
const searchButton = this.page.getByRole('button', { name: '搜索' }).or(this.page.locator('button:has-text("搜索")'));
await searchButton.click();
await this.page.waitForTimeout(2000);
const userRow = this.page.locator('tbody tr').filter({ hasText: username });
const rowCount = await userRow.count();
if (rowCount > 0) {
const deleteButton = userRow.locator('.delete-button, .el-button--danger').first();
await deleteButton.click();
await this.page.waitForTimeout(500);
const confirmButton = this.page.getByRole('button', { name: '确定' }).or(this.page.locator('.el-button--primary:has-text("确定")'));
await confirmButton.click();
await this.page.waitForTimeout(1500);
}
} catch (error) {
console.warn(`Failed to delete user ${username}:`, error);
}
}
private async deleteRole(roleName: string) {
try {
await this.page.goto('/roles');
await this.page.waitForLoadState('networkidle', { timeout: 10000 });
const searchInput = this.page.locator('input[placeholder*="搜索"], input[name*="keyword"], .el-input__inner').first();
await searchInput.fill(roleName);
const searchButton = this.page.getByRole('button', { name: '搜索' }).or(this.page.locator('button:has-text("搜索")'));
await searchButton.click();
await this.page.waitForTimeout(2000);
const roleRow = this.page.locator('tbody tr').filter({ hasText: roleName });
const rowCount = await roleRow.count();
if (rowCount > 0) {
const deleteButton = roleRow.locator('.delete-button, .el-button--danger').first();
await deleteButton.click();
await this.page.waitForTimeout(500);
const confirmButton = this.page.getByRole('button', { name: '确定' }).or(this.page.locator('.el-button--primary:has-text("确定")'));
await confirmButton.click();
await this.page.waitForTimeout(1500);
}
} catch (error) {
console.warn(`Failed to delete role ${roleName}:`, error);
}
}
private async deleteMenu(menuName: string) {
try {
await this.page.goto('/menus');
await this.page.waitForLoadState('networkidle', { timeout: 10000 });
const menuRow = this.page.locator('tbody tr').filter({ hasText: menuName });
const rowCount = await menuRow.count();
if (rowCount > 0) {
const deleteButton = menuRow.locator('.delete-button, .el-button--danger').first();
await deleteButton.click();
await this.page.waitForTimeout(500);
const confirmButton = this.page.getByRole('button', { name: '确定' }).or(this.page.locator('.el-button--primary:has-text("确定")'));
await confirmButton.click();
await this.page.waitForTimeout(1500);
}
} catch (error) {
console.warn(`Failed to delete menu ${menuName}:`, error);
}
}
private async deleteDictType(dictType: string) {
try {
await this.page.goto('/dict');
await this.page.waitForLoadState('networkidle', { timeout: 10000 });
const dictRow = this.page.locator('.dict-type-table tbody tr').filter({ hasText: dictType });
const rowCount = await dictRow.count();
if (rowCount > 0) {
const deleteButton = dictRow.locator('.delete-button, .el-button--danger').first();
await deleteButton.click();
await this.page.waitForTimeout(500);
const confirmButton = this.page.getByRole('button', { name: '确定' }).or(this.page.locator('.el-button--primary:has-text("确定")'));
await confirmButton.click();
await this.page.waitForTimeout(1500);
}
} catch (error) {
console.warn(`Failed to delete dict type ${dictType}:`, error);
}
}
private async deleteDictData(dictData: string) {
try {
await this.page.goto('/dict');
await this.page.waitForLoadState('networkidle', { timeout: 10000 });
const dictRow = this.page.locator('.dict-data-table tbody tr').filter({ hasText: dictData });
const rowCount = await dictRow.count();
if (rowCount > 0) {
const deleteButton = dictRow.locator('.delete-button, .el-button--danger').first();
await deleteButton.click();
await this.page.waitForTimeout(500);
const confirmButton = this.page.getByRole('button', { name: '确定' }).or(this.page.locator('.el-button--primary:has-text("确定")'));
await confirmButton.click();
await this.page.waitForTimeout(1500);
}
} catch (error) {
console.warn(`Failed to delete dict data ${dictData}:`, error);
}
}
}
+255
View File
@@ -0,0 +1,255 @@
export interface UserData {
username: string;
nickname: string;
email: string;
phone: string;
password: string;
confirmPassword: string;
}
export interface RoleData {
roleName: string;
roleKey: string;
roleSort: number;
status: string;
}
export interface MenuData {
menuName: string;
menuType?: string;
path?: string;
component?: string;
permission?: string;
sort?: number;
visible?: string;
status?: string;
}
export interface DictTypeData {
dictName: string;
dictType: string;
status: string;
remark?: string;
}
export interface DictDataData {
dictLabel: string;
dictValue: string;
dictType: string;
status: string;
sort?: number;
}
export class TestDataFactory {
static generateTimestamp(): string {
return Date.now().toString();
}
static generateRandomString(length: number = 8): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
static generateValidEmail(username: string): string {
return `${username}@example.com`;
}
static generateValidPhone(): 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;
}
static generateValidPassword(): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*';
let password = '';
for (let i = 0; i < 12; i++) {
password += chars.charAt(Math.floor(Math.random() * chars.length));
}
return password;
}
static createUser(suffix?: string): UserData {
const timestamp = this.generateTimestamp();
const uniqueSuffix = suffix || this.generateRandomString(4);
return {
username: `testuser_${uniqueSuffix}_${timestamp}`,
nickname: `测试用户_${uniqueSuffix}_${timestamp}`,
email: this.generateValidEmail(`testuser_${uniqueSuffix}_${timestamp}`),
phone: this.generateValidPhone(),
password: this.generateValidPassword(),
confirmPassword: this.generateValidPassword()
};
}
static createAdminUser(): UserData {
return {
username: 'admin',
nickname: '管理员',
email: 'admin@example.com',
phone: '13800138000',
password: 'admin123',
confirmPassword: 'admin123'
};
}
static createRole(suffix?: string): RoleData {
const timestamp = this.generateTimestamp();
const uniqueSuffix = suffix || this.generateRandomString(4);
return {
roleName: `testrole_${uniqueSuffix}_${timestamp}`,
roleKey: `test_role_${uniqueSuffix}_${timestamp}`,
roleSort: 1,
status: '1'
};
}
static createAdminRole(): RoleData {
return {
roleName: '管理员',
roleKey: 'admin',
roleSort: 1,
status: '1'
};
}
static createMenu(suffix?: string, parentId?: string): MenuData {
const timestamp = this.generateTimestamp();
const uniqueSuffix = suffix || this.generateRandomString(4);
return {
menuName: `测试菜单_${uniqueSuffix}_${timestamp}`,
menuType: 'M',
path: `/testmenu_${uniqueSuffix}_${timestamp}`,
component: `TestMenu${uniqueSuffix}`,
permission: `system:testmenu:${uniqueSuffix}:${timestamp}`,
sort: 1,
visible: '0',
status: '0'
};
}
static createSubMenu(parentId: string, suffix?: string): MenuData {
const menuData = this.createMenu(suffix);
menuData.menuType = 'C';
menuData.path = `${menuData.path}/submenu`;
return menuData;
}
static createDictType(suffix?: string): DictTypeData {
const timestamp = this.generateTimestamp();
const uniqueSuffix = suffix || this.generateRandomString(4);
return {
dictName: `测试字典类型_${uniqueSuffix}_${timestamp}`,
dictType: `test_dict_type_${uniqueSuffix}_${timestamp}`,
status: '0',
remark: `测试字典类型备注_${uniqueSuffix}_${timestamp}`
};
}
static createDictData(dictType: string, suffix?: string): DictDataData {
const timestamp = this.generateTimestamp();
const uniqueSuffix = suffix || this.generateRandomString(4);
return {
dictLabel: `测试字典数据_${uniqueSuffix}_${timestamp}`,
dictValue: `test_dict_value_${uniqueSuffix}_${timestamp}`,
dictType: dictType,
status: '0',
sort: 1
};
}
static createBatchUsers(count: number): UserData[] {
const users: UserData[] = [];
for (let i = 0; i < count; i++) {
users.push(this.createUser(`batch_${i}`));
}
return users;
}
static createBatchRoles(count: number): RoleData[] {
const roles: RoleData[] = [];
for (let i = 0; i < count; i++) {
roles.push(this.createRole(`batch_${i}`));
}
return roles;
}
static createBatchMenus(count: number): MenuData[] {
const menus: MenuData[] = [];
for (let i = 0; i < count; i++) {
menus.push(this.createMenu(`batch_${i}`));
}
return menus;
}
static createBatchDictTypes(count: number): DictTypeData[] {
const dictTypes: DictTypeData[] = [];
for (let i = 0; i < count; i++) {
dictTypes.push(this.createDictType(`batch_${i}`));
}
return dictTypes;
}
static createBatchDictData(dictType: string, count: number): DictDataData[] {
const dictData: DictDataData[] = [];
for (let i = 0; i < count; i++) {
dictData.push(this.createDictData(dictType, `batch_${i}`));
}
return dictData;
}
static createInvalidUser(): UserData {
return {
username: '',
nickname: '',
email: 'invalid-email',
phone: 'invalid-phone',
password: 'weak',
confirmPassword: 'different'
};
}
static createInvalidRole(): RoleData {
return {
roleName: '',
roleKey: '',
roleSort: -1,
status: 'invalid'
};
}
static createInvalidMenu(): MenuData {
return {
menuName: '',
menuType: 'invalid',
path: '',
component: '',
permission: '',
sort: -1,
visible: 'invalid',
status: 'invalid'
};
}
static createLongString(length: number = 1000): string {
return this.generateRandomString(length);
}
static createSpecialCharsString(): string {
return '!@#$%^&*()_+-=[]{}|;:,.<>?/~`';
}
static createUnicodeString(): string {
return '测试中文🎉🚀';
}
}
+283
View File
@@ -0,0 +1,283 @@
import { Page, Locator } from '@playwright/test';
export class TestHelpers {
static async waitForElementVisible(locator: Locator, timeout: number = 5000): Promise<boolean> {
try {
await locator.waitFor({ state: 'visible', timeout });
return true;
} catch {
return false;
}
}
static async waitForElementHidden(locator: Locator, timeout: number = 5000): Promise<boolean> {
try {
await locator.waitFor({ state: 'hidden', timeout });
return true;
} catch {
return false;
}
}
static async safeClick(locator: Locator, timeout: number = 5000): Promise<boolean> {
try {
await locator.waitFor({ state: 'visible', timeout });
await locator.click();
return true;
} catch (error) {
console.warn('Safe click failed:', error);
return false;
}
}
static async safeFill(locator: Locator, value: string, timeout: number = 5000): Promise<boolean> {
try {
await locator.waitFor({ state: 'visible', timeout });
await locator.clear();
await locator.fill(value);
return true;
} catch (error) {
console.warn('Safe fill failed:', error);
return false;
}
}
static async safeSelect(locator: Locator, value: string, timeout: number = 5000): Promise<boolean> {
try {
await locator.waitFor({ state: 'visible', timeout });
await locator.selectOption(value);
return true;
} catch (error) {
console.warn('Safe select failed:', error);
return false;
}
}
static async retryOperation<T>(
operation: () => Promise<T>,
maxRetries: number = 3,
delayMs: number = 1000
): Promise<T | null> {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await operation();
} catch (error) {
if (attempt === maxRetries) {
console.error(`Operation failed after ${maxRetries} attempts:`, error);
return null;
}
console.log(`Attempt ${attempt} failed, retrying in ${delayMs}ms...`);
await new Promise(resolve => setTimeout(resolve, delayMs));
}
}
return null;
}
static async waitForNetworkIdle(page: Page, timeout: number = 10000): Promise<void> {
try {
await page.waitForLoadState('networkidle', { timeout });
} catch (error) {
console.warn('Network idle timeout, continuing...');
}
}
static async waitForNavigation(page: Page, urlPattern: RegExp, timeout: number = 10000): Promise<boolean> {
try {
await page.waitForURL(urlPattern, { timeout });
return true;
} catch {
return false;
}
}
static async handleDialog(page: Page, action: 'accept' | 'dismiss' = 'accept'): Promise<void> {
page.on('dialog', async dialog => {
if (action === 'accept') {
await dialog.accept();
} else {
await dialog.dismiss();
}
});
}
static async getTableData(table: Locator): Promise<string[][]> {
const rows = await table.locator('tbody tr').all();
const data: string[][] = [];
for (const row of rows) {
const cells = await row.locator('td').allTextContents();
data.push(cells);
}
return data;
}
static async findTableRowByContent(table: Locator, content: string): Promise<Locator | null> {
const rows = await table.locator('tbody tr').all();
for (const row of rows) {
const textContent = await row.textContent();
if (textContent && textContent.includes(content)) {
return row;
}
}
return null;
}
static async scrollToElement(page: Page, locator: Locator): Promise<void> {
await locator.scrollIntoViewIfNeeded();
await page.waitForTimeout(300);
}
static async waitForAnimation(locator: Locator): Promise<void> {
await locator.waitFor({ state: 'attached' });
await locator.evaluate(el => {
return new Promise(resolve => {
requestAnimationFrame(() => {
setTimeout(resolve, 300);
});
});
});
}
static async takeScreenshot(page: Page, name: string): Promise<void> {
await page.screenshot({ path: `test-results/screenshots/${name}.png`, fullPage: true });
}
static async waitForPageLoad(page: Page, timeout: number = 10000): Promise<void> {
try {
await page.waitForLoadState('load', { timeout });
} catch (error) {
console.warn('Page load timeout, continuing...');
}
}
static async waitForDOMContent(page: Page, timeout: number = 10000): Promise<void> {
try {
await page.waitForLoadState('domcontentloaded', { timeout });
} catch (error) {
console.warn('DOM content load timeout, continuing...');
}
}
static async isElementVisible(locator: Locator): Promise<boolean> {
try {
return await locator.isVisible({ timeout: 1000 });
} catch {
return false;
}
}
static async isElementEnabled(locator: Locator): Promise<boolean> {
try {
return await locator.isEnabled({ timeout: 1000 });
} catch {
return false;
}
}
static async getElementText(locator: Locator): Promise<string | null> {
try {
return await locator.textContent({ timeout: 5000 });
} catch {
return null;
}
}
static async getElementCount(locator: Locator): Promise<number> {
try {
return await locator.count();
} catch {
return 0;
}
}
static async waitForTextContent(locator: Locator, expectedText: string, timeout: number = 5000): Promise<boolean> {
try {
await locator.waitFor({ state: 'visible', timeout });
const text = await locator.textContent();
return text !== null && text.includes(expectedText);
} catch {
return false;
}
}
static async clearInput(locator: Locator): Promise<void> {
await locator.click();
await locator.fill('');
await locator.press('Control+A');
await locator.press('Backspace');
}
static async waitForSuccessMessage(page: Page, timeout: number = 5000): Promise<boolean> {
const successMessage = page.locator('.el-message--success, .success-message, [class*="success"]');
try {
await successMessage.waitFor({ state: 'visible', timeout });
return true;
} catch {
return false;
}
}
static async waitForErrorMessage(page: Page, timeout: number = 5000): Promise<boolean> {
const errorMessage = page.locator('.el-message--error, .error-message, [class*="error"]');
try {
await errorMessage.waitFor({ state: 'visible', timeout });
return true;
} catch {
return false;
}
}
static async waitForLoadingComplete(page: Page, timeout: number = 10000): Promise<void> {
const loadingSpinner = page.locator('.el-loading-mask, .loading, [class*="loading"]');
try {
await loadingSpinner.waitFor({ state: 'visible', timeout: 2000 });
await loadingSpinner.waitFor({ state: 'hidden', timeout });
} catch {
console.log('No loading spinner found or already hidden');
}
}
static async waitForModal(page: Page, timeout: number = 5000): Promise<boolean> {
const modal = page.locator('.el-dialog, .modal, [role="dialog"]');
try {
await modal.waitFor({ state: 'visible', timeout });
return true;
} catch {
return false;
}
}
static async closeModal(page: Page): Promise<boolean> {
const closeButton = page.locator('.el-dialog__close, .modal-close, button[aria-label="Close"]');
try {
await closeButton.click();
return true;
} catch {
return false;
}
}
static async waitForSelectDropdown(page: Page, timeout: number = 5000): Promise<boolean> {
const dropdown = page.locator('.el-select-dropdown, .select-dropdown');
try {
await dropdown.waitFor({ state: 'visible', timeout });
return true;
} catch {
return false;
}
}
static async selectFromDropdown(page: Page, value: string): Promise<boolean> {
const option = page.locator('.el-select-dropdown__item, .select-option').filter({ hasText: value });
try {
await option.click();
return true;
} catch {
return false;
}
}
}
+159
View File
@@ -0,0 +1,159 @@
import { APIRequestContext } from '@playwright/test';
export class ApiClient {
private request: APIRequestContext;
private baseURL: string;
constructor(request: APIRequestContext, baseURL: string = 'http://localhost:8084') {
this.request = request;
this.baseURL = baseURL;
}
async login(username: string, password: string): Promise<{ token: string; userId: number }> {
const response = await this.request.post(`${this.baseURL}/api/auth/login`, {
data: {
username,
password,
},
});
if (!response.ok()) {
throw new Error(`Login failed: ${response.status()}`);
}
const data = await response.json();
return {
token: data.token,
userId: data.userId,
};
}
async logout(token: string): Promise<void> {
await this.request.post(`${this.baseURL}/api/auth/logout`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
}
async getUsers(token: string): Promise<any[]> {
const response = await this.request.get(`${this.baseURL}/api/users`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok()) {
throw new Error(`Get users failed: ${response.status()}`);
}
return await response.json();
}
async createUser(token: string, userData: any): Promise<any> {
const response = await this.request.post(`${this.baseURL}/api/users`, {
headers: {
Authorization: `Bearer ${token}`,
},
data: userData,
});
if (!response.ok()) {
throw new Error(`Create user failed: ${response.status()}`);
}
return await response.json();
}
async updateUser(token: string, userId: number, userData: any): Promise<any> {
const response = await this.request.put(`${this.baseURL}/api/users/${userId}`, {
headers: {
Authorization: `Bearer ${token}`,
},
data: userData,
});
if (!response.ok()) {
throw new Error(`Update user failed: ${response.status()}`);
}
return await response.json();
}
async deleteUser(token: string, userId: number): Promise<void> {
const response = await this.request.delete(`${this.baseURL}/api/users/${userId}`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok()) {
throw new Error(`Delete user failed: ${response.status()}`);
}
}
async getRoles(token: string): Promise<any[]> {
const response = await this.request.get(`${this.baseURL}/api/roles`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok()) {
throw new Error(`Get roles failed: ${response.status()}`);
}
return await response.json();
}
async createRole(token: string, roleData: any): Promise<any> {
const response = await this.request.post(`${this.baseURL}/api/roles`, {
headers: {
Authorization: `Bearer ${token}`,
},
data: roleData,
});
if (!response.ok()) {
throw new Error(`Create role failed: ${response.status()}`);
}
return await response.json();
}
async deleteRole(token: string, roleId: number): Promise<void> {
const response = await this.request.delete(`${this.baseURL}/api/roles/${roleId}`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok()) {
throw new Error(`Delete role failed: ${response.status()}`);
}
}
async getMenus(token: string): Promise<any[]> {
const response = await this.request.get(`${this.baseURL}/api/menus`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok()) {
throw new Error(`Get menus failed: ${response.status()}`);
}
return await response.json();
}
async healthCheck(): Promise<{ status: string }> {
const response = await this.request.get(`${this.baseURL}/actuator/health`);
if (!response.ok()) {
throw new Error(`Health check failed: ${response.status()}`);
}
return await response.json();
}
}
+10
View File
@@ -0,0 +1,10 @@
export { TestDataCleanup } from './TestDataCleanup';
export { TestDataFactory } from './TestDataFactory';
export { RetryHelper } from './RetryHelper';
export type {
UserData,
RoleData,
MenuData,
DictTypeData,
DictDataData
} from './TestDataFactory';
+181
View File
@@ -0,0 +1,181 @@
import { APIRequestContext } from '@playwright/test';
export interface TestUser {
username: string;
nickname?: string;
email: string;
phone: string;
password: string;
roleIds?: number[];
}
export interface TestRole {
roleName: string;
roleKey: string;
roleSort: string;
status: string;
remark?: string;
}
export class TestDataManager {
private static testData: Map<string, any> = new Map();
private static apiBaseUrl: string;
static initialize(apiBaseUrl: string = 'http://localhost:8084') {
this.apiBaseUrl = apiBaseUrl;
}
static generateTimestamp(): string {
return Date.now().toString();
}
static generateTestUser(override?: Partial<TestUser>): TestUser {
const timestamp = this.generateTimestamp();
return {
username: `testuser_${timestamp}`,
nickname: `测试用户${timestamp}`,
email: `test_${timestamp}@example.com`,
phone: '13800138000',
password: 'Test123!@#',
roleIds: [],
...override,
};
}
static generateTestRole(override?: Partial<TestRole>): TestRole {
const timestamp = this.generateTimestamp();
return {
roleName: `测试角色_${timestamp}`,
roleKey: `test_role_${timestamp}`,
roleSort: '1',
status: '1',
remark: `测试角色备注_${timestamp}`,
...override,
};
}
static async createTestUser(request: APIRequestContext, userData: TestUser): Promise<any> {
const response = await request.post(`${this.apiBaseUrl}/api/users`, {
data: userData,
});
if (!response.ok()) {
throw new Error(`Failed to create test user: ${await response.text()}`);
}
const result = await response.json();
const userId = result.data?.id || result.id;
this.testData.set(`user_${userData.username}`, {
id: userId,
...userData,
});
return result;
}
static async createTestRole(request: APIRequestContext, roleData: TestRole): Promise<any> {
const response = await request.post(`${this.apiBaseUrl}/api/roles`, {
data: roleData,
});
if (!response.ok()) {
throw new Error(`Failed to create test role: ${await response.text()}`);
}
const result = await response.json();
const roleId = result.data?.id || result.id;
this.testData.set(`role_${roleData.roleKey}`, {
id: roleId,
...roleData,
});
return result;
}
static async deleteTestUser(request: APIRequestContext, username: string): Promise<void> {
const userData = this.testData.get(`user_${username}`);
if (!userData || !userData.id) {
return;
}
const response = await request.delete(`${this.apiBaseUrl}/api/users/${userData.id}`);
if (!response.ok()) {
console.warn(`Failed to delete test user ${username}: ${await response.text()}`);
}
this.testData.delete(`user_${username}`);
}
static async deleteTestRole(request: APIRequestContext, roleKey: string): Promise<void> {
const roleData = this.testData.get(`role_${roleKey}`);
if (!roleData || !roleData.id) {
return;
}
const response = await request.delete(`${this.apiBaseUrl}/api/roles/${roleData.id}`);
if (!response.ok()) {
console.warn(`Failed to delete test role ${roleKey}: ${await response.text()}`);
}
this.testData.delete(`role_${roleKey}`);
}
static async cleanupTestData(request: APIRequestContext): Promise<void> {
const cleanupPromises: Promise<void>[] = [];
const entries = Array.from(this.testData.entries());
for (const [key, data] of entries) {
if (key.startsWith('user_')) {
cleanupPromises.push(this.deleteTestUser(request, data.username));
} else if (key.startsWith('role_')) {
cleanupPromises.push(this.deleteTestRole(request, data.roleKey));
}
}
await Promise.allSettled(cleanupPromises);
this.testData.clear();
}
static getTestData(key: string): any {
return this.testData.get(key);
}
static getAllTestData(): Map<string, any> {
return new Map(this.testData);
}
static clearTestData(): void {
this.testData.clear();
}
}
export class DatabaseHelper {
private static apiBaseUrl: string;
static initialize(apiBaseUrl: string = 'http://localhost:8084') {
this.apiBaseUrl = apiBaseUrl;
}
static async resetDatabase(request: APIRequestContext): Promise<void> {
const response = await request.post(`${this.apiBaseUrl}/api/test/reset-database`);
if (!response.ok()) {
throw new Error(`Failed to reset database: ${await response.text()}`);
}
}
static async clearTestData(request: APIRequestContext): Promise<void> {
const response = await request.post(`${this.apiBaseUrl}/api/test/clear-test-data`);
if (!response.ok()) {
throw new Error(`Failed to clear test data: ${await response.text()}`);
}
}
static async seedTestData(request: APIRequestContext): Promise<void> {
const response = await request.post(`${this.apiBaseUrl}/api/test/seed-test-data`);
if (!response.ok()) {
throw new Error(`Failed to seed test data: ${await response.text()}`);
}
}
}
+263
View File
@@ -0,0 +1,263 @@
import { Page, expect } from '@playwright/test';
export class TestHelper {
static async waitForPageLoad(page: Page, timeout: number = 30000): Promise<void> {
await page.waitForLoadState('networkidle', { timeout });
await page.waitForLoadState('domcontentloaded', { timeout });
}
static async waitForElementVisible(
page: Page,
selector: string,
timeout: number = 10000
): Promise<void> {
await expect(page.locator(selector)).toBeVisible({ timeout });
}
static async waitForElementHidden(
page: Page,
selector: string,
timeout: number = 10000
): Promise<void> {
await expect(page.locator(selector)).toBeHidden({ timeout });
}
static async waitForTextContent(
page: Page,
selector: string,
text: string,
timeout: number = 10000
): Promise<void> {
await expect(page.locator(selector)).toContainText(text, { timeout });
}
static async clickElement(page: Page, selector: string, timeout: number = 10000): Promise<void> {
await page.click(selector, { timeout });
}
static async fillInput(
page: Page,
selector: string,
value: string,
timeout: number = 10000
): Promise<void> {
await page.fill(selector, value, { timeout });
}
static async selectOption(
page: Page,
selector: string,
value: string,
timeout: number = 10000
): Promise<void> {
await page.selectOption(selector, value, { timeout });
}
static async checkCheckbox(
page: Page,
selector: string,
timeout: number = 10000
): Promise<void> {
await page.check(selector, { timeout });
}
static async uncheckCheckbox(
page: Page,
selector: string,
timeout: number = 10000
): Promise<void> {
await page.uncheck(selector, { timeout });
}
static async uploadFile(
page: Page,
selector: string,
filePath: string,
timeout: number = 10000
): Promise<void> {
await page.setInputFiles(selector, filePath, { timeout });
}
static async takeScreenshot(
page: Page,
filename: string,
fullPage: boolean = false
): Promise<void> {
await page.screenshot({
path: `test-results/screenshots/${filename}`,
fullPage,
});
}
static async waitForUrl(
page: Page,
urlPattern: string | RegExp,
timeout: number = 30000
): Promise<void> {
await page.waitForURL(urlPattern, { timeout });
}
static async reloadPage(page: Page, timeout: number = 30000): Promise<void> {
await page.reload({ waitUntil: 'networkidle', timeout });
}
static async navigateTo(page: Page, url: string, timeout: number = 30000): Promise<void> {
await page.goto(url, { waitUntil: 'networkidle', timeout });
}
static async waitForDialog(page: Page, timeout: number = 10000): Promise<void> {
await page.waitForEvent('dialog', { timeout });
}
static async handleDialog(page: Page, accept: boolean = true): Promise<void> {
page.on('dialog', async (dialog) => {
if (accept) {
await dialog.accept();
} else {
await dialog.dismiss();
}
});
}
static async waitForToast(
page: Page,
message: string,
timeout: number = 5000
): Promise<void> {
await expect(page.locator('.el-message')).toContainText(message, { timeout });
}
static async waitForSuccessMessage(page: Page, timeout: number = 5000): Promise<void> {
await expect(page.locator('.el-message--success')).toBeVisible({ timeout });
}
static async waitForErrorMessage(page: Page, timeout: number = 5000): Promise<void> {
await expect(page.locator('.el-message--error')).toBeVisible({ timeout });
}
static async getElementText(page: Page, selector: string): Promise<string> {
const text = await page.textContent(selector);
return text || '';
}
static async getElementCount(page: Page, selector: string): Promise<number> {
return await page.locator(selector).count();
}
static async isElementVisible(page: Page, selector: string): Promise<boolean> {
return await page.locator(selector).isVisible();
}
static async isElementEnabled(page: Page, selector: string): Promise<boolean> {
return await page.locator(selector).isEnabled();
}
static async scrollToElement(page: Page, selector: string): Promise<void> {
await page.locator(selector).scrollIntoViewIfNeeded();
}
static async hoverElement(page: Page, selector: string): Promise<void> {
await page.hover(selector);
}
static async doubleClickElement(page: Page, selector: string): Promise<void> {
await page.dblclick(selector);
}
static async rightClickElement(page: Page, selector: string): Promise<void> {
await page.click(selector, { button: 'right' });
}
static async waitForApiResponse(
page: Page,
urlPattern: string | RegExp,
timeout: number = 30000
): Promise<void> {
await page.waitForResponse(
(response) => !!response.url().match(urlPattern),
{ timeout }
);
}
static async getApiResponse(
page: Page,
urlPattern: string | RegExp,
timeout: number = 30000
): Promise<any> {
const response = await page.waitForResponse(
(response) => !!response.url().match(urlPattern),
{ timeout }
);
return await response.json();
}
static async mockApiResponse(
page: Page,
urlPattern: string | RegExp,
mockData: any
): Promise<void> {
await page.route(urlPattern, async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockData),
});
});
}
static async executeScript(page: Page, script: string): Promise<any> {
return await page.evaluate(script);
}
static async setLocalStorage(page: Page, key: string, value: string): Promise<void> {
await page.evaluate(
({ key, value }) => {
localStorage.setItem(key, value);
},
{ key, value }
);
}
static async getLocalStorage(page: Page, key: string): Promise<string | null> {
return await page.evaluate((key) => localStorage.getItem(key), key);
}
static async clearLocalStorage(page: Page): Promise<void> {
await page.evaluate(() => localStorage.clear());
}
static async setSessionStorage(page: Page, key: string, value: string): Promise<void> {
await page.evaluate(
({ key, value }) => {
sessionStorage.setItem(key, value);
},
{ key, value }
);
}
static async clearSessionStorage(page: Page): Promise<void> {
await page.evaluate(() => sessionStorage.clear());
}
static async clearCookies(page: Page): Promise<void> {
await page.context().clearCookies();
}
static async clearAllStorage(page: Page): Promise<void> {
await this.clearLocalStorage(page);
await this.clearSessionStorage(page);
await this.clearCookies(page);
}
static async getAuthToken(page: Page): Promise<string> {
const token = await this.getLocalStorage(page, 'token');
if (!token) {
const user = await this.getLocalStorage(page, 'user');
if (user) {
const userData = JSON.parse(user);
return userData.token || '';
}
}
return token || '';
}
}
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Novalon 管理系统</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+54
View File
@@ -0,0 +1,54 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Gzip 压缩
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json application/javascript;
# 静态资源缓存
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# API 代理
location /api/ {
proxy_pass http://gateway:8080/api/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
# 超时设置
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
# SPA 路由支持
location / {
try_files $uri $uri/ /index.html;
}
# 安全头
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
# 错误页面
error_page 404 /index.html;
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}
+6780
View File
File diff suppressed because it is too large Load Diff
+68
View File
@@ -0,0 +1,68 @@
{
"name": "gym-manage-web",
"version": "1.0.0",
"description": "Gym Management System Frontend",
"type": "module",
"scripts": {
"dev": "vite",
"dev:local": "vite --mode development-local",
"dev:test": "vite --mode test",
"build": "vue-tsc && vite build",
"build:test": "vue-tsc && vite build --mode test",
"build:prod": "vue-tsc && vite build --mode production",
"preview": "vite preview",
"test": "vitest --run",
"test:ui": "vitest --ui",
"test:unit": "vitest --run --coverage",
"test:coverage": "vitest --run --coverage",
"test:e2e": "playwright test",
"test:e2e:smoke": "playwright test --project=smoke",
"test:e2e:journeys": "playwright test --project=journeys",
"test:e2e:headed": "playwright test --headed",
"test:e2e:debug": "playwright test --debug",
"test:e2e:perf": "node scripts/measure-e2e-performance.js",
"test:perf": "node scripts/performance-test.js performance",
"test:load": "node scripts/performance-test.js load",
"test:perf:all": "node scripts/performance-test.js all",
"test:edge": "playwright test edge-cases.spec.ts",
"test:performance-opt": "playwright test performance-optimization.spec.ts",
"test:parallel-opt": "playwright test parallel-optimization.spec.ts",
"test:all-opt": "playwright test edge-cases.spec.ts performance-optimization.spec.ts parallel-optimization.spec.ts",
"test:monitor": "node e2e/performanceMonitor.js report",
"type-check": "vue-tsc --noEmit",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx --fix --ignore-path .gitignore",
"format": "prettier --write src/"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"axios": "^1.6.2",
"crypto-js": "^4.2.0",
"date-fns": "^4.1.0",
"dayjs": "^1.11.10",
"element-plus": "^2.13.5",
"jwt-decode": "^4.0.0",
"pinia": "^3.0.4",
"vue": "^3.5.26",
"vue-i18n": "^9.8.0",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@playwright/test": "^1.40.1",
"@types/crypto-js": "^4.2.2",
"@types/node": "^20.10.0",
"@typescript-eslint/eslint-plugin": "^6.18.1",
"@typescript-eslint/parser": "^6.18.1",
"@vitejs/plugin-vue": "^6.0.3",
"@vitest/coverage-v8": "^4.1.1",
"@vitest/ui": "^4.0.16",
"@vue/test-utils": "^2.4.3",
"eslint": "^8.56.0",
"eslint-plugin-vue": "^9.19.2",
"jsdom": "^27.4.0",
"prettier": "^3.1.1",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vitest": "^4.0.16",
"vue-tsc": "^3.2.2"
}
}
@@ -0,0 +1,99 @@
import { defineConfig, devices } from '@playwright/test';
const baseURL = 'http://localhost:3002';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 1,
workers: process.env.CI ? 4 : '50%',
reporter: [
['html', { outputFolder: 'playwright-report' }],
['json', { outputFile: 'test-results/results.json' }],
['junit', { outputFile: 'test-results/junit.xml' }],
['list'],
],
timeout: 120000,
expect: {
timeout: 30000,
toHaveScreenshot: { threshold: 0.2 },
toMatchSnapshot: { threshold: 0.2 }
},
use: {
baseURL: baseURL,
trace: process.env.CI ? 'retain-on-failure' : 'on-first-retry',
screenshot: 'only-on-failure',
video: process.env.CI ? 'retain-on-failure' : 'on-first-retry',
actionTimeout: 30000,
navigationTimeout: 60000,
headless: process.env.PLAYWRIGHT_HEADLESS === 'true' || process.env.CI === 'true',
locale: 'zh-CN',
timezoneId: 'Asia/Shanghai',
ignoreHTTPSErrors: true,
bypassCSP: true,
viewport: { width: 1280, height: 720 },
launchOptions: {
slowMo: process.env.CI ? 0 : 100
},
contextOptions: {
permissions: ['geolocation'],
geolocation: { latitude: 35.6895, longitude: 139.6917 },
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
}
},
projects: [
{
name: 'ui-test',
testMatch: '**/basic-ui-test.spec.ts',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'smoke-test',
testMatch: '**/smoke/**/*.spec.ts',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'journey-test',
testMatch: '**/journeys/**/*.spec.ts',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'api-test',
testMatch: '**/api-connectivity.spec.ts',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'auth-test',
testMatch: '**/auth-test.spec.ts',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'menu-management-test',
testMatch: '**/menu-management.spec.ts',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'config-management-test',
testMatch: '**/config-management.spec.ts',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'dict-management-test',
testMatch: '**/dict-management.spec.ts',
use: { ...devices['Desktop Chrome'] },
},
],
webServer: {
command: 'pnpm run dev',
url: baseURL,
reuseExistingServer: !process.env.CI,
timeout: 120000,
stdout: 'pipe',
stderr: 'pipe'
},
});
@@ -0,0 +1,57 @@
import { defineConfig, devices } from '@playwright/test';
const baseURL = 'http://localhost:3002';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: 0,
workers: 1,
reporter: 'list',
timeout: 30000,
expect: {
timeout: 10000,
},
use: {
baseURL: baseURL,
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'off',
actionTimeout: 10000,
navigationTimeout: 15000,
headless: true,
locale: 'zh-CN',
timezoneId: 'Asia/Shanghai',
ignoreHTTPSErrors: true,
bypassCSP: true,
viewport: { width: 1280, height: 720 },
},
projects: [
{
name: 'ui-test',
testMatch: '**/basic-ui-test.spec.ts',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'smoke-test',
testMatch: '**/smoke/**/*.spec.ts',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'journey-test',
testMatch: '**/journeys/**/*.spec.ts',
use: { ...devices['Desktop Chrome'] },
},
],
webServer: {
command: 'pnpm run dev',
url: baseURL,
reuseExistingServer: !process.env.CI,
timeout: 120000,
},
});
+122
View File
@@ -0,0 +1,122 @@
import { defineConfig, devices } from '@playwright/test';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const isHeadless = process.env.PLAYWRIGHT_HEADLESS === 'true' || process.env.CI === 'true';
const baseURL = process.env.TEST_BASE_URL || process.env.VITE_BASE_URL || 'http://localhost:3002';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 1,
workers: process.env.CI ? 4 : '50%',
reporter: [
['html', { outputFolder: 'playwright-report' }],
['json', { outputFile: 'test-results/results.json' }],
['junit', { outputFile: 'test-results/junit.xml' }],
['list'],
['./e2e/customReporter.ts']
],
timeout: 120000,
expect: {
timeout: 30000,
toHaveScreenshot: { threshold: 0.2 },
toMatchSnapshot: { threshold: 0.2 }
},
use: {
baseURL: baseURL,
trace: process.env.CI ? 'retain-on-failure' : 'on-first-retry',
screenshot: 'only-on-failure',
video: process.env.CI ? 'retain-on-failure' : 'on-first-retry',
actionTimeout: 30000,
navigationTimeout: 60000,
headless: isHeadless,
locale: 'zh-CN',
timezoneId: 'Asia/Shanghai',
ignoreHTTPSErrors: true,
bypassCSP: true,
viewport: { width: 1280, height: 720 },
launchOptions: {
slowMo: process.env.CI ? 0 : 100
},
contextOptions: {
permissions: ['geolocation'],
geolocation: { latitude: 35.6895, longitude: 139.6917 },
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
}
},
projects: [
{
name: 'setup',
testMatch: /.*\.setup\.ts/,
},
{
name: 'smoke',
testDir: './e2e/smoke',
testMatch: /.*\.spec\.ts/,
use: {
...devices['Desktop Chrome'],
launchOptions: {
args: [
'--disable-blink-features=AutomationControlled',
'--disable-dev-shm-usage',
'--no-sandbox'
]
}
},
},
{
name: 'journeys',
testDir: './e2e/journeys',
testMatch: /.*\.spec\.ts/,
dependencies: ['setup'],
use: {
...devices['Desktop Chrome'],
storageState: 'playwright/.auth/user.json',
launchOptions: {
args: [
'--disable-blink-features=AutomationControlled',
'--disable-dev-shm-usage',
'--no-sandbox'
]
}
},
},
{
name: 'debug',
testDir: './e2e/debug',
testMatch: /.*\.spec\.ts/,
dependencies: ['setup'],
use: {
...devices['Desktop Chrome'],
storageState: 'playwright/.auth/user.json',
launchOptions: {
args: [
'--disable-blink-features=AutomationControlled',
'--disable-dev-shm-usage',
'--no-sandbox'
]
}
},
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3002',
reuseExistingServer: !process.env.CI,
timeout: 120000,
stdout: 'pipe',
stderr: 'pipe'
},
globalSetup: path.resolve(__dirname, './e2e/global-setup.ts'),
globalTeardown: path.resolve(__dirname, './e2e/global-teardown.ts'),
});
+30
View File
@@ -0,0 +1,30 @@
{
"cookies": [],
"origins": [
{
"origin": "http://localhost:3002",
"localStorage": [
{
"name": "token",
"value": "eyJhbGciOiJIUzM4NCJ9.eyJyb2xlcyI6WyJhZG1pbiJdLCJ1c2VySWQiOjEsInVzZXJuYW1lIjoiYWRtaW4iLCJzdWIiOiJhZG1pbiIsImlhdCI6MTc3NTY0ODAzOCwiZXhwIjoxNzc1NzM0NDM4fQ.jCpkwk034HQKIYBWdZ5qjIe8rkxrar6fSLNauoJM0UgOFfVSBuoxaMpIzRHC7KDS"
},
{
"name": "permission",
"value": "{\"roles\":[\"admin\"],\"permissions\":[\"system:user:list\",\"system:role:list\",\"system:menu:list\",\"system:dept:list\",\"system:dict:list\",\"system:config:list\",\"system:notice:list\",\"system:file:list\",\"system:user:query\",\"system:user:add\",\"system:user:edit\",\"system:user:remove\",\"system:user:export\",\"system:user:import\",\"system:user:resetPwd\",\"system:role:query\",\"system:role:add\",\"system:role:edit\",\"system:role:remove\",\"system:role:export\",\"system:menu:query\",\"system:menu:add\",\"system:menu:edit\",\"system:menu:remove\",\"audit:operation:list\",\"audit:login:list\",\"audit:exception:list\",\"audit:operation:query\",\"audit:operation:remove\",\"audit:operation:export\",\"audit:login:query\",\"audit:login:remove\",\"audit:login:export\",\"audit:exception:query\",\"audit:exception:remove\",\"audit:exception:export\",\"monitor:online:list\",\"monitor:job:list\",\"monitor:data:list\",\"monitor:server:list\",\"monitor:cache:list\",\"monitor:online:query\",\"monitor:online:forceLogout\",\"monitor:job:query\",\"monitor:job:add\",\"monitor:job:edit\",\"monitor:job:remove\",\"monitor:job:execute\"],\"menus\":[{\"id\":1,\"name\":\"系统管理\",\"path\":\"\",\"icon\":\"Setting\",\"sort\":1,\"children\":[{\"id\":11,\"name\":\"用户管理\",\"path\":\"/users\",\"icon\":\"User\",\"parentId\":1,\"sort\":1},{\"id\":12,\"name\":\"角色管理\",\"path\":\"/roles\",\"icon\":\"UserFilled\",\"parentId\":1,\"sort\":2},{\"id\":13,\"name\":\"菜单管理\",\"path\":\"/menus\",\"icon\":\"Menu\",\"parentId\":1,\"sort\":3},{\"id\":14,\"name\":\"部门管理\",\"path\":\"/dept\",\"icon\":\"Document\",\"parentId\":1,\"sort\":4},{\"id\":15,\"name\":\"字典管理\",\"path\":\"/dict\",\"icon\":\"Collection\",\"parentId\":1,\"sort\":5},{\"id\":16,\"name\":\"参数管理\",\"path\":\"/sys/config\",\"icon\":\"Document\",\"parentId\":1,\"sort\":6},{\"id\":17,\"name\":\"通知公告\",\"path\":\"/notice\",\"icon\":\"Bell\",\"parentId\":1,\"sort\":7},{\"id\":18,\"name\":\"文件管理\",\"path\":\"/files\",\"icon\":\"Folder\",\"parentId\":1,\"sort\":8}]},{\"id\":2,\"name\":\"审计日志\",\"path\":\"\",\"icon\":\"Document\",\"sort\":2,\"children\":[{\"id\":21,\"name\":\"操作日志\",\"path\":\"/oplog\",\"icon\":\"Document\",\"parentId\":2,\"sort\":1},{\"id\":22,\"name\":\"登录日志\",\"path\":\"/loginlog\",\"icon\":\"Document\",\"parentId\":2,\"sort\":2},{\"id\":23,\"name\":\"异常日志\",\"path\":\"/exceptionlog\",\"icon\":\"Warning\",\"parentId\":2,\"sort\":3}]},{\"id\":3,\"name\":\"系统监控\",\"path\":\"\",\"icon\":\"Monitor\",\"sort\":3,\"children\":[{\"id\":31,\"name\":\"在线用户\",\"path\":\"/monitor/online\",\"icon\":\"Document\",\"parentId\":3,\"sort\":1},{\"id\":32,\"name\":\"定时任务\",\"path\":\"/monitor/job\",\"icon\":\"Document\",\"parentId\":3,\"sort\":2},{\"id\":33,\"name\":\"数据监控\",\"path\":\"/monitor/data\",\"icon\":\"Document\",\"parentId\":3,\"sort\":3},{\"id\":34,\"name\":\"服务监控\",\"path\":\"/monitor/server\",\"icon\":\"Document\",\"parentId\":3,\"sort\":4},{\"id\":35,\"name\":\"缓存监控\",\"path\":\"/monitor/cache\",\"icon\":\"Document\",\"parentId\":3,\"sort\":5}]}]}"
},
{
"name": "userId",
"value": "1"
},
{
"name": "username",
"value": "admin"
},
{
"name": "roles",
"value": "[\"admin\"]"
}
]
}
]
}
+3684
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,146 @@
#!/usr/bin/env node
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const E2E_DIR = path.join(__dirname, 'e2e');
const RESULTS_FILE = path.join(__dirname, 'e2e-performance-results.json');
function measureE2ETestPerformance() {
console.log('🚀 开始E2E性能测试...\n');
const startTime = Date.now();
try {
const output = execSync('npm run test:e2e', {
cwd: __dirname,
encoding: 'utf8',
stdio: 'pipe'
});
const endTime = Date.now();
const duration = (endTime - startTime) / 1000;
const results = {
timestamp: new Date().toISOString(),
duration: duration,
durationFormatted: formatDuration(duration),
success: true,
message: 'E2E测试执行成功'
};
saveResults(results);
console.log('\n✅ E2E测试执行成功!');
console.log(`⏱️ 总耗时: ${results.durationFormatted}`);
console.log(`📊 性能评估: ${evaluatePerformance(duration)}`);
return results;
} catch (error) {
const endTime = Date.now();
const duration = (endTime - startTime) / 1000;
const results = {
timestamp: new Date().toISOString(),
duration: duration,
durationFormatted: formatDuration(duration),
success: false,
message: error.message || 'E2E测试执行失败'
};
saveResults(results);
console.log('\n❌ E2E测试执行失败!');
console.log(`⏱️ 总耗时: ${results.durationFormatted}`);
console.log(`📊 性能评估: ${evaluatePerformance(duration)}`);
console.log(`💥 错误信息: ${error.message}`);
return results;
}
}
function formatDuration(seconds) {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60);
return `${minutes}${remainingSeconds}`;
}
function evaluatePerformance(duration) {
if (duration < 60) {
return '🟢 优秀 - 执行时间在1分钟以内';
} else if (duration < 90) {
return '🟡 良好 - 执行时间在1.5分钟以内';
} else if (duration < 120) {
return '🟠 一般 - 执行时间在2分钟以内';
} else {
return '🔴 需要优化 - 执行时间超过2分钟';
}
}
function saveResults(results) {
const history = [];
if (fs.existsSync(RESULTS_FILE)) {
const data = fs.readFileSync(RESULTS_FILE, 'utf8');
try {
history.push(...JSON.parse(data));
} catch (e) {
console.warn('⚠️ 无法解析历史结果文件');
}
}
history.push(results);
if (history.length > 10) {
history.shift();
}
fs.writeFileSync(RESULTS_FILE, JSON.stringify(history, null, 2));
console.log('\n📈 性能趋势分析:');
analyzePerformanceTrend(history);
}
function analyzePerformanceTrend(history) {
if (history.length < 2) {
console.log(' 需要更多测试数据来分析趋势');
return;
}
const successfulTests = history.filter(r => r.success);
if (successfulTests.length < 2) {
console.log(' 需要更多成功的测试数据来分析趋势');
return;
}
const durations = successfulTests.map(r => r.duration);
const avgDuration = durations.reduce((a, b) => a + b, 0) / durations.length;
const minDuration = Math.min(...durations);
const maxDuration = Math.max(...durations);
console.log(` 平均执行时间: ${formatDuration(avgDuration)}`);
console.log(` 最快执行时间: ${formatDuration(minDuration)}`);
console.log(` 最慢执行时间: ${formatDuration(maxDuration)}`);
const recentTests = successfulTests.slice(-3);
if (recentTests.length >= 2) {
const recentAvg = recentTests.reduce((a, b) => a + b.duration, 0) / recentTests.length;
const olderTests = successfulTests.slice(0, -3);
if (olderTests.length > 0) {
const olderAvg = olderTests.reduce((a, b) => a + b.duration, 0) / olderTests.length;
const improvement = ((olderAvg - recentAvg) / olderAvg * 100).toFixed(1);
if (improvement > 0) {
console.log(` 📉 性能提升: ${improvement}%`);
} else {
console.log(` 📈 性能下降: ${Math.abs(improvement)}%`);
}
}
}
}
if (require.main === module) {
measureE2ETestPerformance();
}
module.exports = { measureE2ETestPerformance };
+337
View File
@@ -0,0 +1,337 @@
#!/usr/bin/env node
const http = require('http');
const https = require('https');
const fs = require('fs');
const path = require('path');
const API_BASE_URL = process.env.API_BASE_URL || 'http://localhost:8080';
const RESULTS_FILE = path.join(__dirname, '../performance-test-results.json');
class PerformanceTester {
constructor(baseUrl) {
this.baseUrl = baseUrl;
this.results = [];
}
async testEndpoint(endpoint, method = 'GET', body = null) {
const url = `${this.baseUrl}${endpoint}`;
const startTime = Date.now();
return new Promise((resolve, reject) => {
const options = {
method: method,
headers: {
'Content-Type': 'application/json',
}
};
if (body) {
options.headers['Content-Length'] = Buffer.byteLength(JSON.stringify(body));
}
const protocol = url.startsWith('https') ? https : http;
const req = protocol.request(url, options, (res) => {
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
const endTime = Date.now();
const duration = endTime - startTime;
resolve({
endpoint,
method,
statusCode: res.statusCode,
duration,
success: res.statusCode >= 200 && res.statusCode < 300,
dataSize: data.length
});
});
});
req.on('error', (error) => {
const endTime = Date.now();
const duration = endTime - startTime;
resolve({
endpoint,
method,
statusCode: 0,
duration,
success: false,
error: error.message
});
});
if (body) {
req.write(JSON.stringify(body));
}
req.end();
});
}
async runLoadTest(endpoint, concurrentRequests = 10, totalRequests = 100) {
console.log(`\n📊 开始负载测试: ${endpoint}`);
console.log(` 并发数: ${concurrentRequests}`);
console.log(` 总请求数: ${totalRequests}\n`);
const results = [];
const startTime = Date.now();
for (let i = 0; i < totalRequests; i += concurrentRequests) {
const batch = Math.min(concurrentRequests, totalRequests - i);
const promises = [];
for (let j = 0; j < batch; j++) {
promises.push(this.testEndpoint(endpoint));
}
const batchResults = await Promise.all(promises);
results.push(...batchResults);
console.log(` 进度: ${Math.min(i + batch, totalRequests)}/${totalRequests} 请求已完成`);
}
const endTime = Date.now();
const totalDuration = endTime - startTime;
const successfulRequests = results.filter(r => r.success);
const failedRequests = results.filter(r => !r.success);
const durations = successfulRequests.map(r => r.duration);
const avgDuration = durations.length > 0 ? durations.reduce((a, b) => a + b, 0) / durations.length : 0;
const minDuration = durations.length > 0 ? Math.min(...durations) : 0;
const maxDuration = durations.length > 0 ? Math.max(...durations) : 0;
const p95Duration = this.calculatePercentile(durations, 95);
const p99Duration = this.calculatePercentile(durations, 99);
const throughput = (successfulRequests.length / totalDuration) * 1000;
return {
endpoint,
concurrentRequests,
totalRequests,
successfulRequests: successfulRequests.length,
failedRequests: failedRequests.length,
successRate: (successfulRequests.length / totalRequests * 100).toFixed(2),
totalDuration,
avgDuration,
minDuration,
maxDuration,
p95Duration,
p99Duration,
throughput: throughput.toFixed(2),
results
};
}
calculatePercentile(values, percentile) {
if (values.length === 0) return 0;
const sorted = [...values].sort((a, b) => a - b);
const index = Math.ceil((percentile / 100) * sorted.length) - 1;
return sorted[Math.max(0, index)];
}
async runPerformanceTests() {
console.log('🚀 开始性能测试...\n');
const endpoints = [
{ path: '/api/auth/login', method: 'POST', body: { username: 'admin', password: 'admin123' } },
{ path: '/api/users', method: 'GET' },
{ path: '/api/roles', method: 'GET' },
{ path: '/api/menus', method: 'GET' },
{ path: '/api/dicts', method: 'GET' },
];
for (const endpoint of endpoints) {
console.log(`\n📡 测试端点: ${endpoint.method} ${endpoint.path}`);
const results = [];
const iterations = 10;
for (let i = 0; i < iterations; i++) {
const result = await this.testEndpoint(endpoint.path, endpoint.method, endpoint.body);
results.push(result);
console.log(` ${i + 1}/${iterations}: ${result.duration}ms - ${result.success ? '✅' : '❌'}`);
}
const durations = results.filter(r => r.success).map(r => r.duration);
const avgDuration = durations.length > 0 ? durations.reduce((a, b) => a + b, 0) / durations.length : 0;
const minDuration = durations.length > 0 ? Math.min(...durations) : 0;
const maxDuration = durations.length > 0 ? Math.max(...durations) : 0;
const successRate = (results.filter(r => r.success).length / results.length * 100).toFixed(2);
this.results.push({
endpoint: endpoint.path,
method: endpoint.method,
avgDuration,
minDuration,
maxDuration,
successRate,
status: this.evaluatePerformance(avgDuration)
});
}
this.saveResults();
this.printSummary();
}
evaluatePerformance(avgDuration) {
if (avgDuration < 100) {
return '🟢 优秀';
} else if (avgDuration < 300) {
return '🟡 良好';
} else if (avgDuration < 500) {
return '🟠 一般';
} else {
return '🔴 需要优化';
}
}
saveResults() {
const timestamp = new Date().toISOString();
const data = {
timestamp,
performanceTests: this.results,
loadTests: this.loadTestResults
};
const history = [];
if (fs.existsSync(RESULTS_FILE)) {
try {
history.push(...JSON.parse(fs.readFileSync(RESULTS_FILE, 'utf8')));
} catch (e) {
console.warn('⚠️ 无法解析历史结果文件');
}
}
history.push(data);
if (history.length > 20) {
history.shift();
}
fs.writeFileSync(RESULTS_FILE, JSON.stringify(history, null, 2));
}
printSummary() {
console.log('\n📊 性能测试摘要:');
console.log('═══════════════════════════════════════');
const table = this.results.map(r => ({
端点: r.endpoint,
方法: r.method,
平均: `${r.avgDuration.toFixed(0)}ms`,
最小: `${r.minDuration}ms`,
最大: `${r.maxDuration}ms`,
成功率: `${r.successRate}%`,
状态: r.status
}));
console.table(table);
if (this.loadTestResults) {
console.log('\n📈 负载测试摘要:');
console.log('═══════════════════════════════════════');
const loadTable = this.loadTestResults.map(r => ({
端点: r.endpoint,
总请求: r.totalRequests,
成功: r.successfulRequests,
失败: r.failedRequests,
成功率: `${r.successRate}%`,
平均响应: `${r.avgDuration.toFixed(0)}ms`,
P95: `${r.p95Duration.toFixed(0)}ms`,
P99: `${r.p99Duration.toFixed(0)}ms`,
吞吐量: `${r.throughput} req/s`
}));
console.table(loadTable);
}
console.log('\n💡 性能优化建议:');
this.printRecommendations();
}
printRecommendations() {
const slowEndpoints = this.results.filter(r => r.avgDuration > 300);
if (slowEndpoints.length > 0) {
console.log(' ⚠️ 以下端点响应时间较长,建议优化:');
slowEndpoints.forEach(r => {
console.log(` - ${r.endpoint}: ${r.avgDuration.toFixed(0)}ms`);
});
}
const lowSuccessRate = this.results.filter(r => parseFloat(r.successRate) < 95);
if (lowSuccessRate.length > 0) {
console.log(' ⚠️ 以下端点成功率较低,建议检查:');
lowSuccessRate.forEach(r => {
console.log(` - ${r.endpoint}: ${r.successRate}%`);
});
}
if (slowEndpoints.length === 0 && lowSuccessRate.length === 0) {
console.log(' ✅ 所有端点性能良好,无需优化');
}
}
async runLoadTests() {
console.log('\n📊 开始负载测试...\n');
const endpoints = ['/api/users', '/api/roles', '/api/menus'];
this.loadTestResults = [];
for (const endpoint of endpoints) {
const result = await this.runLoadTest(endpoint, 10, 100);
this.loadTestResults.push(result);
console.log(`\n📈 ${endpoint} 负载测试结果:`);
console.log(` 成功率: ${result.successRate}%`);
console.log(` 平均响应时间: ${result.avgDuration.toFixed(0)}ms`);
console.log(` P95响应时间: ${result.p95Duration.toFixed(0)}ms`);
console.log(` P99响应时间: ${result.p99Duration.toFixed(0)}ms`);
console.log(` 吞吐量: ${result.throughput} req/s`);
}
this.saveResults();
}
}
async function main() {
const tester = new PerformanceTester(API_BASE_URL);
const command = process.argv[2];
switch (command) {
case 'performance':
await tester.runPerformanceTests();
break;
case 'load':
await tester.runLoadTests();
break;
case 'all':
await tester.runPerformanceTests();
await tester.runLoadTests();
break;
default:
console.log('使用方法:');
console.log(' node scripts/performance-test.js performance - 运行性能测试');
console.log(' node scripts/performance-test.js load - 运行负载测试');
console.log(' node scripts/performance-test.js all - 运行所有测试');
console.log('\n环境变量:');
console.log(' API_BASE_URL - API基础URL (默认: http://localhost:8080)');
}
}
if (require.main === module) {
main();
}
module.exports = PerformanceTester;
+46
View File
@@ -0,0 +1,46 @@
#!/bin/bash
# Playwright E2E Headless 模式测试脚本
# 用于完整的端到端测试和UAT测试
set -e
echo "========================================"
echo "Playwright E2E Headless 测试脚本"
echo "========================================"
# 设置工作目录
cd /Users/zhangxiang/Codes/Novalon/novalon-manage-system/novalon-manage-web
# 检查前端开发服务器
echo "🔍 检查前端开发服务器..."
if ! lsof -ti:3001 > /dev/null; then
echo "❌ 前端开发服务器未运行,启动中..."
npm run dev > /tmp/frontend.log 2>&1 &
echo "✅ 前端开发服务器已启动(PID: $!"
sleep 10
fi
# 检查后端服务
echo "🔍 检查后端服务..."
if ! lsof -ti:8080 > /dev/null; then
echo "❌ 后端服务未运行,启动中..."
cd /Users/zhangxiang/Codes/Novalon/novalon-manage-system/novalon-manage-api
mvn spring-boot:run -pl manage-gateway > /tmp/gateway.log 2>&1 &
echo "✅ 后端服务已启动(PID: $!"
sleep 30
fi
# 回到前端目录
cd /Users/zhangxiang/Codes/Novalon/novalon-manage-system/novalon-manage-web
# 运行 E2E 测试(Headless 模式)
echo "🚀 运行 E2E 测试(Headless 模式)..."
PLAYWRIGHT_HEADLESS=true npx playwright test --project=chromium --reporter=list
# 生成测试报告
echo "📊 生成测试报告..."
npx playwright show-report playwright-report
echo "✅ E2E Headless 测试完成!"
echo "_report: playwright-report/index.html"
+6
View File
@@ -0,0 +1,6 @@
<template>
<router-view />
</template>
<script setup lang="ts">
</script>
@@ -0,0 +1,72 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import MenuItem from '@/components/MenuItem.vue'
describe('MenuItem 组件', () => {
it('应该正确接收菜单项 props', () => {
const menu = {
id: 1,
name: '仪表盘',
path: '/dashboard',
icon: 'Odometer',
sort: 1
}
const wrapper = mount(MenuItem, {
props: { menu },
global: {
stubs: {
'el-menu-item': {
template: '<div><slot /></div>'
},
'el-sub-menu': {
template: '<div><slot name="title" /><slot /></div>'
},
'el-icon': {
template: '<div><slot /></div>'
}
}
}
})
expect(wrapper.props('menu')).toEqual(menu)
})
it('应该正确处理有子菜单的菜单项', () => {
const menu = {
id: 2,
name: '系统管理',
path: '/system',
icon: 'Setting',
sort: 2,
children: [
{
id: 3,
name: '用户管理',
path: '/users',
sort: 1
}
]
}
const wrapper = mount(MenuItem, {
props: { menu },
global: {
stubs: {
'el-menu-item': {
template: '<div><slot /></div>'
},
'el-sub-menu': {
template: '<div><slot name="title" /><slot /></div>'
},
'el-icon': {
template: '<div><slot /></div>'
}
}
}
})
expect(wrapper.props('menu')).toEqual(menu)
expect(wrapper.props('menu').children).toHaveLength(1)
})
})
@@ -0,0 +1,124 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { permissionDirective } from '@/directives/permission'
import { usePermissionStore } from '@/stores/permission'
describe('v-permission 指令', () => {
beforeEach(() => {
setActivePinia(createPinia())
localStorage.clear()
})
describe('角色检查', () => {
it('有角色时应该显示元素', () => {
const store = usePermissionStore()
store.setPermissionData({
roles: ['admin'],
permissions: [],
menus: []
})
const wrapper = mount({
template: '<button v-permission:role="\'admin\'">管理员按钮</button>',
directives: {
permission: permissionDirective
}
})
expect(wrapper.find('button').isVisible()).toBe(true)
})
it('无角色时应该隐藏元素', () => {
const store = usePermissionStore()
store.setPermissionData({
roles: ['user'],
permissions: [],
menus: []
})
const wrapper = mount({
template: '<button v-permission:role="\'admin\'">管理员按钮</button>',
directives: {
permission: permissionDirective
}
})
expect(wrapper.find('button').isVisible()).toBe(false)
})
it('支持数组参数(满足任一即可)', () => {
const store = usePermissionStore()
store.setPermissionData({
roles: ['user'],
permissions: [],
menus: []
})
const wrapper = mount({
template: '<button v-permission:role="[\'admin\', \'user\']">按钮</button>',
directives: {
permission: permissionDirective
}
})
expect(wrapper.find('button').isVisible()).toBe(true)
})
})
describe('权限检查', () => {
it('有权限时应该显示元素', () => {
const store = usePermissionStore()
store.setPermissionData({
roles: [],
permissions: ['user:delete'],
menus: []
})
const wrapper = mount({
template: '<button v-permission:permission="\'user:delete\'">删除用户</button>',
directives: {
permission: permissionDirective
}
})
expect(wrapper.find('button').isVisible()).toBe(true)
})
it('无权限时应该隐藏元素', () => {
const store = usePermissionStore()
store.setPermissionData({
roles: [],
permissions: ['user:read'],
menus: []
})
const wrapper = mount({
template: '<button v-permission:permission="\'user:delete\'">删除用户</button>',
directives: {
permission: permissionDirective
}
})
expect(wrapper.find('button').isVisible()).toBe(false)
})
it('支持简写形式(默认权限检查)', () => {
const store = usePermissionStore()
store.setPermissionData({
roles: [],
permissions: ['user:create'],
menus: []
})
const wrapper = mount({
template: '<button v-permission="\'user:create\'">创建用户</button>',
directives: {
permission: permissionDirective
}
})
expect(wrapper.find('button').isVisible()).toBe(true)
})
})
})
@@ -0,0 +1,291 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
const mockLocalStorage = {
store: {} as Record<string, string>,
getItem(key: string) {
return this.store[key] || null
},
setItem(key: string, value: string) {
this.store[key] = value
},
removeItem(key: string) {
delete this.store[key]
},
clear() {
this.store = {}
}
}
Object.defineProperty(window, 'localStorage', {
value: mockLocalStorage
})
const createTestRouter = (routes: RouteRecordRaw[]) => {
return createRouter({
history: createWebHistory(),
routes
})
}
describe('路由守卫权限检查', () => {
beforeEach(() => {
mockLocalStorage.clear()
})
describe('基础认证检查', () => {
it('未登录用户访问受保护路由应重定向到登录页', async () => {
const routes: RouteRecordRaw[] = [
{
path: '/login',
name: 'Login',
component: { template: '<div>Login</div>' }
},
{
path: '/',
component: { template: '<div>Layout</div>' },
meta: { requiresAuth: true },
children: [
{
path: 'dashboard',
name: 'Dashboard',
component: { template: '<div>Dashboard</div>' }
}
]
}
]
const router = createTestRouter(routes)
router.beforeEach((to, _from, next) => {
const token = localStorage.getItem('token')
if (to.meta.requiresAuth && !token) {
next('/login')
} else {
next()
}
})
await router.push('/dashboard')
expect(router.currentRoute.value.path).toBe('/login')
})
it('已登录用户访问受保护路由应允许通过', async () => {
mockLocalStorage.setItem('token', 'valid-token')
const routes: RouteRecordRaw[] = [
{
path: '/login',
name: 'Login',
component: { template: '<div>Login</div>' }
},
{
path: '/',
component: { template: '<div>Layout</div>' },
meta: { requiresAuth: true },
children: [
{
path: 'dashboard',
name: 'Dashboard',
component: { template: '<div>Dashboard</div>' }
}
]
}
]
const router = createTestRouter(routes)
router.beforeEach((to, _from, next) => {
const token = localStorage.getItem('token')
if (to.meta.requiresAuth && !token) {
next('/login')
} else {
next()
}
})
await router.push('/dashboard')
expect(router.currentRoute.value.path).toBe('/dashboard')
})
})
describe('角色权限检查', () => {
it('普通用户访问管理员路由应重定向到403页面', async () => {
mockLocalStorage.setItem('token', 'valid-token')
mockLocalStorage.setItem('roles', JSON.stringify(['user']))
const routes: RouteRecordRaw[] = [
{
path: '/login',
name: 'Login',
component: { template: '<div>Login</div>' }
},
{
path: '/403',
name: 'Forbidden',
component: { template: '<div>403 Forbidden</div>' }
},
{
path: '/',
component: { template: '<div>Layout</div>' },
meta: { requiresAuth: true },
children: [
{
path: 'dashboard',
name: 'Dashboard',
component: { template: '<div>Dashboard</div>' }
},
{
path: 'users',
name: 'UserManagement',
component: { template: '<div>UserManagement</div>' },
meta: { roles: ['admin'] }
}
]
}
]
const router = createTestRouter(routes)
router.beforeEach((to, _from, next) => {
const token = localStorage.getItem('token')
const rolesStr = localStorage.getItem('roles')
const userRoles = rolesStr ? JSON.parse(rolesStr) : []
if (to.meta.requiresAuth && !token) {
next('/login')
return
}
if (to.meta.roles && Array.isArray(to.meta.roles)) {
const hasRole = to.meta.roles.some((role: string) => userRoles.includes(role))
if (!hasRole) {
next('/403')
return
}
}
next()
})
await router.push('/users')
expect(router.currentRoute.value.path).toBe('/403')
})
it('管理员用户访问管理员路由应允许通过', async () => {
mockLocalStorage.setItem('token', 'valid-token')
mockLocalStorage.setItem('roles', JSON.stringify(['admin']))
const routes: RouteRecordRaw[] = [
{
path: '/login',
name: 'Login',
component: { template: '<div>Login</div>' }
},
{
path: '/403',
name: 'Forbidden',
component: { template: '<div>403 Forbidden</div>' }
},
{
path: '/',
component: { template: '<div>Layout</div>' },
meta: { requiresAuth: true },
children: [
{
path: 'dashboard',
name: 'Dashboard',
component: { template: '<div>Dashboard</div>' }
},
{
path: 'users',
name: 'UserManagement',
component: { template: '<div>UserManagement</div>' },
meta: { roles: ['admin'] }
}
]
}
]
const router = createTestRouter(routes)
router.beforeEach((to, _from, next) => {
const token = localStorage.getItem('token')
const rolesStr = localStorage.getItem('roles')
const userRoles = rolesStr ? JSON.parse(rolesStr) : []
if (to.meta.requiresAuth && !token) {
next('/login')
return
}
if (to.meta.roles && Array.isArray(to.meta.roles)) {
const hasRole = to.meta.roles.some((role: string) => userRoles.includes(role))
if (!hasRole) {
next('/403')
return
}
}
next()
})
await router.push('/users')
expect(router.currentRoute.value.path).toBe('/users')
})
it('无角色要求的路由所有登录用户都可访问', async () => {
mockLocalStorage.setItem('token', 'valid-token')
mockLocalStorage.setItem('roles', JSON.stringify(['user']))
const routes: RouteRecordRaw[] = [
{
path: '/login',
name: 'Login',
component: { template: '<div>Login</div>' }
},
{
path: '/',
component: { template: '<div>Layout</div>' },
meta: { requiresAuth: true },
children: [
{
path: 'dashboard',
name: 'Dashboard',
component: { template: '<div>Dashboard</div>' }
}
]
}
]
const router = createTestRouter(routes)
router.beforeEach((to, _from, next) => {
const token = localStorage.getItem('token')
const rolesStr = localStorage.getItem('roles')
const userRoles = rolesStr ? JSON.parse(rolesStr) : []
if (to.meta.requiresAuth && !token) {
next('/login')
return
}
if (to.meta.roles && Array.isArray(to.meta.roles)) {
const hasRole = to.meta.roles.some((role: string) => userRoles.includes(role))
if (!hasRole) {
next('/403')
return
}
}
next()
})
await router.push('/dashboard')
expect(router.currentRoute.value.path).toBe('/dashboard')
})
})
})
@@ -0,0 +1,167 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { usePermissionStore } from '@/stores/permission'
describe('Permission Store', () => {
beforeEach(() => {
setActivePinia(createPinia())
localStorage.clear()
})
describe('基础功能', () => {
it('应该正确初始化状态', () => {
const store = usePermissionStore()
expect(store.roles).toEqual([])
expect(store.permissions).toEqual([])
expect(store.menus).toEqual([])
expect(store.loaded).toBe(false)
})
it('应该正确设置权限数据', () => {
const store = usePermissionStore()
store.setPermissionData({
roles: ['admin'],
permissions: ['user:read', 'user:delete'],
menus: [
{
id: 1,
name: '仪表盘',
path: '/dashboard',
icon: 'Odometer',
sort: 1
}
]
})
expect(store.roles).toEqual(['admin'])
expect(store.permissions).toEqual(['user:read', 'user:delete'])
expect(store.menus).toHaveLength(1)
expect(store.loaded).toBe(true)
})
it('应该正确清除权限数据', () => {
const store = usePermissionStore()
store.setPermissionData({
roles: ['admin'],
permissions: ['user:read'],
menus: []
})
store.clearPermissionData()
expect(store.roles).toEqual([])
expect(store.permissions).toEqual([])
expect(store.menus).toEqual([])
expect(store.loaded).toBe(false)
})
})
describe('权限检查方法', () => {
it('应该正确检查单个角色', () => {
const store = usePermissionStore()
store.setPermissionData({
roles: ['admin', 'user'],
permissions: [],
menus: []
})
expect(store.hasRole('admin')).toBe(true)
expect(store.hasRole('manager')).toBe(false)
})
it('应该正确检查多个角色(满足任一即可)', () => {
const store = usePermissionStore()
store.setPermissionData({
roles: ['user'],
permissions: [],
menus: []
})
expect(store.hasRole(['admin', 'user'])).toBe(true)
expect(store.hasRole(['admin', 'manager'])).toBe(false)
})
it('应该正确检查单个权限', () => {
const store = usePermissionStore()
store.setPermissionData({
roles: [],
permissions: ['user:read', 'user:delete'],
menus: []
})
expect(store.hasPermission('user:read')).toBe(true)
expect(store.hasPermission('user:create')).toBe(false)
})
it('应该正确检查多个权限(满足任一即可)', () => {
const store = usePermissionStore()
store.setPermissionData({
roles: [],
permissions: ['user:read'],
menus: []
})
expect(store.hasPermission(['user:read', 'user:create'])).toBe(true)
expect(store.hasPermission(['user:create', 'user:update'])).toBe(false)
})
})
describe('localStorage 持久化', () => {
it('应该正确保存到 localStorage', () => {
const store = usePermissionStore()
store.setPermissionData({
roles: ['admin'],
permissions: ['user:read'],
menus: [
{
id: 1,
name: '仪表盘',
path: '/dashboard',
sort: 1
}
]
})
const stored = localStorage.getItem('permission')
expect(stored).toBeTruthy()
const data = JSON.parse(stored!)
expect(data.roles).toEqual(['admin'])
expect(data.permissions).toEqual(['user:read'])
expect(data.menus).toHaveLength(1)
})
it('应该正确从 localStorage 恢复', () => {
localStorage.setItem('permission', JSON.stringify({
roles: ['user'],
permissions: ['user:read:self'],
menus: []
}))
const store = usePermissionStore()
store.initFromStorage()
expect(store.roles).toEqual(['user'])
expect(store.permissions).toEqual(['user:read:self'])
expect(store.loaded).toBe(true)
})
it('清除数据时应该同时清除 localStorage', () => {
const store = usePermissionStore()
store.setPermissionData({
roles: ['admin'],
permissions: [],
menus: []
})
store.clearPermissionData()
expect(localStorage.getItem('permission')).toBeNull()
})
})
})
+44
View File
@@ -0,0 +1,44 @@
import request from '@/utils/request'
export interface LoginRequest {
username: string
password: string
}
export interface LoginResponse {
token: string
user: UserInfo
}
export interface UserInfo {
id: number
username: string
nickname: string
email: string
phone: string
avatar: string
roles: string[]
permissions: string[]
}
export interface UpdatePasswordRequest {
oldPassword: string
newPassword: string
}
export const authApi = {
login: (data: LoginRequest) =>
request.post<LoginResponse>('/auth/login', data),
logout: () =>
request.post<void>('/auth/logout'),
getCurrentUser: () =>
request.get<UserInfo>('/auth/current'),
updatePassword: (data: UpdatePasswordRequest) =>
request.put<void>('/auth/password', data),
refreshToken: () =>
request.post<LoginResponse>('/auth/refresh'),
}
+39
View File
@@ -0,0 +1,39 @@
import request from '@/utils/request'
export interface ExceptionLog {
id?: number
username?: string
operation?: string
method?: string
params?: string
errorMsg?: string
exceptionStack?: string
ip?: string
createTime?: string
}
export interface PageResponse<T> {
content: T[]
totalPages: number
totalElements: number
currentPage: number
size: number
}
export const exceptionLogApi = {
getAll: () => request.get<ExceptionLog[]>('/logs/exception'),
getById: (id: number) => request.get<ExceptionLog>(`/logs/exception/${id}`),
getPage: (params: {
page?: number
size?: number
sort?: string
order?: string
keyword?: string
}) => request.get<PageResponse<ExceptionLog>>('/logs/exception/page', { params }),
getCount: () => request.get<number>('/logs/exception/count'),
create: (data: Partial<ExceptionLog>) => request.post<ExceptionLog>('/logs/exception', data)
}
+41
View File
@@ -0,0 +1,41 @@
import request from '@/utils/request'
export interface OperationLog {
id?: number
username?: string
operation?: string
method?: string
params?: string
result?: string
ip?: string
duration?: number
status?: string
errorMsg?: string
createdAt?: string
}
export interface PageResponse<T> {
content: T[]
totalPages: number
totalElements: number
currentPage: number
size: number
}
export const operationLogApi = {
getAll: () => request.get<OperationLog[]>('/logs/operation'),
getById: (id: number) => request.get<OperationLog>(`/logs/operation/${id}`),
getPage: (params: {
page?: number
size?: number
sort?: string
order?: string
keyword?: string
}) => request.get<PageResponse<OperationLog>>('/logs/operation/page', { params }),
getCount: () => request.get<number>('/logs/operation/count'),
create: (data: Partial<OperationLog>) => request.post<OperationLog>('/logs/operation', data)
}
+82
View File
@@ -0,0 +1,82 @@
import request from '@/utils/request'
import type { PageResponse } from './user.api'
import { RoleStatus } from '@/constants/status'
export interface Role {
id: number
roleName: string
roleKey: string
roleSort: number
status: RoleStatus
permissions: Permission[]
createdAt: string
updatedAt: string
}
export interface Permission {
id: number
name: string
code: string
resource: string
action: string
}
export interface CreateRoleRequest {
roleName: string
roleKey: string
roleSort: number
permissions: number[]
}
export interface UpdateRoleRequest {
roleName?: string
roleKey?: string
roleSort?: number
status?: RoleStatus
permissions?: number[]
}
export interface RolePageRequest {
page: number
size: number
roleName?: string
roleKey?: string
status?: string
sortBy?: string
sortOrder?: 'asc' | 'desc'
}
export const roleApi = {
getAll: () =>
request.get<Role[]>('/roles'),
getPage: (params: RolePageRequest) =>
request.get<PageResponse<Role>>('/roles/page', { params }),
getById: (id: number) =>
request.get<Role>(`/roles/${id}`),
create: (data: CreateRoleRequest) =>
request.post<Role>('/roles', data),
update: (id: number, data: UpdateRoleRequest) =>
request.put<Role>(`/roles/${id}`, data),
delete: (id: number) =>
request.delete<void>(`/roles/${id}`),
batchDelete: (ids: number[]) =>
request.post<void>('/roles/batch-delete', { ids }),
updateStatus: (id: number, status: 'ACTIVE' | 'INACTIVE') =>
request.put<void>(`/roles/${id}/status`, { status }),
assignPermissions: (id: number, permissionIds: number[]) =>
request.post<void>(`/roles/${id}/permissions`, { permissionIds }),
getPermissions: (id: number) =>
request.get<Permission[]>(`/roles/${id}/permissions`),
getAllPermissions: () =>
request.get<Permission[]>('/permissions'),
}
+86
View File
@@ -0,0 +1,86 @@
import request from '@/utils/request'
import { UserStatus } from '@/constants/status'
export interface User {
id: number
username: string
nickname: string
email: string
phone: string
avatar: string
status: UserStatus
roles: number[]
createdAt: string
updatedAt: string
}
export interface CreateUserRequest {
username: string
password: string
nickname: string
email: string
phone: string
roles?: number[]
}
export interface UpdateUserRequest {
nickname?: string
email?: string
phone?: string
avatar?: string
status?: UserStatus
roles?: number[]
}
export interface UserPageRequest {
page: number
size: number
keyword?: string
username?: string
nickname?: string
status?: string
sortBy?: string
sortOrder?: 'asc' | 'desc'
}
export interface PageResponse<T> {
content: T[]
totalElements: number
totalPages: number
size: number
number: number
first: boolean
last: boolean
}
export const userApi = {
getAll: () =>
request.get<User[]>('/users'),
getPage: (params: UserPageRequest) =>
request.get<PageResponse<User>>('/users/page', { params }),
getById: (id: number) =>
request.get<User>(`/users/${id}`),
create: (data: CreateUserRequest) =>
request.post<User>('/users', data),
update: (id: number, data: UpdateUserRequest) =>
request.put<User>(`/users/${id}`, data),
delete: (id: number) =>
request.delete<void>(`/users/${id}`),
batchDelete: (ids: number[]) =>
request.post<void>('/users/batch-delete', { ids }),
resetPassword: (id: number) =>
request.post<void>(`/users/${id}/reset-password`),
updateStatus: (id: number, status: UserStatus) =>
request.put<void>(`/users/${id}/status`, { status }),
assignRoles: (id: number, roleIds: number[]) =>
request.post<void>(`/users/${id}/roles`, { roleIds }),
}
+92
View File
@@ -0,0 +1,92 @@
:root {
--el-color-primary: #409eff;
--el-color-primary-light-9: #53a8ff;
--el-color-primary-light-3: #79bbff;
--el-color-primary-dark-2: #337ecc;
--el-color-success: #67c23a;
--el-color-success-light-9: #85ce61;
--el-color-success-light-3: #a0daee;
--el-color-success-dark-2: #529b2e;
--el-color-warning: #e6a23c;
--el-color-warning-light-9: #ebb563;
--el-color-warning-light-3: #f0c78a;
--el-color-warning-dark-2: #b88230;
--el-color-danger: #f56c6c;
--el-color-danger-light-9: #f78989;
--el-color-danger-light-3: #dd6161;
--el-color-danger-dark-2: #c45656;
--el-color-info: #909399;
--el-color-info-light-9: #a6a9ad;
--el-color-info-light-3: #c8c9cc;
--el-color-info-dark-2: #73767a;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.el-message {
--el-message-bg-color: var(--el-color-success-dark-2);
--el-message-border-color: var(--el-color-success-dark-2);
--el-message-text-color: #ffffff;
font-weight: 500;
font-size: 14px;
}
.el-message--success {
--el-message-bg-color: var(--el-color-success-dark-2);
--el-message-border-color: var(--el-color-success-dark-2);
--el-message-text-color: #ffffff;
}
.el-message--error {
--el-message-bg-color: var(--el-color-danger-dark-2);
--el-message-border-color: var(--el-color-danger-dark-2);
--el-message-text-color: #ffffff;
}
.el-message--warning {
--el-message-bg-color: var(--el-color-warning-dark-2);
--el-message-border-color: var(--el-color-warning-dark-2);
--el-message-text-color: #ffffff;
}
.el-message--info {
--el-message-bg-color: var(--el-color-info-dark-2);
--el-message-border-color: var(--el-color-info-dark-2);
--el-message-text-color: #ffffff;
}
.el-tag.el-tag--light {
color: #ffffff !important;
background-color: var(--el-color-danger-light-9);
border-color: var(--el-color-danger-light-9);
}
.el-tag.el-tag--light.el-tag--success {
background-color: var(--el-color-success-light-9);
border-color: var(--el-color-success-light-9);
}
.el-tag.el-tag--light.el-tag--warning {
background-color: var(--el-color-warning-light-9);
border-color: var(--el-color-warning-light-9);
}
.el-tag.el-tag--light.el-tag--info {
background-color: var(--el-color-info-light-9);
border-color: var(--el-color-info-light-9);
}
.el-tag.el-tag--light.el-tag--danger {
background-color: var(--el-color-danger-light-9);
border-color: var(--el-color-danger-light-9);
}
@@ -0,0 +1,43 @@
<template>
<el-sub-menu
v-if="menu.children && menu.children.length > 0"
:index="String(menu.id)"
>
<template #title>
<el-icon v-if="menu.icon">
<component :is="iconComponents[menu.icon]" />
</el-icon>
<span>{{ menu.name }}</span>
</template>
<menu-item
v-for="child in menu.children"
:key="child.id"
:menu="child"
/>
</el-sub-menu>
<el-menu-item
v-else
:index="menu.path"
>
<el-icon v-if="menu.icon">
<component :is="iconComponents[menu.icon]" />
</el-icon>
<span>{{ menu.name }}</span>
</el-menu-item>
</template>
<script setup lang="ts">
import type { MenuItem as MenuItemType } from '@/stores/permission'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import { markRaw, type Component } from 'vue'
const iconComponents: Record<string, Component> = {}
Object.keys(ElementPlusIconsVue).forEach(key => {
iconComponents[key] = markRaw(ElementPlusIconsVue[key as keyof typeof ElementPlusIconsVue])
})
defineProps<{
menu: MenuItemType
}>()
</script>
+87
View File
@@ -0,0 +1,87 @@
/**
* 系统状态值常量定义
*
* 统一前后端状态值,避免不一致导致的功能问题
*
* @author 张翔
* @date 2026-03-24
*/
/**
* 用户状态枚举
*/
export enum UserStatus {
/** 正常 */
ACTIVE = 1,
/** 禁用 */
INACTIVE = 0,
/** 锁定 */
LOCKED = 2
}
/**
* 角色状态枚举
*/
export enum RoleStatus {
/** 正常 */
ACTIVE = 1,
/** 禁用 */
INACTIVE = 0
}
/**
* 菜单状态枚举
*/
export enum MenuStatus {
/** 正常 */
ACTIVE = 1,
/** 禁用 */
INACTIVE = 0
}
/**
* 通知状态枚举
*/
export enum NoticeStatus {
/** 正常 */
ACTIVE = '1',
/** 禁用 */
INACTIVE = '0'
}
/**
* 状态值映射工具类
*/
export class StatusHelper {
/**
* 判断状态是否为正常
*/
static isActive(status: number | string): boolean {
return status === 1 || status === '1' || status === 'ACTIVE'
}
/**
* 判断状态是否为禁用
*/
static isInactive(status: number | string): boolean {
return status === 0 || status === '0' || status === 'INACTIVE'
}
/**
* 获取状态显示文本
*/
static getStatusText(status: number | string): string {
if (this.isActive(status)) return '正常'
if (this.isInactive(status)) return '禁用'
return '未知'
}
/**
* 获取状态标签类型
*/
static getStatusType(status: number | string): 'success' | 'danger' | 'warning' {
if (this.isActive(status)) return 'success'
if (this.isInactive(status)) return 'danger'
return 'warning'
}
}
@@ -0,0 +1,33 @@
import type { Directive, DirectiveBinding } from 'vue'
import { usePermissionStore } from '@/stores/permission'
export const permissionDirective: Directive = {
mounted(el: HTMLElement, binding: DirectiveBinding) {
const permissionStore = usePermissionStore()
const { arg, value } = binding
const checkType = arg || 'permission'
if (!value) {
console.warn('v-permission 指令需要提供权限值')
el.style.display = 'none'
return
}
let hasAccess = false
if (checkType === 'role') {
hasAccess = permissionStore.hasRole(value)
} else if (checkType === 'permission') {
hasAccess = permissionStore.hasPermission(value)
} else {
console.warn(`未知的权限检查类型: ${checkType}`)
el.style.display = 'none'
return
}
if (!hasAccess) {
el.style.display = 'none'
}
}
}
@@ -0,0 +1,166 @@
<template>
<el-container class="default-layout">
<el-aside
:width="collapsed ? '64px' : '200px'"
class="aside"
>
<div class="logo">
<span v-if="!collapsed">Novalon</span>
<span v-else>N</span>
</div>
<el-menu
:default-active="activeMenu"
class="menu"
:collapse="collapsed"
background-color="#f5f7fa"
text-color="#606266"
active-text-color="#409eff"
router
>
<div v-if="menuTree.length === 0" style="padding: 20px; text-align: center; color: #999;">
菜单加载中...
</div>
<menu-item
v-for="menu in menuTree"
:key="menu.id"
:menu="menu"
/>
</el-menu>
</el-aside>
<el-container>
<el-header class="header">
<el-icon
class="trigger"
@click="collapsed = !collapsed"
>
<Fold v-if="!collapsed" />
<Expand v-else />
</el-icon>
<div class="header-right">
<el-dropdown @command="handleCommand">
<el-avatar :size="32">
{{ username }}
</el-avatar>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="profile">
个人中心
</el-dropdown-item>
<el-dropdown-item
command="logout"
divided
>
退出登录
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</el-header>
<el-main class="content">
<router-view />
</el-main>
</el-container>
</el-container>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { Fold, Expand } from '@element-plus/icons-vue'
import { usePermissionStore } from '@/stores/permission'
import MenuItem from '@/components/MenuItem.vue'
const router = useRouter()
const route = useRoute()
const collapsed = ref(false)
const username = ref(localStorage.getItem('username') || 'Admin')
const permissionStore = usePermissionStore()
const activeMenu = computed(() => route.path)
const menuTree = computed(() => {
return permissionStore.menus
})
const handleCommand = (command: string) => {
if (command === 'profile') {
router.push('/profile')
} else if (command === 'logout') {
permissionStore.clearPermissionData()
localStorage.clear()
router.push('/login')
}
}
onMounted(async () => {
const token = localStorage.getItem('token')
if (!token) {
router.push('/login')
} else if (!permissionStore.loaded) {
permissionStore.initFromStorage()
if (!permissionStore.loaded || permissionStore.menus.length === 0) {
try {
await permissionStore.fetchUserMenus()
} catch (error) {
console.error('获取用户菜单失败:', error)
}
}
}
})
</script>
<style scoped lang="css">
.default-layout {
min-height: 100vh;
}
.aside {
background-color: #f5f7fa;
transition: width 0.3s;
overflow: hidden;
}
.logo {
height: 64px;
display: flex;
align-items: center;
justify-content: center;
color: #303133;
font-size: 20px;
font-weight: bold;
}
.menu {
border-right: none;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 16px;
background: #fff;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
.trigger {
font-size: 18px;
cursor: pointer;
transition: color 0.3s;
&:hover { color: #409eff; }
}
.header-right {
display: flex;
align-items: center;
}
}
.content {
margin: 16px;
padding: 16px;
background: #fff;
min-height: calc(100vh - 96px);
}
</style>
+22
View File
@@ -0,0 +1,22 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import 'element-plus/dist/index.css'
import router from './router'
import App from './App.vue'
import './assets/styles.css'
import { permissionDirective } from './directives/permission'
const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
app.use(router)
app.use(ElementPlus, {
locale: zhCn,
})
app.directive('permission', permissionDirective)
app.mount('#app')
@@ -0,0 +1,24 @@
import { describe, it, expect } from 'vitest';
import { AdminRole } from '../admin.role';
describe('AdminRole', () => {
it('should have admin credentials', () => {
expect(AdminRole.name).toBe('admin');
expect(AdminRole.displayName).toBe('超级管理员');
expect(AdminRole.credentials.username).toBe('admin');
expect(AdminRole.credentials.password).toBe('Test@123');
});
it('should have all permissions', () => {
expect(AdminRole.permissions).toContain('user:*');
expect(AdminRole.permissions).toContain('role:*');
expect(AdminRole.permissions).toContain('menu:*');
expect(AdminRole.cannotAccess).toHaveLength(0);
});
it('should be able to create all resources', () => {
expect(AdminRole.expectedBehaviors.canCreate).toContain('user');
expect(AdminRole.expectedBehaviors.canCreate).toContain('role');
expect(AdminRole.expectedBehaviors.canCreate).toContain('menu');
});
});
@@ -0,0 +1,30 @@
import { describe, it, expect } from 'vitest';
import type { RoleDefinition } from '../base.role';
describe('RoleDefinition', () => {
it('should define required role properties', () => {
const role: RoleDefinition = {
name: 'test',
displayName: '测试角色',
credentials: {
username: 'testuser',
password: 'Test@123'
},
permissions: ['test:read', 'test:write'],
cannotAccess: ['/admin'],
expectedBehaviors: {
canCreate: ['test'],
canRead: ['test'],
canUpdate: ['test'],
canDelete: []
}
};
expect(role.name).toBe('test');
expect(role.displayName).toBe('测试角色');
expect(role.credentials.username).toBe('testuser');
expect(role.credentials.password).toBe('Test@123');
expect(role.permissions).toHaveLength(2);
expect(role.cannotAccess).toHaveLength(1);
});
});
@@ -0,0 +1,28 @@
import { describe, it, expect } from 'vitest';
import { RoleFactory } from '../role-factory';
describe('RoleFactory', () => {
it('should get admin role', () => {
const role = RoleFactory.getRole('admin');
expect(role.name).toBe('admin');
expect(role.credentials.username).toBe('admin');
});
it('should get user role', () => {
const role = RoleFactory.getRole('user');
expect(role.name).toBe('user');
expect(role.credentials.username).toBe('normaluser');
});
it('should throw error for unknown role', () => {
expect(() => RoleFactory.getRole('unknown')).toThrow("Role 'unknown' not found");
});
it('should get all roles', () => {
const roles = RoleFactory.getAllRoles();
expect(roles).toHaveLength(3);
expect(roles.map(r => r.name)).toContain('admin');
expect(roles.map(r => r.name)).toContain('user');
expect(roles.map(r => r.name)).toContain('test');
});
});
@@ -0,0 +1,25 @@
import type { RoleDefinition } from './base.role';
export const AdminRole: RoleDefinition = {
name: 'admin',
displayName: '超级管理员',
credentials: {
username: 'admin',
password: 'Test@123'
},
permissions: [
'user:*',
'role:*',
'menu:*',
'config:*',
'log:read',
'dict:*'
],
cannotAccess: [],
expectedBehaviors: {
canCreate: ['user', 'role', 'menu', 'config', 'dict'],
canRead: ['user', 'role', 'menu', 'config', 'dict', 'log'],
canUpdate: ['user', 'role', 'menu', 'config', 'dict'],
canDelete: ['user', 'role', 'menu', 'config', 'dict']
}
};
@@ -0,0 +1,16 @@
export interface RoleDefinition {
name: string;
displayName: string;
credentials: {
username: string;
password: string;
};
permissions: string[];
cannotAccess: string[];
expectedBehaviors: {
canCreate: string[];
canRead: string[];
canUpdate: string[];
canDelete: string[];
};
}
@@ -0,0 +1,24 @@
import type { RoleDefinition } from './base.role';
import { AdminRole } from './admin.role';
import { UserRole } from './user.role';
import { TestRole } from './test.role';
export class RoleFactory {
private static roles: Map<string, RoleDefinition> = new Map([
['admin', AdminRole],
['user', UserRole],
['test', TestRole]
]);
static getRole(roleName: string): RoleDefinition {
const role = this.roles.get(roleName);
if (!role) {
throw new Error(`Role '${roleName}' not found`);
}
return role;
}
static getAllRoles(): RoleDefinition[] {
return Array.from(this.roles.values());
}
}
@@ -0,0 +1,24 @@
import type { RoleDefinition } from './base.role';
export const TestRole: RoleDefinition = {
name: 'test',
displayName: '测试用户',
credentials: {
username: 'e2e_test_user',
password: 'Test@123'
},
permissions: [
'test:read',
'test:write'
],
cannotAccess: [
'/user-management',
'/role-management'
],
expectedBehaviors: {
canCreate: ['test'],
canRead: ['test'],
canUpdate: ['test'],
canDelete: []
}
};
@@ -0,0 +1,26 @@
import type { RoleDefinition } from './base.role';
export const UserRole: RoleDefinition = {
name: 'user',
displayName: '普通用户',
credentials: {
username: 'normaluser',
password: 'Test@123'
},
permissions: [
'user:read:self',
'user:update:self'
],
cannotAccess: [
'/user-management',
'/role-management',
'/menu-management',
'/system-config'
],
expectedBehaviors: {
canCreate: [],
canRead: ['self'],
canUpdate: ['self'],
canDelete: []
}
};
@@ -0,0 +1,68 @@
import { describe, it, expect, vi } from 'vitest';
import { PermissionHelper } from '../permission-helper';
// Mock Playwright
vi.mock('@playwright/test', () => ({
expect: Object.assign(vi.fn(), {
extend: vi.fn().mockReturnValue(expect),
}),
}));
describe('PermissionHelper', () => {
it('should create PermissionHelper instance', () => {
const mockPage = {
goto: vi.fn(),
url: vi.fn().mockReturnValue('http://localhost:3000/dashboard'),
locator: vi.fn().mockReturnValue({
count: vi.fn().mockResolvedValue(0),
}),
} as any;
const helper = new PermissionHelper(mockPage);
expect(helper).toBeDefined();
});
it('should have verifyCanAccess method', () => {
const mockPage = {
goto: vi.fn(),
url: vi.fn().mockReturnValue('http://localhost:3000/dashboard'),
locator: vi.fn(),
} as any;
const helper = new PermissionHelper(mockPage);
expect(typeof helper.verifyCanAccess).toBe('function');
});
it('should have verifyCannotAccess method', () => {
const mockPage = {
goto: vi.fn(),
url: vi.fn(),
locator: vi.fn(),
} as any;
const helper = new PermissionHelper(mockPage);
expect(typeof helper.verifyCannotAccess).toBe('function');
});
it('should have verifyRolePermissions method', () => {
const mockPage = {
goto: vi.fn(),
url: vi.fn(),
locator: vi.fn(),
} as any;
const helper = new PermissionHelper(mockPage);
expect(typeof helper.verifyRolePermissions).toBe('function');
});
it('should have verifyPermissionBoundary method', () => {
const mockPage = {
goto: vi.fn(),
url: vi.fn(),
locator: vi.fn(),
} as any;
const helper = new PermissionHelper(mockPage);
expect(typeof helper.verifyPermissionBoundary).toBe('function');
});
});
@@ -0,0 +1,79 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { RoleAuthManager } from '../role-auth-manager';
// Mock fetch
global.fetch = vi.fn();
describe('RoleAuthManager', () => {
beforeEach(() => {
RoleAuthManager.clearCache();
vi.clearAllMocks();
});
it('should authenticate and cache token', async () => {
const mockToken = 'mock-jwt-token-12345';
(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { token: mockToken } })
});
const token = await RoleAuthManager.getRoleToken('admin');
expect(token).toBe(mockToken);
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining('/api/auth/login'),
expect.objectContaining({
method: 'POST',
body: expect.stringContaining('admin')
})
);
});
it('should return cached token on second call', async () => {
const mockToken = 'cached-token';
(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { token: mockToken } })
});
const token1 = await RoleAuthManager.getRoleToken('admin');
const token2 = await RoleAuthManager.getRoleToken('admin');
expect(token1).toBe(token2);
expect(global.fetch).toHaveBeenCalledTimes(1);
});
it('should throw error for unknown role', async () => {
await expect(RoleAuthManager.getRoleToken('unknown')).rejects.toThrow("Role 'unknown' not found");
});
it('should throw error on authentication failure', async () => {
(global.fetch as any).mockResolvedValueOnce({
ok: false,
statusText: 'Unauthorized'
});
await expect(RoleAuthManager.getRoleToken('admin')).rejects.toThrow('Authentication failed');
});
it('should clear specific role token', async () => {
const mockToken = 'token-to-clear';
(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { token: mockToken } })
});
await RoleAuthManager.getRoleToken('admin');
RoleAuthManager.clearRoleToken('admin');
// 再次获取应该重新认证
(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { token: 'new-token' } })
});
const newToken = await RoleAuthManager.getRoleToken('admin');
expect(newToken).toBe('new-token');
expect(global.fetch).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,117 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { TestDataManager, getTestDataManager } from '../test-data-manager';
global.fetch = vi.fn();
describe('TestDataManager', () => {
let manager: TestDataManager;
beforeEach(() => {
manager = TestDataManager.getInstance();
manager.clearTracking();
vi.clearAllMocks();
});
it('should be a singleton', () => {
const instance1 = getTestDataManager();
const instance2 = getTestDataManager();
expect(instance1).toBe(instance2);
});
it('should create user and track it', async () => {
const mockUserId = 'user-123';
(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { id: mockUserId } })
});
const userData = {
username: 'testuser',
password: 'Test@123',
email: 'test@example.com',
};
const result = await manager.createUser(userData);
expect(result.id).toBe(mockUserId);
expect(result.type).toBe('user');
expect(result.data.username).toBe('testuser');
expect(manager.getCreatedData('user')).toHaveLength(1);
});
it('should create role and track it', async () => {
const mockRoleId = 'role-456';
(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { id: mockRoleId } })
});
const roleData = {
roleName: '测试角色',
roleKey: 'test_role',
};
const result = await manager.createRole(roleData);
expect(result.id).toBe(mockRoleId);
expect(result.type).toBe('role');
expect(manager.getCreatedData('role')).toHaveLength(1);
});
it('should cleanup created data', async () => {
(global.fetch as any)
.mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { id: 'user-1' } })
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { id: 'user-2' } })
})
.mockResolvedValueOnce({ ok: true })
.mockResolvedValueOnce({ ok: true });
await manager.createUser({ username: 'user1', password: 'Test@123', email: 'user1@test.com' });
await manager.createUser({ username: 'user2', password: 'Test@123', email: 'user2@test.com' });
expect(manager.getCreatedData('user')).toHaveLength(2);
await manager.cleanup('user');
expect(manager.getCreatedData('user')).toHaveLength(0);
expect(global.fetch).toHaveBeenCalledTimes(4); // 2 creates + 2 deletes
});
it('should cleanup all data types when no type specified', async () => {
(global.fetch as any)
.mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { id: 'user-1' } })
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { id: 'role-1' } })
})
.mockResolvedValueOnce({ ok: true })
.mockResolvedValueOnce({ ok: true });
await manager.createUser({ username: 'user1', password: 'Test@123', email: 'user1@test.com' });
await manager.createRole({ roleName: '角色1', roleKey: 'role1' });
await manager.cleanup();
expect(manager.getCreatedData('user')).toHaveLength(0);
expect(manager.getCreatedData('role')).toHaveLength(0);
});
it('should throw error on creation failure', async () => {
(global.fetch as any).mockResolvedValueOnce({
ok: false,
statusText: 'Bad Request'
});
await expect(
manager.createUser({ username: 'test', password: 'Test@123', email: 'test@test.com' })
).rejects.toThrow('Failed to create user');
});
});
@@ -0,0 +1,76 @@
import { Page, BrowserContext } from '@playwright/test';
import { RoleFactory } from '../roles/role-factory';
import { RoleAuthManager } from './role-auth-manager';
import type { RoleDefinition } from '../roles/base.role';
export class AuthHelper {
constructor(
private page: Page,
private context: BrowserContext
) {}
async loginAsRole(roleName: string, useTokenInjection: boolean = true): Promise<void> {
const role = RoleFactory.getRole(roleName);
if (useTokenInjection) {
await this.injectToken(role);
} else {
await this.performLogin(role);
}
}
private async injectToken(role: RoleDefinition): Promise<void> {
const token = await RoleAuthManager.getRoleToken(role.name);
// 注入token到localStorage
await this.page.addInitScript((token) => {
localStorage.setItem('token', token);
localStorage.setItem('username', 'admin');
}, token);
// 设置cookie
await this.context.addCookies([
{
name: 'token',
value: token,
domain: 'localhost',
path: '/',
}
]);
}
private async performLogin(role: RoleDefinition): Promise<void> {
await this.page.goto('/login');
await this.page.fill('input[placeholder*="用户名"]', role.credentials.username);
await this.page.fill('input[placeholder*="密码"]', role.credentials.password);
await this.page.click('button[type="submit"]');
// 等待登录成功跳转
await this.page.waitForURL(/\/(dashboard|home)?/, { timeout: 10000 });
}
async logout(): Promise<void> {
await this.page.click('[data-testid="user-menu"]');
await this.page.click('[data-testid="logout-button"]');
await this.page.waitForURL('/login');
}
async clearAuth(): Promise<void> {
await this.context.clearCookies();
await this.page.evaluate(() => {
localStorage.clear();
sessionStorage.clear();
});
}
}
export async function createAuthenticatedPage(
page: Page,
context: BrowserContext,
roleName: string
): Promise<AuthHelper> {
const helper = new AuthHelper(page, context);
await helper.loginAsRole(roleName);
return helper;
}
@@ -0,0 +1,131 @@
import { Page, expect } from '@playwright/test';
import type { RoleDefinition } from '../roles/base.role';
export class PermissionHelper {
constructor(private page: Page) {}
async verifyCanAccess(path: string): Promise<void> {
await this.page.goto(path);
await expect(this.page).not.toHaveURL(/\/login/);
await expect(this.page).not.toHaveURL(/\/403/);
await expect(this.page).not.toHaveURL(/\/404/);
}
async verifyCannotAccess(path: string): Promise<void> {
await this.page.goto(path);
// 应该被重定向到登录页或显示403错误
const url = this.page.url();
const isForbidden = url.includes('/403') || url.includes('/login');
expect(isForbidden || await this.isAccessDenied()).toBeTruthy();
}
private async isAccessDenied(): Promise<boolean> {
const deniedMessage = this.page.locator('text=/无权限|权限不足|Access Denied|Forbidden/i');
return await deniedMessage.count() > 0;
}
async verifyCanCreate(_resource: string, createButtonSelector: string): Promise<void> {
const createButton = this.page.locator(createButtonSelector);
await expect(createButton).toBeVisible();
await expect(createButton).toBeEnabled();
}
async verifyCannotCreate(_resource: string, createButtonSelector: string): Promise<void> {
const createButton = this.page.locator(createButtonSelector);
const count = await createButton.count();
if (count > 0) {
await expect(createButton).not.toBeVisible();
}
}
async verifyCanEdit(_resourceId: string, editButtonSelector: string): Promise<void> {
const editButton = this.page.locator(editButtonSelector);
await expect(editButton).toBeVisible();
await expect(editButton).toBeEnabled();
}
async verifyCannotEdit(_resourceId: string, editButtonSelector: string): Promise<void> {
const editButton = this.page.locator(editButtonSelector);
const count = await editButton.count();
if (count > 0) {
await expect(editButton).not.toBeVisible();
}
}
async verifyCanDelete(_resourceId: string, deleteButtonSelector: string): Promise<void> {
const deleteButton = this.page.locator(deleteButtonSelector);
await expect(deleteButton).toBeVisible();
await expect(deleteButton).toBeEnabled();
}
async verifyCannotDelete(_resourceId: string, deleteButtonSelector: string): Promise<void> {
const deleteButton = this.page.locator(deleteButtonSelector);
const count = await deleteButton.count();
if (count > 0) {
await expect(deleteButton).not.toBeVisible();
}
}
async verifyRolePermissions(role: RoleDefinition): Promise<void> {
// 验证可访问的路径
for (const path of role.expectedBehaviors.canRead) {
if (path !== 'self') {
await this.verifyCanAccess(`/${path}`);
}
}
// 验证不可访问的路径
for (const path of role.cannotAccess) {
await this.verifyCannotAccess(path);
}
}
async verifyPermissionBoundary(
role: RoleDefinition,
testScenarios: {
resource: string;
path: string;
createButton?: string;
editButton?: string;
deleteButton?: string;
}
): Promise<void> {
await this.page.goto(testScenarios.path);
// 验证创建权限
if (testScenarios.createButton) {
if (role.expectedBehaviors.canCreate.includes(testScenarios.resource)) {
await this.verifyCanCreate(testScenarios.resource, testScenarios.createButton);
} else {
await this.verifyCannotCreate(testScenarios.resource, testScenarios.createButton);
}
}
// 验证编辑权限
if (testScenarios.editButton) {
if (role.expectedBehaviors.canUpdate.includes(testScenarios.resource)) {
await this.verifyCanEdit(testScenarios.resource, testScenarios.editButton);
} else {
await this.verifyCannotEdit(testScenarios.resource, testScenarios.editButton);
}
}
// 验证删除权限
if (testScenarios.deleteButton) {
if (role.expectedBehaviors.canDelete.includes(testScenarios.resource)) {
await this.verifyCanDelete(testScenarios.resource, testScenarios.deleteButton);
} else {
await this.verifyCannotDelete(testScenarios.resource, testScenarios.deleteButton);
}
}
}
}
export function createPermissionHelper(page: Page): PermissionHelper {
return new PermissionHelper(page);
}
@@ -0,0 +1,59 @@
import { RoleFactory } from '../roles/role-factory';
interface TokenCache {
token: string;
expiresAt: number;
}
export class RoleAuthManager {
private static tokenCache: Map<string, TokenCache> = new Map();
private static readonly API_BASE_URL = process.env.VITE_API_BASE_URL || 'http://localhost:8084';
private static readonly TOKEN_EXPIRY_BUFFER = 60000;
static async getRoleToken(roleName: string): Promise<string> {
const cached = this.tokenCache.get(roleName);
if (cached && cached.expiresAt > Date.now() + this.TOKEN_EXPIRY_BUFFER) {
return cached.token;
}
const role = RoleFactory.getRole(roleName);
const token = await this.authenticateWithBackend(role.credentials);
this.tokenCache.set(roleName, {
token,
expiresAt: Date.now() + 3600000
});
return token;
}
private static async authenticateWithBackend(credentials: { username: string; password: string }): Promise<string> {
const path = '/api/auth/login';
const body = JSON.stringify(credentials);
const response = await fetch(`${this.API_BASE_URL}${path}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body,
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Authentication failed for user ${credentials.username}: ${response.statusText} - ${errorText}`);
}
const data = await response.json();
return data.data?.token || data.token;
}
static clearCache(): void {
this.tokenCache.clear();
}
static clearRoleToken(roleName: string): void {
this.tokenCache.delete(roleName);
}
}
@@ -0,0 +1,150 @@
import { Page } from '@playwright/test';
export interface TestData {
id: string;
type: string;
data: Record<string, any>;
createdAt: Date;
}
export class TestDataManager {
private static instance: TestDataManager;
private createdData: Map<string, TestData[]> = new Map();
private _page: Page | null = null;
private static readonly API_BASE_URL = process.env.VITE_API_BASE_URL || 'http://localhost:8084';
static getInstance(): TestDataManager {
if (!TestDataManager.instance) {
TestDataManager.instance = new TestDataManager();
}
return TestDataManager.instance;
}
setPage(page: Page): void {
this._page = page;
}
getPage(): Page | null {
return this._page;
}
async createUser(userData: {
username: string;
password: string;
email: string;
phone?: string;
nickname?: string;
}): Promise<TestData> {
const response = await fetch(`${TestDataManager.API_BASE_URL}/api/users`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
...userData,
status: 1,
}),
});
if (!response.ok) {
throw new Error(`Failed to create user: ${response.statusText}`);
}
const result = await response.json();
const testData: TestData = {
id: result.data?.id || result.id,
type: 'user',
data: userData,
createdAt: new Date(),
};
this.trackData('user', testData);
return testData;
}
async createRole(roleData: {
roleName: string;
roleKey: string;
roleSort?: number;
}): Promise<TestData> {
const response = await fetch(`${TestDataManager.API_BASE_URL}/api/roles`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
...roleData,
status: 1,
}),
});
if (!response.ok) {
throw new Error(`Failed to create role: ${response.statusText}`);
}
const result = await response.json();
const testData: TestData = {
id: result.data?.id || result.id,
type: 'role',
data: roleData,
createdAt: new Date(),
};
this.trackData('role', testData);
return testData;
}
async cleanup(type?: string): Promise<void> {
const typesToClean = type ? [type] : Array.from(this.createdData.keys());
for (const dataType of typesToClean) {
const items = this.createdData.get(dataType) || [];
for (const item of items.reverse()) {
try {
await this.deleteData(item);
} catch (error) {
console.error(`Failed to cleanup ${dataType} ${item.id}:`, error);
}
}
this.createdData.delete(dataType);
}
}
private async deleteData(data: TestData): Promise<void> {
const endpoint = this.getEndpoint(data.type);
await fetch(`${TestDataManager.API_BASE_URL}${endpoint}/${data.id}`, {
method: 'DELETE',
});
}
private getEndpoint(type: string): string {
const endpoints: Record<string, string> = {
user: '/api/users',
role: '/api/roles',
menu: '/api/menus',
config: '/api/configs',
};
return endpoints[type] || `/api/${type}s`;
}
private trackData(type: string, data: TestData): void {
if (!this.createdData.has(type)) {
this.createdData.set(type, []);
}
this.createdData.get(type)!.push(data);
}
getCreatedData(type: string): TestData[] {
return this.createdData.get(type) || [];
}
clearTracking(): void {
this.createdData.clear();
}
}
export function getTestDataManager(): TestDataManager {
return TestDataManager.getInstance();
}
+158
View File
@@ -0,0 +1,158 @@
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
declare module 'vue-router' {
interface RouteMeta {
requiresAuth?: boolean
roles?: string[]
title?: string
}
}
const routes: RouteRecordRaw[] = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/system/Login.vue'),
meta: { title: '登录' }
},
{
path: '/403',
name: 'Forbidden',
component: () => import('@/views/system/Forbidden.vue'),
meta: { title: '无权限' }
},
{
path: '/',
component: () => import('@/layouts/DefaultLayout.vue'),
redirect: '/dashboard',
meta: { requiresAuth: true },
children: [
{
path: 'dashboard',
name: 'Dashboard',
component: () => import('@/views/system/Dashboard.vue'),
meta: { title: '仪表盘' }
},
{
path: 'users',
name: 'UserManagement',
component: () => import('@/views/system/UserManagement.vue'),
meta: { title: '用户管理' }
},
{
path: 'roles',
name: 'RoleManagement',
component: () => import('@/views/system/RoleManagement.vue'),
meta: { title: '角色管理' }
},
{
path: 'menus',
name: 'MenuManagement',
component: () => import('@/views/system/MenuManagement.vue'),
meta: { title: '菜单管理' }
},
{
path: 'sys/config',
name: 'ConfigManagement',
component: () => import('@/views/config/ConfigManagement.vue'),
meta: { title: '参数配置' }
},
{
path: 'dict',
name: 'DictManagement',
component: () => import('@/views/config/DictManagement.vue'),
meta: { title: '字典管理' }
},
{
path: 'files',
name: 'FileManagement',
component: () => import('@/views/file/FileManagement.vue'),
meta: { title: '文件管理' }
},
{
path: 'notice',
name: 'NoticeManagement',
component: () => import('@/views/notify/NoticeManagement.vue'),
meta: { title: '通知公告' }
},
{
path: 'loginlog',
name: 'LoginLog',
component: () => import('@/views/audit/LoginLog.vue'),
meta: { title: '登录日志' }
},
{
path: 'oplog',
name: 'OperationLog',
component: () => import('@/views/audit/OperationLog.vue'),
meta: { title: '操作日志' }
},
{
path: 'exceptionlog',
name: 'ExceptionLog',
component: () => import('@/views/audit/ExceptionLog.vue'),
meta: { title: '异常日志' }
}
]
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
function checkRoutePermission(route: RouteLocationNormalized, userRoles: string[]): boolean {
if (!route.meta.roles || !Array.isArray(route.meta.roles) || route.meta.roles.length === 0) {
return true
}
return route.meta.roles.some((role: string) => userRoles.includes(role))
}
router.beforeEach((to, _from, next) => {
try {
const token = localStorage.getItem('token')
const rolesStr = localStorage.getItem('roles')
let userRoles: string[] = []
try {
userRoles = rolesStr ? JSON.parse(rolesStr) : []
} catch (e) {
console.warn('解析用户角色失败,将使用空数组:', e)
userRoles = []
}
if (to.meta.title) {
document.title = `${to.meta.title} - Novalon 管理系统`
}
if (to.path === '/login') {
if (token) {
next('/')
} else {
next()
}
} else if (to.path === '/403') {
next()
} else {
if (to.meta.requiresAuth !== false && !token) {
next('/login')
return
}
if (!checkRoutePermission(to, userRoles)) {
console.warn(`用户角色 ${userRoles} 无权访问路由 ${to.path},需要角色: ${to.meta.roles}`)
next('/403')
return
}
next()
}
} catch (error) {
console.error('路由守卫错误:', error)
next('/login')
}
})
export default router
+210
View File
@@ -0,0 +1,210 @@
import { defineStore } from 'pinia'
import request from '@/utils/request'
export interface MenuItem {
id: number
name: string
path: string
icon?: string
parentId?: number
sort: number
children?: MenuItem[]
}
interface BackendMenuItem {
id: number
menuName: string
parentId: number
orderNum: number
menuType: string
perms?: string
component?: string
status: number
children?: BackendMenuItem[]
}
function transformMenuData(backendMenus: BackendMenuItem[]): MenuItem[] {
const menuMap = new Map<number, MenuItem>()
const rootMenus: MenuItem[] = []
const componentToPathMap: Record<string, string> = {
'system/user/index': '/users',
'system/role/index': '/roles',
'system/menu/index': '/menus',
'system/dict/index': '/dict',
'system/config/index': '/sys/config',
'system/notice/index': '/notice',
'system/file/index': '/files',
'audit/operation/index': '/oplog',
'audit/login/index': '/loginlog',
'audit/exception/index': '/exceptionlog',
}
const filteredMenus = backendMenus.filter(menu => menu.menuType !== 'F')
filteredMenus.forEach(menu => {
const menuItem: MenuItem = {
id: menu.id,
name: menu.menuName,
path: menu.component ? (componentToPathMap[menu.component] || `/${menu.component.replace('/index', '').replace('system/', '')}`) : '',
icon: getMenuIcon(menu.menuName),
parentId: menu.parentId === 0 ? undefined : menu.parentId,
sort: menu.orderNum
}
menuMap.set(menu.id, menuItem)
})
filteredMenus.forEach(menu => {
const menuItem = menuMap.get(menu.id)!
if (menu.parentId === 0) {
rootMenus.push(menuItem)
} else {
const parentMenu = menuMap.get(menu.parentId)
if (parentMenu) {
if (!parentMenu.children) {
parentMenu.children = []
}
parentMenu.children.push(menuItem)
}
}
})
rootMenus.forEach(menu => {
if (menu.children) {
menu.children.sort((a, b) => a.sort - b.sort)
}
})
return rootMenus.sort((a, b) => a.sort - b.sort)
}
function getMenuIcon(menuName: string): string {
const iconMap: Record<string, string> = {
'系统管理': 'Setting',
'审计日志': 'Document',
'系统监控': 'Monitor',
'用户管理': 'User',
'角色管理': 'UserFilled',
'菜单管理': 'Menu',
'字典管理': 'Collection',
'参数配置': 'Tools',
'通知公告': 'Bell',
'文件管理': 'Folder',
'操作日志': 'Document',
'登录日志': 'Document',
'异常日志': 'Warning'
}
return iconMap[menuName] || 'Document'
}
interface PermissionState {
roles: string[]
permissions: string[]
menus: MenuItem[]
loaded: boolean
}
export const usePermissionStore = defineStore('permission', {
state: (): PermissionState => ({
roles: [],
permissions: [],
menus: [],
loaded: false
}),
getters: {
hasRole: (state) => (role: string | string[]) => {
if (Array.isArray(role)) {
return role.some(r => state.roles.includes(r))
}
return state.roles.includes(role)
},
hasPermission: (state) => (permission: string | string[]) => {
if (Array.isArray(permission)) {
return permission.some(p => state.permissions.includes(p))
}
return state.permissions.includes(permission)
}
},
actions: {
setPermissionData(data: {
roles: string[]
permissions: string[]
menus: MenuItem[]
}) {
this.roles = data.roles
this.permissions = data.permissions
this.menus = data.menus
this.loaded = true
this.saveToStorage()
},
clearPermissionData() {
this.roles = []
this.permissions = []
this.menus = []
this.loaded = false
localStorage.removeItem('permission')
},
saveToStorage() {
const data = {
roles: this.roles,
permissions: this.permissions,
menus: this.menus
}
localStorage.setItem('permission', JSON.stringify(data))
},
initFromStorage() {
const stored = localStorage.getItem('permission')
if (stored) {
try {
const data = JSON.parse(stored)
this.roles = data.roles || []
this.permissions = data.permissions || []
this.menus = data.menus || []
this.loaded = true
} catch (error) {
console.error('从 localStorage 恢复权限数据失败:', error)
}
}
},
async fetchUserMenus() {
try {
const res: any = await request.get('/menus')
if (res && Array.isArray(res)) {
const transformedMenus = transformMenuData(res)
const permissions: string[] = []
const extractPermissions = (menus: BackendMenuItem[]) => {
menus.forEach(menu => {
if (menu.perms) {
permissions.push(menu.perms)
}
if (menu.children && menu.children.length > 0) {
extractPermissions(menu.children)
}
})
}
extractPermissions(res)
this.setPermissionData({
roles: JSON.parse(localStorage.getItem('roles') || '[]'),
permissions: permissions,
menus: transformedMenus
})
}
} catch (error) {
console.error('获取用户菜单失败:', error)
throw error
}
}
}
})
@@ -0,0 +1,267 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import ConfigManagement from '@/views/config/ConfigManagement.vue'
vi.mock('vue-router')
vi.mock('element-plus', () => ({
ElMessage: {
success: vi.fn(),
error: vi.fn(),
},
ElMessageBox: {
confirm: vi.fn(),
},
}))
vi.mock('@/utils/request', () => {
const mockRequest = {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
}
mockRequest.get.mockResolvedValue([])
mockRequest.post.mockResolvedValue({})
mockRequest.put.mockResolvedValue({})
mockRequest.delete.mockResolvedValue({})
return {
default: mockRequest,
}
})
describe('ConfigManagement Component', () => {
let router: any
let wrapper: any
beforeEach(() => {
router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: { template: '<div>Home</div>' } },
],
})
vi.clearAllMocks()
})
afterEach(() => {
if (wrapper) {
wrapper.unmount()
}
})
describe('component initialization', () => {
it('should render config management container', () => {
wrapper = mount(ConfigManagement, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-tag': true,
'el-dialog': true,
'el-form': true,
'el-form-item': true,
'el-input': true,
'el-icon': true,
},
},
})
expect(wrapper.find('.config-management').exists()).toBe(true)
})
it('should initialize with empty data source', () => {
wrapper = mount(ConfigManagement, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-tag': true,
'el-dialog': true,
'el-form': true,
'el-form-item': true,
'el-input': true,
'el-icon': true,
},
},
})
expect(wrapper.vm.dataSource).toBeDefined()
expect(Array.isArray(wrapper.vm.dataSource)).toBe(true)
})
it('should initialize with loading state false', () => {
wrapper = mount(ConfigManagement, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-tag': true,
'el-dialog': true,
'el-form': true,
'el-form-item': true,
'el-input': true,
'el-icon': true,
},
},
})
expect(wrapper.vm.loading).toBeDefined()
expect(typeof wrapper.vm.loading).toBe('boolean')
})
it('should initialize with modal visible false', () => {
wrapper = mount(ConfigManagement, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-tag': true,
'el-dialog': true,
'el-form': true,
'el-form-item': true,
'el-input': true,
'el-icon': true,
},
},
})
expect(wrapper.vm.modalVisible).toBe(false)
})
it('should initialize with empty form state', () => {
wrapper = mount(ConfigManagement, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-tag': true,
'el-dialog': true,
'el-form': true,
'el-form-item': true,
'el-input': true,
'el-icon': true,
},
},
})
expect(wrapper.vm.formState.configName).toBe('')
expect(wrapper.vm.formState.configKey).toBe('')
expect(wrapper.vm.formState.configValue).toBe('')
})
})
describe('add config functionality', () => {
it('should have handleAdd method', () => {
wrapper = mount(ConfigManagement, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-tag': true,
'el-dialog': true,
'el-form': true,
'el-form-item': true,
'el-input': true,
'el-icon': true,
},
},
})
expect(typeof wrapper.vm.handleAdd).toBe('function')
})
})
describe('edit config functionality', () => {
it('should have handleEdit method', () => {
wrapper = mount(ConfigManagement, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-tag': true,
'el-dialog': true,
'el-form': true,
'el-form-item': true,
'el-input': true,
'el-icon': true,
},
},
})
expect(typeof wrapper.vm.handleEdit).toBe('function')
})
})
describe('delete config functionality', () => {
it('should have handleDelete method', () => {
wrapper = mount(ConfigManagement, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-tag': true,
'el-dialog': true,
'el-form': true,
'el-form-item': true,
'el-input': true,
'el-icon': true,
},
},
})
expect(typeof wrapper.vm.handleDelete).toBe('function')
})
})
describe('form submission', () => {
it('should have handleModalOk method', () => {
wrapper = mount(ConfigManagement, {
global: {
plugins: [router],
stubs: {
'el-card': true,
'el-button': true,
'el-table': true,
'el-table-column': true,
'el-tag': true,
'el-dialog': true,
'el-form': true,
'el-form-item': true,
'el-input': true,
'el-icon': true,
},
},
})
expect(typeof wrapper.vm.handleModalOk).toBe('function')
})
})
})

Some files were not shown because too many files have changed in this diff Show More