初始提交

This commit is contained in:
焦龙言
2026-07-24 09:43:10 +08:00
commit b5b4721456
793 changed files with 116305 additions and 0 deletions
+115
View File
@@ -0,0 +1,115 @@
# ShiErFeng 项目测试套件
这是一个为施尔丰项目创建的完整测试套件,使用 Playwright 进行端到端测试。测试环境已禁用验证码功能,以简化测试流程。
## 功能特性
- 使用默认方式手动启动前后端服务或`Docker Compose`自动启动项目前后端服务
- 测试环境已禁用验证码功能
- 验证登录流程和认证机制
- 测试所有受保护的页面功能
- 验证未登录用户访问受保护页面时的重定向行为
## 依赖安装
```bash
pip install -r requirements.txt
playwright install
```
## 使用方法
### 方式一:默认方法
#### 启动前端
```bash
cd shierfeng-fastapi-frontend
npm install
npm run dev
```
#### 启动后端
```bash
cd shierfeng-fastapi-backend
pip install -r requirements.txt
python app.py --env=dev
```
#### 运行测试
```bash
cd shierfeng-fastapi-test
pip install -r requirements.txt
python -m pytest -v
```
### 方式二:使用Docker
#### 进入测试目录
```bash
cd shierfeng-fastapi-test
```
#### 启动 Docker 服务
```bash
# MySQL版本
docker compose -f docker-compose.test.my.yml up -d --build
# PostgreSQL版本
docker compose -f docker-compose.test.pg.yml up -d --build
```
#### 运行测试
```bash
pip install -r requirements.txt
python -m pytest -v
```
## 测试内容
### 登录测试
- 验证登录页面正常加载
- 验证登录流程(测试环境已禁用验证码)
- 测试认证后的页面访问
### 页面访问和功能测试
- 仪表盘页面
- 用户管理页面
- 角色管理页面
- 菜单管理页面
- 部门管理页面
- 岗位管理页面
- 字典管理页面
- 参数配置页面
- 通知公告页面
- 日志管理页面(操作日志、登录日志)
- 在线用户页面
- 定时任务页面
- 服务监控页面
- 数据监控页面
- 缓存监控页面
- 缓存列表页面
- 代码生成页面
- 系统接口页面
### 认证测试
- 验证未登录用户访问受保护页面时被重定向到登录页
- 验证登录后可以访问受保护页面
## 配置说明
使用 `docker-compose.test.my.yml`或`docker-compose.test.pg.yml`启动服务,默认前端端口为 `80`,后端端口为 `9099`。测试环境已禁用验证码功能。
## 注意事项
1. 确保系统已安装 Docker 和 Docker Compose
2. 确保端口 `80` 和 `9099` 未被占用
3. 首次运行时 Docker 镜像构建可能需要几分钟时间
4. 测试使用默认管理员账户:用户名 `admin`,密码 `admin123`
@@ -0,0 +1,87 @@
import re
from playwright.async_api._context_manager import PlaywrightContextManager
from common.login_helper import LoginHelper
class BasePageTest:
browser = None
context = None
page = None
token = None
async def setup(self, playwright: PlaywrightContextManager) -> None:
"""初始化浏览器和登录"""
# 首先登录获取token
helper = LoginHelper()
self.token = helper.login(username='admin', password='admin123')
assert self.token is not None, '登录应该成功'
# 启动浏览器
self.browser = await playwright.chromium.launch(headless=True)
self.context = await self.browser.new_context()
# 设置认证token
await self.context.add_cookies(
[
{
'name': 'Admin-Token',
'value': self.token,
'domain': 'localhost',
'path': '/',
'httpOnly': False,
'secure': False,
}
]
)
self.page = await self.context.new_page()
async def teardown(self) -> None:
"""清理资源"""
if self.context:
await self.context.close()
if self.browser:
await self.browser.close()
async def goto_page(self, url: str) -> None:
"""访问指定页面"""
await self.page.goto(url)
async def wait_for_page_title(self, title_text: str, timeout: int = 10000) -> None:
"""等待页面标题出现"""
await self.page.wait_for_selector(f'div:has-text("{title_text}")', timeout=timeout)
page_title = await self.page.inner_text(f'div:has-text("{title_text}")')
assert title_text in page_title
async def wait_for_selector(self, selector: str, timeout: int = 10000) -> None:
"""等待选择器出现"""
await self.page.wait_for_selector(selector, timeout=timeout)
async def query_selector(self, selector: str) -> any:
"""查询选择器元素"""
return await self.page.query_selector(selector)
async def click_button(self, button_text: str) -> None:
"""点击按钮"""
button = await self.page.wait_for_selector(f'text="{button_text}"', timeout=5000)
await button.click()
async def fill_input(self, selector: str, value: str) -> None:
"""填充输入框"""
await self.page.fill(selector, value)
async def get_text_content(self, selector: str) -> str:
"""获取元素文本内容"""
element = await self.page.query_selector(selector)
if element:
return await element.text_content()
return None
async def get_table_total_rows(self) -> int:
"""获取表格总行数"""
element = self.page.locator('span.el-pagination__total').first
text = await element.text_content()
# 提取数字
number = re.search(r'\d+', text).group()
return int(number)
+3
View File
@@ -0,0 +1,3 @@
class Config:
frontend_url = 'http://localhost:80'
backend_url = 'http://localhost:9099'
@@ -0,0 +1,57 @@
import requests
from common.config import Config
class LoginHelper:
"""
登录辅助类
"""
def __init__(self, base_url: str = Config.frontend_url) -> None:
self.base_url = base_url
self.session = requests.Session()
def login(self, username: str = 'admin', password: str = 'admin123', max_retries: int = 3) -> str | None:
"""
执行登录操作(在测试环境中,验证码已禁用)
"""
for _attempt in range(max_retries):
# 在测试环境中,验证码已禁用,所以直接登录
login_data = {'username': username, 'password': password}
headers = {'Content-Type': 'application/x-www-form-urlencoded', 'Referer': f'{self.base_url}/login'}
response = self.session.post(f'{Config.backend_url}/login', data=login_data, headers=headers)
http_ok = 200
if response.status_code == http_ok:
result = response.json()
# 检查登录是否成功 - 可能是 code=200 或 success=true
if result.get('code') == http_ok or result.get('success'):
# 尝试从不同可能的位置获取token
token = result.get('token') # 直接在根级别
if not token:
token = result.get('data', {}).get('token') # 在data对象内
if token:
print('登录成功')
return token
print(f'登录失败: 响应中未找到token - {result}')
else:
print(f'登录失败: {result.get("msg", "未知错误")}')
else:
print(f'登录请求失败: 状态码 {response.status_code}')
print('登录失败,已达到最大重试次数')
return None
# 使用示例
if __name__ == '__main__':
helper = LoginHelper()
token = helper.login()
if token:
print(f'获取到token: {token}')
else:
print('登录失败')
@@ -0,0 +1,4 @@
-- 用于测试环境的SQL脚本,禁用验证码功能
-- 更新验证码配置为禁用状态
UPDATE sys_config SET config_value = 'false' WHERE config_key = 'sys.account.captchaEnabled';
@@ -0,0 +1,77 @@
services:
# 前端服务
shierfeng-frontend:
build:
context: ../shierfeng-fastapi-frontend
dockerfile: Dockerfile
image: shierfeng-frontend:latest
container_name: shierfeng-frontend-test
ports:
- "80:80"
volumes:
- ../shierfeng-fastapi-frontend/bin/nginx.dockermy.conf:/etc/nginx/conf.d/default.conf
depends_on:
- shierfeng-backend-my
networks:
- shierfeng-network-test
# 后端服务(MySQL版本)
shierfeng-backend-my:
build:
context: ../shierfeng-fastapi-backend
dockerfile: Dockerfile.my
image: shierfeng-backend-my:latest
container_name: shierfeng-backend-my-test
ports:
- "9099:9099"
environment:
TRANSPORT_CRYPTO_ENABLED: "false"
TRANSPORT_CRYPTO_MODE: "off"
depends_on:
shierfeng-mysql:
condition: service_healthy
shierfeng-redis:
condition: service_healthy
networks:
- shierfeng-network-test
# MySQL服务
shierfeng-mysql:
image: mysql:8.0
container_name: shierfeng-mysql-test
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: shierfeng-fastapi
ports:
- "13307:3306"
volumes:
- ../shierfeng-fastapi-backend/sql/shierfeng-fastapi.sql:/docker-entrypoint-initdb.d/01-shierfeng-fastapi.sql
- ./disable_captcha.sql:/docker-entrypoint-initdb.d/02-disable-captcha.sql
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_general_ci --skip-character-set-client-handshake=1
networks:
- shierfeng-network-test
healthcheck:
test: ["CMD", "bash", "-c", "mysqladmin ping -h localhost -u root -proot && mysql -h localhost -u root -proot -e 'SELECT 1'"]
interval: 5s
timeout: 10s
retries: 30
# Redis服务
shierfeng-redis:
image: redis:latest
container_name: shierfeng-redis-test
ports:
- "16380:6379"
networks:
- shierfeng-network-test
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 10s
retries: 30
# 网络配置
networks:
shierfeng-network-test:
name: shierfeng-network-test
driver: bridge
@@ -0,0 +1,77 @@
services:
# 前端服务
shierfeng-frontend:
build:
context: ../shierfeng-fastapi-frontend
dockerfile: Dockerfile
image: shierfeng-frontend:latest
container_name: shierfeng-frontend-test
ports:
- "80:80"
volumes:
- ../shierfeng-fastapi-frontend/bin/nginx.dockerpg.conf:/etc/nginx/conf.d/default.conf
depends_on:
- shierfeng-backend-pg
networks:
- shierfeng-network-test
# 后端服务(PostgreSQL版本)
shierfeng-backend-pg:
build:
context: ../shierfeng-fastapi-backend
dockerfile: Dockerfile.pg
image: shierfeng-backend-pg:latest
container_name: shierfeng-backend-pg-test
ports:
- "9099:9099"
environment:
TRANSPORT_CRYPTO_ENABLED: "false"
TRANSPORT_CRYPTO_MODE: "off"
depends_on:
shierfeng-pg:
condition: service_healthy
shierfeng-redis:
condition: service_healthy
networks:
- shierfeng-network-test
# PostgreSQL服务
shierfeng-pg:
image: postgres:14
container_name: shierfeng-pg-test
environment:
POSTGRES_PASSWORD: root
POSTGRES_DB: shierfeng-fastapi
POSTGRES_INITDB_ARGS: --encoding=UTF8 --lc-collate=C --lc-ctype=C
ports:
- "15433:5432"
volumes:
- ../shierfeng-fastapi-backend/sql/shierfeng-fastapi-pg.sql:/docker-entrypoint-initdb.d/01-shierfeng-fastapi-pg.sql
- ./disable_captcha.sql:/docker-entrypoint-initdb.d/02-disable-captcha.sql
networks:
- shierfeng-network-test
healthcheck:
test: ["CMD", "pg_isready", "-U", "postgres"]
interval: 5s
timeout: 10s
retries: 30
# Redis服务
shierfeng-redis:
image: redis:latest
container_name: shierfeng-redis-test
ports:
- "16381:6379"
networks:
- shierfeng-network-test
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 10s
retries: 30
# 网络配置
networks:
shierfeng-network-test:
name: shierfeng-network-test
driver: bridge
@@ -0,0 +1,81 @@
import pytest
from playwright.async_api import async_playwright, expect
from common.base_page_test import BasePageTest
from common.config import Config
class CacheListTest(BasePageTest):
"""缓存列表测试类"""
async def test_cache_list(self) -> None:
"""测试缓存列表页面"""
await self.page.goto(Config.frontend_url + '/monitor/cacheList')
await self.page.wait_for_load_state('networkidle')
# 1. 缓存列表 - 点击 sys_config
await self.page.wait_for_selector('text=缓存列表')
# 等待 sys_config 出现并点击
sys_config_row = self.page.locator('.el-card', has_text='缓存列表').locator('tr', has_text='sys_config')
await sys_config_row.wait_for()
await sys_config_row.click()
# 2. 键名列表 - 点击 sys.account.captchaEnabled
await self.page.wait_for_selector('text=键名列表')
# 等待 sys.account.captchaEnabled 出现并点击
captcha_row = self.page.locator('.el-card', has_text='键名列表').locator(
'tr', has_text='sys.account.captchaEnabled'
)
await captcha_row.wait_for()
await captcha_row.click()
# 3. 验证缓存内容为 false
await self.page.wait_for_selector('text=缓存内容')
# 等待数据加载
await self.page.wait_for_timeout(2000)
# 获取内容
# 缓存内容显示在一个 textarea 中
content_area = self.page.locator('div.el-form-item', has_text='缓存内容:').locator('textarea')
# 也可以尝试直接获取 .el-form-item__content 的文本,如果 textarea 不可交互
if await content_area.count() > 0:
# 尝试等待内容不为空
try:
await expect(content_area).not_to_be_empty(timeout=5000)
except TimeoutError:
pass
value = await content_area.input_value()
print(f'Cache content value: {value}')
# 如果为空,可能需要重新点击一下
if not value:
print('Value is empty, trying to click key again')
await captcha_row.click()
await self.page.wait_for_timeout(2000)
value = await content_area.input_value()
print(f'Cache content value after retry: {value}')
assert 'false' in value.lower(), f"Expected 'false' in cache content, got: {value}"
else:
# 如果不是 textarea,尝试获取文本
content_div = self.page.locator('div.el-form-item', has_text='缓存内容:').locator('.el-form-item__content')
value = await content_div.text_content()
print(f'Cache content text: {value}')
assert 'false' in value.lower(), f"Expected 'false' in cache content, got: {value}"
@pytest.mark.asyncio
async def test_cache_list_page() -> None:
"""测试缓存列表页面功能"""
async with async_playwright() as p:
test_instance = CacheListTest()
await test_instance.setup(p)
try:
await test_instance.test_cache_list()
finally:
await test_instance.teardown()
@@ -0,0 +1,48 @@
import pytest
from playwright.async_api import async_playwright
from common.base_page_test import BasePageTest
from common.config import Config
class CacheMonitorTest(BasePageTest):
"""缓存监控测试类"""
async def test_cache_monitor(self) -> None:
"""测试缓存监控页面"""
await self.page.goto(Config.frontend_url + '/monitor/cache')
await self.page.wait_for_load_state('networkidle')
# 验证基本信息
await self.page.wait_for_selector('text=基本信息')
await self.page.wait_for_selector('text=Redis版本')
await self.page.wait_for_selector('text=运行模式')
# 验证命令统计
await self.page.wait_for_selector('text=命令统计')
# 验证内存信息
await self.page.wait_for_selector('text=内存信息')
# 验证端口为 6379
port_row = self.page.locator('tr', has_text='端口')
try:
await port_row.wait_for(timeout=5000)
text = await port_row.text_content()
assert '6379' in text, f"Expected port '6379' in row, but got: {text}"
except Exception:
print("Warning: '端口' row not found, checking page content")
content = await self.page.content()
assert '6379' in content, "Port '6379' not found in page content"
@pytest.mark.asyncio
async def test_cache_monitor_page() -> None:
"""测试缓存监控页面功能"""
async with async_playwright() as p:
test_instance = CacheMonitorTest()
await test_instance.setup(p)
try:
await test_instance.test_cache_monitor()
finally:
await test_instance.teardown()
@@ -0,0 +1,185 @@
from datetime import datetime
import pytest
from playwright.async_api import async_playwright
from common.base_page_test import BasePageTest
from common.config import Config
class JobManagementTest(BasePageTest):
"""定时任务管理测试"""
def generate_job_data(self) -> dict:
"""生成测试数据"""
timestamp = datetime.now().strftime('%H%M%S')
return {
'job_name': f'测试任务_{timestamp}',
'job_group': '默认',
'job_executor': '进程池',
'invoke_target': 'module_task.scheduler_test.job', # 必须为这个值
'cron_expression': '0/2 * * * * ?', # 每2秒执行一次
'new_invoke_target': 'module_task.scheduler_test.job', # 修改时也用这个
'job_name_edit': f'测试任务_{timestamp}_edit',
}
async def create_job(
self, job_name: str, job_group: str, job_executor: str, invoke_target: str, cron_expression: str
) -> None:
"""创建定时任务"""
await self.page.get_by_role('button', name='新增').click()
# 等待对话框
dialog = self.page.get_by_role('dialog')
await dialog.wait_for()
# 填写表单
await dialog.get_by_role('textbox', name='任务名称').fill(job_name)
# 选择任务分组
await dialog.locator("label:has-text('任务分组') + div .el-select").click()
await self.page.get_by_role('option', name=job_group).click()
# 选择任务执行器
await dialog.locator("label:has-text('任务执行器') + div .el-select").click()
await self.page.get_by_role('option', name=job_executor).click()
# 填写调用方法
await dialog.get_by_role('textbox', name='调用方法').fill(invoke_target)
# 填写cron表达式
await dialog.get_by_role('textbox', name='cron表达式').fill(cron_expression)
# 策略:如果"失败"策略等有默认值,就不管了
await dialog.get_by_role('button', name='确 定').click()
await self.wait_for_selector("div:has-text('新增成功')", timeout=10000)
async def search_job(self, job_name: str) -> None:
"""搜索任务"""
# 我们可以限制在 form 里搜索
form = self.page.locator('form').first
await form.get_by_role('textbox', name='任务名称').fill(job_name)
await self.page.get_by_role('button', name='搜索').click()
await self.page.wait_for_timeout(1000) # 等待搜索结果
async def edit_job(self, job_name: str, new_invoke_target: str) -> None:
"""修改任务"""
await self.search_job(job_name)
# 点击修改 (在操作列)
row = self.page.locator('tbody tr').first
# 操作列按钮顺序: 修改, 删除, 执行一次, 详细, 日志
await row.locator('button').nth(0).click()
dialog = self.page.get_by_role('dialog')
await dialog.wait_for()
# 修改调用方法
await dialog.get_by_role('textbox', name='调用方法').fill(new_invoke_target)
await dialog.get_by_role('button', name='确 定').click()
await self.wait_for_selector("div:has-text('修改成功')", timeout=10000)
async def toggle_job_status(self, job_name: str) -> None:
"""切换任务状态"""
await self.search_job(job_name)
row = self.page.locator('tbody tr').first
# 切换开关
# 需要点击可见的 .el-switch 或 .el-switch__core
await row.locator('.el-switch').click()
# 确认切换
await self.page.get_by_role('button', name='确定').click()
await self.page.wait_for_timeout(1000)
await self.wait_for_selector("div:has-text('成功')", timeout=10000)
async def run_job_once(self, job_name: str) -> None:
"""执行一次任务"""
await self.search_job(job_name)
row = self.page.locator('tbody tr').first
# 点击执行一次 (第3个按钮)
await row.locator('button').nth(2).click()
# 确认执行
await self.page.get_by_role('button', name='确定').click()
await self.wait_for_selector("div:has-text('执行成功')", timeout=10000)
async def view_job_log(self, job_name: str) -> None:
"""查看调度日志"""
# 可以点击顶部的 "日志" 按钮,也可以点击行的 "调度日志"
# 我们点击行的 "调度日志" (icon="Operation")
await self.search_job(job_name)
row = self.page.locator('tbody tr').first
log_btn = row.locator('button').nth(4)
await log_btn.click()
# 等待跳转到日志页面 (实际上是 router push 到 /monitor/job-log)
# 检查 URL
await self.page.wait_for_url('**/monitor/job-log**')
# 验证在日志页面
await self.wait_for_selector('text=调度日志')
# 关闭返回 (点击 "关闭" 按钮)
await self.page.get_by_role('button', name='关闭').click()
# 等待返回任务列表
await self.page.wait_for_url('**/monitor/job')
async def delete_job(self, job_name: str) -> None:
"""删除任务"""
await self.search_job(job_name)
row = self.page.locator('tbody tr').first
# 点击删除 (第2个按钮)
await row.locator('button').nth(1).click()
# 确认删除
await self.page.get_by_role('button', name='确定').click()
await self.wait_for_selector("div:has-text('删除成功')", timeout=10000)
async def test_job_crud_operations(self) -> None:
"""测试定时任务管理全流程"""
data = self.generate_job_data()
# 1. 进入定时任务页面
await self.goto_page(Config.frontend_url + '/monitor/job')
await self.wait_for_selector('text=任务名称')
# 2. 创建任务
await self.create_job(
data['job_name'], data['job_group'], data['job_executor'], data['invoke_target'], data['cron_expression']
)
# 3. 修改任务 (修改调用方法)
await self.edit_job(data['job_name'], data['new_invoke_target'])
# 4. 切换状态 (开启/关闭)
await self.toggle_job_status(data['job_name']) # 开启
await self.page.wait_for_timeout(1000)
await self.toggle_job_status(data['job_name']) # 关闭
# 5. 执行一次
await self.run_job_once(data['job_name'])
# 6. 查看日志
await self.view_job_log(data['job_name'])
# 7. 删除任务
await self.delete_job(data['job_name'])
@pytest.mark.asyncio
async def test_job_management_page() -> None:
"""测试定时任务管理页面功能"""
async with async_playwright() as p:
test_instance = JobManagementTest()
await test_instance.setup(p)
try:
await test_instance.test_job_crud_operations()
finally:
await test_instance.teardown()
@@ -0,0 +1,73 @@
import pytest
from playwright.async_api import async_playwright
from common.base_page_test import BasePageTest
from common.config import Config
class OnlineUserTest(BasePageTest):
"""在线用户测试类"""
async def test_online_user_operations(self) -> None:
"""测试在线用户强退"""
# 1. 模拟 niangao 用户登录以产生在线会话
# 使用一个新的上下文来模拟另一个用户的登录
niangao_context = await self.browser.new_context()
niangao_page = await niangao_context.new_page()
# 登录 niangao
try:
await niangao_page.goto(Config.frontend_url + '/login')
await niangao_page.locator('div.el-form-item', has_text='账号').locator('input').fill('niangao')
await niangao_page.locator('div.el-form-item', has_text='密码').locator('input').fill('admin123')
await niangao_page.get_by_role('button', name='登 录').click()
# 等待登录成功
await niangao_page.wait_for_url('**/index')
except Exception as e:
print(f'Niangao login failed: {e}. Trying admin123')
try:
await niangao_page.locator('div.el-form-item', has_text='密码').locator('input').fill('123456')
await niangao_page.get_by_role('button', name='登 录').click()
await niangao_page.wait_for_url('**/index')
except Exception as e2:
print(f'Niangao login failed again: {e2}')
# 2. 切换回 admin (self.page) 进行操作
await self.page.goto(Config.frontend_url + '/monitor/online')
await self.page.wait_for_load_state('networkidle')
# 3. 搜索 niangao 用户
await self.page.locator('div.el-form-item', has_text='用户名称').locator('input').fill('niangao')
await self.page.get_by_role('button', name='搜索').click()
await self.page.wait_for_timeout(1000)
# 4. 强退操作
# 检查是否有数据
rows = self.page.locator('.el-table__body tr')
count = await rows.count()
if count > 0:
# 点击强退
await rows.first.get_by_role('button', name='强退').click()
# 确认
await self.page.get_by_role('button', name='确定').click()
# 验证
await self.page.wait_for_selector('text=删除成功', timeout=3000)
# 再次搜索确认消失
await self.page.wait_for_timeout(1000)
await self.page.get_by_role('button', name='搜索').click()
# 关闭 niangao 的上下文
await niangao_context.close()
@pytest.mark.asyncio
async def test_online_user_page() -> None:
"""测试在线用户页面功能"""
async with async_playwright() as p:
test_instance = OnlineUserTest()
await test_instance.setup(p)
try:
await test_instance.test_online_user_operations()
finally:
await test_instance.teardown()
@@ -0,0 +1,46 @@
import pytest
from playwright.async_api import async_playwright
from common.base_page_test import BasePageTest
from common.config import Config
class ServerMonitorTest(BasePageTest):
"""服务监控测试类"""
async def test_server_monitor(self) -> None:
"""测试服务监控页面"""
await self.page.goto(Config.frontend_url + '/monitor/server')
await self.page.wait_for_load_state('networkidle')
# 验证主要板块存在
await self.page.wait_for_selector('text=CPU')
await self.page.wait_for_selector('text=内存')
await self.page.wait_for_selector('text=服务器信息')
await self.page.wait_for_selector('text=Python解释器信息')
await self.page.wait_for_selector('text=磁盘状态')
# 验证项目路径为 /app
# 尝试在表格行中查找
project_path_row = self.page.locator('tr', has_text='项目路径')
try:
await project_path_row.wait_for(timeout=5000)
text = await project_path_row.text_content()
assert '/app' in text, f"Expected project path '/app' in row, but got: {text}"
except Exception:
# 如果没找到行,尝试全局搜索
print("Warning: '项目路径' row not found, checking page content")
content = await self.page.content()
assert '/app' in content, "Project path '/app' not found in page content"
@pytest.mark.asyncio
async def test_server_monitor_page() -> None:
"""测试服务监控页面功能"""
async with async_playwright() as p:
test_instance = ServerMonitorTest()
await test_instance.setup(p)
try:
await test_instance.test_server_monitor()
finally:
await test_instance.teardown()
+5
View File
@@ -0,0 +1,5 @@
playwright
pytest
pytest-asyncio
pytest-playwright
ruff
+68
View File
@@ -0,0 +1,68 @@
line-length = 120
show-fixes = true
target-version = "py310"
unsafe-fixes = true
[lint]
select = [
"FAST", # fastapi
"ANN", # flake8-annotations
"ASYNC", # flake8-async
"B", # flake8-bugbear
"A", # flake8-builtins
"COM", # flake8-commas
"C4", # flake8-comprehensions
"ISC", # flake8-implicit-str-concat
"ICN", # flake8-import-conventions
"PIE", # flake8-pie
"RSE", # flake8-raise
"RET", # flake8-return
"SIM", # flake8-simplify
"TC", # flake8-type-checking
"FLY", # flynt
"I", # isort
"N", # pep8-naming
"PERF", # perflint
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"PGH", # pygrep-hooks
"PL", # pylint
"UP", # pyupgrade
"RUF", # Ruff-specific rules
]
ignore = [
"ANN002", # missing type annotation for *{name}
"ANN003", # missing type annotation for **{name}
"ANN401", # dynamically typed expressions (typing.Any) are disallowed in {name}
"B008", # do not perform function calls in argument defaults
"COM812", # trailing comma missing
"RET504", # unnecessary assignment to {name} before return statement
"SIM105", # use contextlib.suppress({exception}) instead of try-except-pass
"C901", # too complex
"N818", # exception name {name} should be named with an Error suffix
"E501", # line too long
"W191", # indentation contains tabs
"RUF001", # string contains ambiguous
"RUF002", # docstring contains ambiguous
"RUF003", # comment contains ambiguous
"RUF012", # mutable class attributes should be annotated with typing.ClassVar
]
[lint.flake8-type-checking]
runtime-evaluated-base-classes = ["pydantic.BaseModel", "sqlalchemy.orm.DeclarativeBase"]
[lint.isort]
known-third-party = ["alembic"]
[lint.pylint]
max-args = 10
max-returns = 10
[lint.pyupgrade]
# Preserve types, even if a file imports `from __future__ import annotations`.
keep-runtime-typing = true
[format]
docstring-code-format = true
quote-style = "single"
@@ -0,0 +1,165 @@
import time
import pytest
from playwright.async_api import async_playwright
from common.base_page_test import BasePageTest
from common.config import Config
class ConfigManagementTest(BasePageTest):
"""参数设置测试类"""
def generate_config_data(self) -> dict:
"""生成测试数据"""
timestamp = int(time.time())
return {
'config_name': f'test_config_{timestamp}',
'config_key': f'test_config_key_{timestamp}',
'config_value': f'test_config_value_{timestamp}',
}
async def create_config(self, config_name: str, config_key: str, config_value: str) -> None:
"""创建参数配置"""
# 点击新增按钮
await self.page.get_by_role('button', name='新增').first.click()
# 等待对话框
dialog = self.page.get_by_role('dialog')
await dialog.wait_for()
# 填写参数信息
await dialog.get_by_role('textbox', name='参数名称').fill(config_name)
await dialog.get_by_role('textbox', name='参数键名').fill(config_key)
await dialog.get_by_role('textbox', name='参数键值').fill(config_value)
# 等待一段时间确保输入完成
await self.page.wait_for_timeout(500)
# 选择参数状态 - 选择系统内置选项为N(否),避免后端限制删除
# 在对话框中找到系统内置的单选按钮并选择
await self.wait_for_selector('input[type="radio"]', timeout=5000)
# 点击系统内置'否'选项
# 根据codegen脚本,使用更精确的定位器
await self.page.locator('label:nth-child(2) > .el-radio__input > .el-radio__inner').click()
# 等待一段时间确保选择完成
await self.page.wait_for_timeout(500)
# 点击确认按钮
confirm_button = self.page.get_by_role('button', name='确 定').first
await confirm_button.click()
# 等待一段时间确保请求发送
await self.page.wait_for_timeout(1000)
# 等待新增成功提示
await self.wait_for_selector('div:has-text("新增成功")', timeout=10000)
async def search_config(self, config_name: str, config_key: str = '') -> None:
"""搜索参数配置"""
# 在查询表单中输入参数信息进行查询
await self.page.get_by_role('textbox', name='参数名称').fill(config_name)
if config_key:
await self.page.get_by_role('textbox', name='参数键名').fill(config_key)
# 点击搜索按钮
await self.page.get_by_role('button', name='搜索').first.click()
# 等待搜索结果加载
await self.page.wait_for_timeout(1000)
async def edit_config(self, updated_value: str) -> None:
"""编辑参数配置"""
# 点击编辑按钮
await self.page.locator('tbody').get_by_role('button', name='修改').nth(0).click()
# 等待对话框
dialog = self.page.get_by_role('dialog')
await dialog.wait_for()
# 修改参数键值
await dialog.get_by_role('textbox', name='参数键值').fill(updated_value)
# 确认
await self.page.get_by_role('button', name='确 定').first.click()
# 等待编辑成功提示
await self.wait_for_selector('div:has-text("修改成功")', timeout=10000)
async def delete_config(self) -> None:
"""删除参数配置"""
# 点击删除按钮
await self.page.locator('tbody').get_by_role('button', name='删除').nth(0).click()
# 确认删除
await self.page.get_by_role('button', name='确定').first.click()
# 等待删除成功提示
await self.wait_for_selector('div:has-text("删除成功")', timeout=10000)
# 重置查询表单,确保能看到所有参数
reset_button = self.page.get_by_role('button', name='重置')
await reset_button.click()
# 等待列表刷新
await self.page.wait_for_timeout(1000)
async def test_config_crud_operations(self) -> None:
"""测试参数设置的增删查改功能"""
# 访问参数设置页面
await self.goto_page(Config.frontend_url + '/system/config')
# 等待页面加载完成
await self.wait_for_page_title('参数设置', timeout=10000)
await self.page.wait_for_timeout(1000) # 等待列表刷新
# 记录初始参数数量
initial_config_count = await self.get_table_total_rows()
# 生成测试数据
data = self.generate_config_data()
# 1.新增
await self.create_config(data['config_name'], data['config_key'], data['config_value'])
# 验证新增
await self.page.wait_for_timeout(1000) # 等待列表刷新
new_config_count = await self.get_table_total_rows()
assert new_config_count > initial_config_count, '新增参数后数量应该增加'
# 2.搜索
await self.search_config(data['config_name'], data['config_key'])
# 验证搜索
search_result_count = await self.get_table_total_rows()
assert search_result_count >= 1, '搜索结果应该至少包含一个参数'
# 3.编辑
await self.edit_config(f'updated_config_value_{time.time()}')
# 验证编辑
await self.page.wait_for_timeout(1000) # 等待列表刷新
# 重新搜索以验证修改结果
await self.search_config(data['config_name'])
# 4.删除
await self.delete_config()
# 验证删除
await self.page.wait_for_timeout(1000) # 等待列表刷新
final_config_count = await self.get_table_total_rows()
assert final_config_count == initial_config_count, '删除参数后数量应该恢复到初始值'
@pytest.mark.asyncio
async def test_config_management_page() -> None:
"""测试参数设置页面功能"""
async with async_playwright() as p:
test_instance = ConfigManagementTest()
await test_instance.setup(p)
try:
await test_instance.test_config_crud_operations()
finally:
await test_instance.teardown()
@@ -0,0 +1,135 @@
import time
import pytest
from playwright.async_api import async_playwright
from common.base_page_test import BasePageTest
from common.config import Config
class DeptManagementTest(BasePageTest):
"""部门管理页面测试类"""
def generate_dept_data(self) -> dict:
"""生成测试数据"""
timestamp = int(time.time())
return {
'dept_name': f'测试部门_{timestamp}',
'order_num': '3',
'leader_name': '年糕',
}
async def create_dept(self, dept_name: str, order_num: str) -> None:
"""创建部门"""
# 点击新增按钮
await self.page.get_by_role('button', name='新增').first.click()
# 等待对话框
dialog = self.page.get_by_role('dialog')
await dialog.wait_for()
# 上级部门
# 使用 label 定位父级 form-item,再点击内部的 wrapper
await dialog.locator('div.el-form-item').filter(has_text='上级部门').locator('.el-select__wrapper').click()
# 选择 "集团总公司" (根节点)
# 使用 .el-popper 定位下拉框中的内容
await self.page.locator('.el-popper:visible').get_by_text('集团总公司').click()
# 填写部门名称
await dialog.get_by_role('textbox', name='部门名称').fill(dept_name)
# 填写显示排序
# el-input-number 通常是一个 spinbutton 或者 input
await dialog.get_by_role('spinbutton', name='显示排序').fill(order_num)
# 点击确定
await self.page.get_by_role('button', name='确 定').click()
# 等待成功消息
await self.wait_for_selector("div:has-text('成功')", timeout=10000)
async def search_dept(self, dept_name: str) -> None:
"""搜索部门"""
search_form = self.page.locator('form').first
await search_form.get_by_role('textbox', name='部门名称').fill(dept_name)
# 点击搜索按钮
await self.page.get_by_role('button', name='搜索').first.click()
# 等待加载
await self.page.wait_for_timeout(1000)
async def edit_dept(self, leader_name: str) -> None:
"""编辑部门"""
# 点击修改按钮 (第一行)
# 树形表格结构可能不同,但通常也是在 tbody 的 row 中
await self.page.locator('tbody').get_by_role('button', name='修改').nth(0).click()
dialog = self.page.get_by_role('dialog')
await dialog.wait_for()
# 修改负责人
await dialog.get_by_role('textbox', name='负责人').fill(leader_name)
# 确定
await self.page.get_by_role('button', name='确 定').click()
# 等待成功提示
await self.wait_for_selector('div:has-text("修改成功")', timeout=10000)
async def delete_dept(self) -> None:
"""删除部门"""
# 点击删除按钮
await self.page.locator('tbody').get_by_role('button', name='删除').nth(0).click()
# 确认删除
await self.page.get_by_role('button', name='确定').click()
# 等待成功提示
await self.wait_for_selector('div:has-text("删除成功")', timeout=10000)
# 重置搜索
await self.page.get_by_role('button', name='重置').click()
await self.page.wait_for_timeout(1000)
async def test_dept_crud_operations(self) -> None:
"""测试部门管理增删查改"""
# 访问页面
await self.goto_page(Config.frontend_url + '/system/dept')
await self.wait_for_page_title('部门管理', timeout=10000)
# 生成测试数据
data = self.generate_dept_data()
# 1. 新增
await self.create_dept(data['dept_name'], data['order_num'])
# 验证新增
await self.search_dept(data['dept_name'])
# 树形表格,只要有行显示即可
rows = await self.page.query_selector_all('.el-table__row')
assert len(rows) >= 1, '新增后搜索应有结果'
# 2. 编辑
await self.edit_dept(data['leader_name'])
# 重新搜索以验证修改结果
await self.search_dept(data['dept_name'])
# 3. 删除
await self.delete_dept()
# 验证删除
await self.search_dept(data['dept_name'])
rows_after = await self.page.query_selector_all('.el-table__row')
assert len(rows_after) == 0, '删除后搜索应无结果'
@pytest.mark.asyncio
async def test_dept_management_page() -> None:
"""测试部门管理页面功能"""
async with async_playwright() as p:
test_instance = DeptManagementTest()
await test_instance.setup(p)
try:
await test_instance.test_dept_crud_operations()
finally:
await test_instance.teardown()
@@ -0,0 +1,201 @@
from datetime import datetime
import pytest
from playwright.async_api import async_playwright
from common.base_page_test import BasePageTest
from common.config import Config
class DictManagementTest(BasePageTest):
"""字典管理测试类"""
def generate_dict_data(self) -> dict:
"""生成测试数据"""
timestamp = datetime.now().strftime('%H%M%S')
return {
'dict_name': f'测试字典_{timestamp}',
'dict_type': f'test_dict_{timestamp}',
'dict_data_label_1': '正常',
'dict_data_value_1': '1',
'dict_data_label_2': '异常',
'dict_data_value_2': '2',
'remark': f'备注_{timestamp}',
}
async def create_dict_type(self, dict_name: str, dict_type: str) -> None:
"""创建字典类型"""
# 点击新增按钮
await self.page.get_by_role('button', name='新增').first.click()
# 等待对话框
dialog = self.page.get_by_role('dialog')
await dialog.wait_for()
# 填写表单
await dialog.get_by_role('textbox', name='字典名称').fill(dict_name)
await dialog.get_by_role('textbox', name='字典类型').fill(dict_type)
# 提交
await dialog.get_by_role('button', name='确 定').click()
# 验证成功
await self.wait_for_selector("div:has-text('新增成功')", timeout=10000)
async def search_dict_type(self, dict_name: str, dict_type: str) -> None:
"""搜索字典类型"""
form = self.page.locator('form').first
await form.get_by_role('textbox', name='字典名称').fill(dict_name)
await form.get_by_role('textbox', name='字典类型').fill(dict_type)
# 点击搜索按钮
await self.page.get_by_role('button', name='搜索').click()
await self.page.wait_for_timeout(1000)
async def edit_dict_type(self, dict_name: str, remark: str) -> None:
"""修改字典类型"""
await self.search_dict_type(dict_name, '')
# 点击修改按钮
row = self.page.locator('tbody tr').first
await row.get_by_role('button', name='修改').click()
dialog = self.page.get_by_role('dialog')
await dialog.wait_for()
await dialog.get_by_role('textbox', name='备注').fill(remark)
await dialog.get_by_role('button', name='确 定').click()
await self.wait_for_selector("div:has-text('修改成功')", timeout=10000)
async def manage_dict_data(self, dict_type: str, data: dict) -> None:
"""管理字典数据"""
# 1. 进入字典数据页面
# 点击字典类型链接
row = self.page.locator('tbody tr').first
await row.get_by_role('link', name=dict_type).click()
# 等待页面切换(Playwright在SPA中通常不需要处理多标签页,除非新开tab)
# 这里是 router-link,应该是页内跳转
# 检查是否到了字典数据页面
# 或者等待URL变化,或者等待特定元素
# data.vue 有 "关闭" 按钮
await self.wait_for_selector("button:has-text('关闭')", timeout=5000)
# 2. 新增数据1 (正常)
await self.create_dict_data(data['dict_data_label_1'], data['dict_data_value_1'], '1', '主要(primary)')
# 3. 新增数据2 (异常)
await self.create_dict_data(data['dict_data_label_2'], data['dict_data_value_2'], '2', '危险(danger)')
# 4. 修改数据
await self.edit_dict_data(data['dict_data_label_2'], '测试备注')
# 5. 删除数据
await self.delete_dict_data(data['dict_data_label_2'])
await self.delete_dict_data(data['dict_data_label_1'])
# 6. 关闭页面 (返回字典类型)
await self.page.get_by_role('button', name='关闭', exact=True).click()
# 等待返回
await self.wait_for_selector('text=字典名称') # 假设返回后能看到原来的页面元素
async def create_dict_data(self, label: str, value: str, sort: str, list_class: str) -> None:
"""创建字典数据"""
# 点击新增按钮
await self.page.get_by_role('button', name='新增').first.click()
dialog = self.page.get_by_role('dialog')
await dialog.wait_for()
await dialog.get_by_role('textbox', name='数据标签').fill(label)
await dialog.get_by_role('textbox', name='数据键值').fill(value)
await dialog.get_by_role('spinbutton', name='显示排序').fill(sort)
# 选择回显样式
# 点击下拉框触发器
# el-select 的结构通常是 wrapper -> input (readonly)
# 我们可以点击 label 为 "回显样式" 的 form-item 下的 select
select_trigger = dialog.locator("label:has-text('回显样式') + div .el-select")
if await select_trigger.count() == 0:
# 尝试直接找 .el-select,可能布局不同
select_trigger = dialog.locator('.el-select').filter(has_text='默认(default)').first
# 如果还是找不到,尝试点击那个默认值文本
if await select_trigger.count() == 0:
await dialog.get_by_text('默认(default)').click()
else:
await select_trigger.click()
# 选择选项 (选项在 body 的 el-popper 中)
# 等待选项出现
await self.page.get_by_role('option', name=list_class).click()
await dialog.get_by_role('button', name='确 定').click()
await self.wait_for_selector("div:has-text('新增成功')", timeout=10000)
async def edit_dict_data(self, label: str, remark: str) -> None:
"""修改字典数据"""
# 点击修改按钮
row = self.page.locator('tbody tr').filter(has_text=label).first
await row.get_by_role('button', name='修改').click()
dialog = self.page.get_by_role('dialog')
await dialog.wait_for()
await dialog.get_by_role('textbox', name='备注').fill(remark)
await dialog.get_by_role('button', name='确 定').click()
await self.wait_for_selector("div:has-text('修改成功')", timeout=10000)
async def delete_dict_data(self, label: str) -> None:
"""删除字典数据"""
# 点击删除按钮
row = self.page.locator('tbody tr').filter(has_text=label).first
await row.get_by_role('button', name='删除').click()
await self.page.get_by_role('button', name='确定').click()
await self.wait_for_selector("div:has-text('删除成功')", timeout=10000)
async def delete_dict_type(self, dict_name: str) -> None:
"""删除字典类型"""
await self.search_dict_type(dict_name, '')
# 点击删除按钮
row = self.page.locator('tbody tr').filter(has_text=dict_name).first
await row.get_by_role('button', name='删除').click()
await self.page.get_by_role('button', name='确定').click()
await self.wait_for_selector("div:has-text('删除成功')", timeout=10000)
async def test_dict_crud_operations(self) -> None:
"""测试字典管理全流程"""
data = self.generate_dict_data()
# 1. 进入字典管理
await self.goto_page(Config.frontend_url + '/system/dict')
# 等待页面加载
await self.wait_for_selector('text=字典名称') # 页面标题或其他标识
# 2. 创建字典类型
await self.create_dict_type(data['dict_name'], data['dict_type'])
# 3. 搜索并修改
await self.edit_dict_type(data['dict_name'], data['remark'])
# 4. 管理字典数据
await self.manage_dict_data(data['dict_type'], data)
# 5. 删除字典类型
await self.delete_dict_type(data['dict_name'])
@pytest.mark.asyncio
async def test_dict_management_page() -> None:
"""测试字典管理页面功能"""
async with async_playwright() as p:
test_instance = DictManagementTest()
await test_instance.setup(p)
try:
await test_instance.test_dict_crud_operations()
finally:
await test_instance.teardown()
@@ -0,0 +1,138 @@
import pytest
from playwright.async_api import async_playwright
from common.base_page_test import BasePageTest
from common.config import Config
class LogManagementTest(BasePageTest):
"""日志管理测试类"""
async def test_operlog_operations(self) -> None:
"""测试操作日志查询、删除和清空"""
# 1. 导航到操作日志页面
# 直接访问正确的路由
await self.page.goto(Config.frontend_url + '/system/log/operlog')
await self.page.wait_for_load_state('networkidle')
try:
await self.page.wait_for_selector('.app-container', timeout=10000)
except Exception:
# 如果首次加载失败,尝试刷新
await self.page.reload()
await self.page.wait_for_selector('.app-container', timeout=10000)
# 2. 查询操作
# 输入操作人员
await self.page.locator('div.el-form-item', has_text='操作人员').locator('input').fill('admin')
# 点击搜索按钮
await self.page.get_by_role('button', name='搜索').click()
await self.page.wait_for_timeout(1000) # 等待搜索结果
# 点击重置按钮
await self.page.get_by_role('button', name='重置').click()
await self.page.wait_for_timeout(1000) # 等待重置结果
# 3. 删除操作
# 检查是否有数据,如果有则进行删除测试
rows = self.page.locator('.el-table__body tr')
count = await rows.count()
if count > 0:
# 选中第一条
await rows.first.locator('.el-checkbox').click()
# 点击删除
await self.page.get_by_role('button', name='删除').click()
# 确认删除
await self.page.get_by_role('button', name='确定').click()
# 等待删除完成提示
try:
await self.page.wait_for_selector('text=删除成功', timeout=10000)
except Exception:
pass # 可能是 toast 提示,稍纵即逝
# 4. 清空操作
# 点击清空
await self.page.get_by_role('button', name='清空').click()
# 确认清空
await self.page.get_by_role('button', name='确定').click()
# 5. 验证清空后还有一条"清空"的操作日志
await self.page.wait_for_timeout(1000) # 等待清空操作完成并刷新
await self.page.get_by_role('button', name='搜索').click() # 刷新列表
await self.page.wait_for_timeout(1000)
# 验证列表不为空 (因为清空操作本身会产生一条日志)
rows_after_clean = self.page.locator('.el-table__body tr')
count_after_clean = await rows_after_clean.count()
assert count_after_clean > 0, '操作日志清空后应该至少有一条记录(清空操作本身)'
# 可以在这里进一步验证第一条记录的类型是否为清空,但题目只要求不是空列表
async def test_logininfor_operations(self) -> None:
"""测试登录日志查询、删除和清空"""
# 1. 导航到登录日志页面
await self.page.goto(Config.frontend_url + '/system/log/logininfor')
await self.page.wait_for_load_state('networkidle')
try:
await self.page.wait_for_selector('.app-container', timeout=10000)
except Exception:
# 如果失败,尝试刷新
await self.page.reload()
await self.page.wait_for_selector('.app-container', timeout=10000)
# 2. 查询操作
# 输入用户名称
await self.page.locator('div.el-form-item', has_text='用户名称').locator('input').fill('admin')
# 点击搜索
await self.page.get_by_role('button', name='搜索').click()
await self.page.wait_for_timeout(1000)
# 点击重置
await self.page.get_by_role('button', name='重置').click()
await self.page.wait_for_timeout(1000)
# 3. 删除操作
# 检查是否有数据
rows = self.page.locator('.el-table__body tr')
count = await rows.count()
if count > 0:
# 选中第一条
await rows.first.locator('.el-checkbox').click()
# 点击删除
await self.page.get_by_role('button', name='删除').click()
# 确认删除
await self.page.get_by_role('button', name='确定').click()
# 等待删除成功
try:
await self.page.wait_for_selector('text=删除成功', timeout=10000)
except Exception:
pass
# 4. 清空操作
# 点击清空
await self.page.get_by_role('button', name='清空').click()
# 确认清空
await self.page.get_by_role('button', name='确定').click()
# 5. 验证清空后列表为空
await self.page.wait_for_timeout(1000)
# 检查是否显示"暂无数据"
no_data = await self.page.get_by_text('暂无数据').is_visible()
if not no_data:
# 再次检查行数
count_final = await self.page.locator('.el-table__body tr').count()
assert count_final == 0, '登录日志清空后应该没有数据'
@pytest.mark.asyncio
async def test_log_management_page() -> None:
"""测试日志管理页面功能"""
async with async_playwright() as p:
test_instance = LogManagementTest()
await test_instance.setup(p)
try:
# 运行操作日志测试
await test_instance.test_operlog_operations()
# 运行登录日志测试
await test_instance.test_logininfor_operations()
finally:
await test_instance.teardown()
@@ -0,0 +1,145 @@
from datetime import datetime
import pytest
from playwright.async_api import async_playwright
from common.base_page_test import BasePageTest
from common.config import Config
class MenuManagementTest(BasePageTest):
"""菜单管理测试"""
def generate_menu_data(self) -> dict:
"""生成测试数据"""
timestamp = datetime.now().strftime('%H%M%S')
return {
'dir_name': f'测试目录_{timestamp}',
'menu_name': f'测试菜单_{timestamp}',
'path_dir': f'test_dir_{timestamp}',
'path_menu': f'test_menu_{timestamp}',
'path_menu_new': f'test_menu_new_{timestamp}',
'order_num': '10',
}
async def create_directory(self, menu_name: str, path: str, order_num: str) -> None:
"""创建目录"""
# 点击顶部新增按钮
await self.page.get_by_role('button', name='新增').first.click()
# 等待对话框出现
dialog = self.page.get_by_role('dialog')
await dialog.wait_for()
# 填写表单
await dialog.get_by_role('spinbutton', name='显示排序').fill(order_num)
await dialog.get_by_role('textbox', name='菜单名称').fill(menu_name)
await dialog.get_by_role('textbox', name='路由地址').fill(path)
# 提交
await dialog.get_by_role('button', name='确 定').click()
# 验证成功提示
await self.wait_for_selector("div:has-text('新增成功')", timeout=10000)
async def search_menu(self, menu_name: str) -> None:
"""搜索菜单"""
# 填写搜索条件
# 使用 first 匹配搜索表单(通常在顶部),避免匹配到隐藏对话框中的同名字段
await self.page.locator('form').first.get_by_role('textbox', name='菜单名称').fill(menu_name)
await self.page.get_by_role('button', name='搜索').click()
# 等待表格刷新(简单等待)
await self.page.wait_for_timeout(1000)
async def create_sub_menu(self, parent_name: str, menu_name: str, path: str, order_num: str) -> None:
"""在指定目录下创建子菜单"""
# 找到父级菜单行
row = self.page.locator('tbody tr').filter(has_text=parent_name).first
# 点击行内新增按钮
await row.get_by_role('button', name='新增').click()
# 等待对话框
dialog = self.page.get_by_role('dialog')
await dialog.wait_for()
# 选择菜单类型为"菜单"
await dialog.locator('label').filter(has_text='菜单').first.click()
# 填写表单
await dialog.get_by_role('spinbutton', name='显示排序').fill(order_num)
await dialog.get_by_role('textbox', name='菜单名称').fill(menu_name)
await dialog.get_by_role('textbox', name='路由地址').fill(path)
# 提交
await dialog.get_by_role('button', name='确 定').click()
# 验证成功
await self.wait_for_selector("div:has-text('新增成功')", timeout=10000)
async def edit_sub_menu(self, menu_name: str, new_path: str) -> None:
"""修改子菜单"""
# 搜索子菜单
await self.search_menu(menu_name)
row = self.page.locator('tbody tr').filter(has_text=menu_name).first
await row.get_by_role('button', name='修改').click()
await self.wait_for_selector('.el-dialog', timeout=5000)
dialog = self.page.locator('.el-dialog')
await dialog.get_by_role('textbox', name='路由地址').fill(new_path)
await self.page.get_by_role('button', name='确 定').click()
await self.wait_for_selector("div:has-text('修改成功')", timeout=10000)
async def delete_menus(self, menu_names: list[str]) -> None:
"""删除菜单(列表)"""
for name in menu_names:
await self.search_menu(name)
row = self.page.locator('tbody tr').filter(has_text=name).first
# 点击删除
await row.get_by_role('button', name='删除').click()
# 确认删除
await self.page.get_by_role('button', name='确定').click()
await self.wait_for_selector("div:has-text('删除成功')", timeout=10000)
async def test_menu_crud_operations(self) -> None:
"""测试菜单增删改查流程"""
data = self.generate_menu_data()
# 1. 进入菜单管理页面
await self.goto_page(Config.frontend_url + '/system/menu')
await self.wait_for_page_title('菜单管理', timeout=10000)
# 2. 新增目录
await self.create_directory(data['dir_name'], data['path_dir'], data['order_num'])
# 3. 搜索目录并新增子菜单
await self.search_menu(data['dir_name'])
await self.create_sub_menu(data['dir_name'], data['menu_name'], data['path_menu'], '1')
# 4. 修改子菜单
await self.edit_sub_menu(data['menu_name'], data['path_menu_new'])
# 5. 删除子菜单
await self.delete_menus([data['menu_name']])
# 6. 删除目录
await self.delete_menus([data['dir_name']])
@pytest.mark.asyncio
async def test_menu_management_page() -> None:
"""测试菜单管理页面功能"""
async with async_playwright() as p:
test_instance = MenuManagementTest()
await test_instance.setup(p)
try:
await test_instance.test_menu_crud_operations()
finally:
await test_instance.teardown()
@@ -0,0 +1,165 @@
import re
import time
import pytest
from playwright.async_api import async_playwright
from common.base_page_test import BasePageTest
from common.config import Config
class NoticeManagementTest(BasePageTest):
"""通知公告管理测试类"""
def generate_notice_data(self) -> dict:
"""生成测试数据"""
timestamp = int(time.time())
return {
'notice_title': f'test_notice_{timestamp}',
'updated_title': f'updated_notice_{timestamp}',
}
async def create_notice(self, notice_title: str, notice_type: int = 1, notice_content: str = '测试内容') -> None:
"""创建通知公告"""
# 点击新增按钮
await self.page.get_by_role('button', name='新增').first.click()
# 等待对话框
dialog = self.page.get_by_role('dialog')
await dialog.wait_for()
# 填写公告信息
await dialog.get_by_role('textbox', name='公告标题').fill(notice_title)
# 选择公告类型
# 根据codegen脚本,点击下拉框触发器
await self.page.locator('div').filter(has_text=re.compile(r'^请选择$')).nth(4).click()
# 选择对应类型选项,1为通知,2为公告
if notice_type == 1:
await self.page.get_by_role('option', name='通知').click()
else:
await self.page.get_by_role('option', name='公告').click()
# 填写公告内容
await self.page.locator('.ql-container').click()
await self.page.locator('.ql-editor').fill(notice_content)
# 等待一段时间确保输入完成
await self.page.wait_for_timeout(500)
# 点击确认按钮
await self.page.get_by_role('button', name='确 定').first.click()
# 等待一段时间确保请求发送
await self.page.wait_for_timeout(1000)
# 等待新增成功提示
await self.wait_for_selector('div:has-text("新增成功")', timeout=10000)
async def search_notice(self, notice_title: str) -> None:
"""搜索通知公告"""
# 在查询表单中输入公告标题进行查询
await self.page.get_by_role('textbox', name='公告标题').fill(notice_title)
# 点击搜索按钮
await self.page.get_by_role('button', name='搜索').first.click()
# 等待搜索结果加载
await self.page.wait_for_timeout(1000)
async def edit_notice(self, updated_title: str, updated_content: str = '更新的测试内容') -> None:
"""编辑通知公告"""
# 点击编辑按钮
await self.page.locator('tbody').get_by_role('button', name='修改').nth(0).click()
# 等待对话框
dialog = self.page.get_by_role('dialog')
await dialog.wait_for()
# 修改公告标题
await dialog.get_by_role('textbox', name='公告标题').fill(updated_title)
# 修改公告内容
await self.page.locator('.ql-editor').fill(updated_content)
# 点击确认按钮
await self.page.get_by_role('button', name='确 定').first.click()
# 等待编辑成功提示
await self.wait_for_selector('div:has-text("修改成功")', timeout=10000)
async def delete_notice(self) -> None:
"""删除通知公告"""
# 点击删除按钮
await self.page.locator('tbody').get_by_role('button', name='删除').nth(0).click()
# 确认删除
await self.page.get_by_role('button', name='确定').first.click()
# 等待删除成功提示
await self.wait_for_selector('div:has-text("删除成功")', timeout=10000)
# 重置查询表单,确保能看到所有公告
await self.page.get_by_role('button', name='重置').click()
# 等待列表刷新
await self.page.wait_for_timeout(1000)
async def test_notice_crud_operations(self) -> None:
"""测试通知公告的增删查改功能"""
# 访问通知公告页面
await self.goto_page(Config.frontend_url + '/system/notice')
# 等待页面加载完成
await self.wait_for_page_title('通知公告', timeout=10000)
await self.page.wait_for_timeout(1000) # 等待列表刷新
# 记录初始公告数量
initial_notice_count = await self.get_table_total_rows()
# 生成测试数据
data = self.generate_notice_data()
# 创建公告
await self.create_notice(data['notice_title'])
# 验证公告已添加到列表中
await self.page.wait_for_timeout(1000) # 等待列表刷新
new_notice_count = await self.get_table_total_rows()
assert new_notice_count > initial_notice_count, '新增公告后数量应该增加'
# 搜索公告
await self.search_notice(data['notice_title'])
# 验证搜索结果中包含新增的公告
search_result_count = await self.get_table_total_rows()
assert search_result_count >= 1, '搜索结果应该至少包含一个公告'
# 编辑公告
await self.edit_notice(data['updated_title'])
# 验证编辑成功
await self.page.wait_for_timeout(1000) # 等待列表刷新
# 重新搜索以验证修改结果
await self.search_notice(data['updated_title'])
# 删除公告
await self.delete_notice()
# 验证公告已从列表中删除
await self.page.wait_for_timeout(1000) # 等待列表刷新
final_notice_count = await self.get_table_total_rows()
assert final_notice_count == initial_notice_count, '删除公告后数量应该恢复到初始值'
@pytest.mark.asyncio
async def test_notice_management_page() -> None:
"""测试通知公告页面功能"""
async with async_playwright() as p:
test_instance = NoticeManagementTest()
await test_instance.setup(p)
try:
await test_instance.test_notice_crud_operations()
finally:
await test_instance.teardown()
@@ -0,0 +1,136 @@
from datetime import datetime
import pytest
from playwright.async_api import async_playwright
from common.base_page_test import BasePageTest
from common.config import Config
class PostManagementTest(BasePageTest):
"""岗位管理测试类"""
def generate_post_data(self) -> dict:
"""生成测试数据"""
timestamp = datetime.now().strftime('%H%M%S')
return {
'post_name': f'测试岗位_{timestamp}',
'post_code': f'test_{timestamp}',
'post_sort': '10',
'remark': f'测试备注_{timestamp}',
}
async def test_post_crud_operations(self) -> None:
"""测试岗位增删改查流程"""
# 1. 导航到岗位管理页面
await self.navigate_to_post_management()
# 生成测试数据
data = self.generate_post_data()
# 2. 新增岗位
await self.create_post(data['post_name'], data['post_code'], data['post_sort'])
# 3. 搜索岗位
await self.search_post(data['post_name'], data['post_code'])
# 4. 修改岗位
await self.edit_post(data['post_name'], data['remark'])
# 5. 删除岗位
await self.delete_post(data['post_name'])
async def navigate_to_post_management(self) -> None:
"""导航到岗位管理"""
# 直接导航到岗位管理页面
await self.goto_page(Config.frontend_url + '/system/post')
# 等待页面加载
await self.wait_for_selector('.app-container')
async def create_post(self, post_name: str, post_code: str, post_sort: str) -> None:
"""新增岗位"""
await self.page.get_by_role('button', name='新增').click()
dialog = self.page.get_by_role('dialog')
await dialog.wait_for()
# 填写表单
# 注意:Element Plus 表单必填项可能有 * 号,Label 匹配可能需要注意
await dialog.get_by_role('textbox', name='岗位名称').fill(post_name)
await dialog.get_by_role('textbox', name='岗位编码').fill(post_code)
# 岗位顺序是 input-number
# script.py 使用 spinbutton
await dialog.get_by_role('spinbutton', name='岗位顺序').fill(post_sort)
await dialog.get_by_role('button', name='确 定').click()
# 等待成功提示
await self.wait_for_selector("div:has-text('新增成功')", timeout=10000)
async def search_post(self, post_name: str, post_code: str) -> None:
"""搜索岗位"""
# 填写搜索表单
# 限定在搜索区域,防止定位到弹窗或其他地方(虽然此时没有弹窗)
search_form = self.page.locator('.el-form').first
await search_form.get_by_role('textbox', name='岗位编码').fill(post_code)
await search_form.get_by_role('textbox', name='岗位名称').fill(post_name)
await self.page.get_by_role('button', name='搜索').click()
# 等待表格加载结果
# 验证表格中包含刚创建的岗位
await self.wait_for_selector(f"tbody tr:has-text('{post_name}')", timeout=10000)
async def edit_post(self, post_name: str, remark: str) -> None:
"""修改岗位"""
# 确保当前显示的是我们要修改的岗位
await self.search_post(post_name, '')
row = self.page.locator('tbody tr').first
# 点击修改按钮
await row.get_by_role('button', name='修改').click()
dialog = self.page.get_by_role('dialog')
await dialog.wait_for()
# 修改备注
await dialog.get_by_role('textbox', name='备注').fill(remark)
await dialog.get_by_role('button', name='确 定').click()
# 等待成功提示
await self.wait_for_selector("div:has-text('修改成功')", timeout=10000)
async def delete_post(self, post_name: str) -> None:
"""删除岗位"""
# 确保当前显示的是我们要删除的岗位
search_form = self.page.locator('.el-form').first
await search_form.get_by_role('textbox', name='岗位编码').clear()
await search_form.get_by_role('textbox', name='岗位名称').fill(post_name)
await self.page.get_by_role('button', name='搜索').click()
await self.wait_for_selector(f"tbody tr:has-text('{post_name}')", timeout=10000)
row = self.page.locator('tbody tr').first
# 点击删除按钮
await row.get_by_role('button', name='删除').click()
# 确认删除
await self.page.get_by_role('button', name='确定').click()
# 等待成功提示
await self.wait_for_selector("div:has-text('删除成功')", timeout=10000)
@pytest.mark.asyncio
async def test_post_management_page() -> None:
"""测试岗位管理页面功能"""
async with async_playwright() as p:
test_instance = PostManagementTest()
await test_instance.setup(p)
try:
await test_instance.test_post_crud_operations()
finally:
await test_instance.teardown()
@@ -0,0 +1,148 @@
import time
import pytest
from playwright.async_api import async_playwright
from common.base_page_test import BasePageTest
from common.config import Config
class RoleManagementTest(BasePageTest):
"""角色管理测试类"""
def generate_role_data(self) -> dict:
"""生成测试数据"""
timestamp = int(time.time())
return {
'role_name': f'test_role_{timestamp}',
'role_key': f'test_{timestamp}',
}
async def create_role(self, role_name: str, role_key: str, role_sort: int = 3) -> None:
"""创建角色"""
# 点击新增按钮
await self.page.get_by_role('button', name='新增').click()
# 等待对话框
dialog = self.page.get_by_role('dialog')
await dialog.wait_for()
# 填写信息
await dialog.get_by_role('textbox', name='角色名称').fill(role_name)
await dialog.get_by_role('textbox', name='权限字符').fill(role_key)
await dialog.get_by_role('spinbutton', name='角色顺序').fill(str(role_sort))
# 选择菜单权限 (点击第一个复选框)
await self.page.locator('.el-tree-node__content .el-checkbox').first.click()
# 确定
await self.page.get_by_role('button', name='确 定').click()
# 等待成功提示
await self.wait_for_selector('div:has-text("新增成功")', timeout=10000)
async def search_role(self, role_name: str, role_key: str) -> None:
"""搜索角色"""
search_form = self.page.locator('form').first
await search_form.get_by_placeholder('请输入角色名称').fill(role_name)
await search_form.get_by_placeholder('请输入权限字符').fill(role_key)
await search_form.get_by_role('button', name='搜索').click()
# 等待加载
await self.page.wait_for_timeout(1000)
async def edit_role(self, new_remark: str) -> None:
"""编辑角色"""
# 点击修改按钮 (第一行)
# 按钮没有文字,只有图标,所以使用 nth(0)
row = self.page.locator('tbody tr').first
await row.get_by_role('button').nth(0).click()
dialog = self.page.get_by_role('dialog')
await dialog.wait_for()
# 修改备注
await dialog.get_by_role('textbox', name='备注').fill(new_remark)
# 确定
await self.page.get_by_role('button', name='确 定').click()
# 等待成功提示
await self.wait_for_selector('div:has-text("修改成功")', timeout=10000)
async def change_role_status(self) -> None:
"""修改角色状态"""
# 点击开关 (第一行)
# 使用 .el-switch__core 或者 role=switch
# script.py uses .el-switch__action, let's try .el-switch
switch = self.page.locator('.el-switch').first
await switch.click()
# 确认对话框
await self.page.get_by_role('button', name='确定').click()
await self.wait_for_selector('div:has-text("成功")', timeout=5000)
# 再次点击恢复
await self.page.wait_for_timeout(1000)
await switch.click()
await self.page.get_by_role('button', name='确定').click()
await self.wait_for_selector('div:has-text("成功")', timeout=5000)
async def delete_role(self) -> None:
"""删除角色"""
# 点击删除按钮 (第一行第二个按钮)
row = self.page.locator('tbody tr').first
await row.get_by_role('button').nth(1).click()
# 确认删除
await self.page.get_by_role('button', name='确定').click()
# 等待成功提示
await self.wait_for_selector('div:has-text("删除成功")', timeout=10000)
# 重置搜索
await self.page.get_by_role('button', name='重置').click()
await self.page.wait_for_timeout(1000)
async def test_role_crud_operations(self) -> None:
"""测试角色管理CRUD"""
# 访问页面
await self.goto_page(Config.frontend_url + '/system/role')
await self.wait_for_page_title('角色管理', timeout=10000)
# 生成测试数据
data = self.generate_role_data()
# 1. 新增
await self.create_role(data['role_name'], data['role_key'])
# 验证新增结果
await self.search_role(data['role_name'], data['role_key'])
rows = await self.get_table_total_rows()
assert rows >= 1, '新增后搜索应有结果'
# 2. 修改状态
await self.change_role_status()
# 3. 编辑
await self.edit_role('Updated remark')
# 4. 删除
await self.delete_role()
# 验证删除结果
await self.search_role(data['role_name'], data['role_key'])
rows_after = await self.get_table_total_rows()
assert rows_after == 0, '删除后搜索应无结果'
@pytest.mark.asyncio
async def test_role_management_page() -> None:
"""测试角色管理页面功能"""
async with async_playwright() as p:
test_instance = RoleManagementTest()
await test_instance.setup(p)
try:
await test_instance.test_role_crud_operations()
finally:
await test_instance.teardown()
@@ -0,0 +1,170 @@
import time
import pytest
from playwright.async_api import async_playwright
from common.base_page_test import BasePageTest
from common.config import Config
class UserManagementTest(BasePageTest):
"""用户管理测试类"""
def generate_user_data(self) -> dict:
"""生成测试数据"""
timestamp = int(time.time())
return {
'user_name': f'test_{timestamp}',
'nick_name': f'测试用户_{timestamp}',
'phone': '13888888888',
}
async def create_user(self, user_name: str, nick_name: str, phone: str) -> None:
"""创建用户"""
# 点击新增按钮
await self.page.get_by_role('button', name='新增').click()
# 等待对话框
dialog = self.page.get_by_role('dialog')
await dialog.wait_for()
# 填写基本信息
await dialog.get_by_role('textbox', name='用户昵称').fill(nick_name)
# 归属部门
# 使用 label 定位父级 form-item,再点击内部的 wrapper
await dialog.locator('div.el-form-item').filter(has_text='归属部门').locator('.el-select__wrapper').click()
# 选择 "集团总公司" (根节点)
# 使用 .el-popper 定位下拉框中的内容
await self.page.locator('.el-popper:visible').get_by_text('集团总公司').click()
await dialog.get_by_role('textbox', name='用户名称').fill(user_name)
# 手机号码在搜索框也有,所以必须限定在 dialog 内
# 注意:有些 label 可能不带 *,或者带 * 但 get_by_role 需要准确匹配
# 这里使用 get_by_placeholder 或者 filter 来定位更稳妥,或者直接 scope 到 dialog
await dialog.locator('div.el-form-item').filter(has_text='手机号码').get_by_role('textbox').fill(phone)
await dialog.locator('div.el-form-item').filter(has_text='邮箱').get_by_role('textbox').fill('test@example.com')
await dialog.locator('div.el-form-item').filter(has_text='用户密码').get_by_role('textbox').fill('123456')
# 用户性别
# 定位 label 为 "用户性别" 的父级 form-item,然后找里面的 "请选择" 文本
await dialog.locator('div.el-form-item').filter(has_text='用户性别').get_by_text('请选择').click()
await self.page.get_by_role('option', name='男').click()
# 岗位
await dialog.locator('div.el-form-item').filter(has_text='岗位').get_by_text('请选择').click()
await self.page.get_by_role('option', name='董事长').click()
# 角色
await dialog.locator('div.el-form-item').filter(has_text='角色').get_by_text('请选择').click()
await self.page.get_by_role('option', name='普通角色').click()
await dialog.locator('div.el-form-item').filter(has_text='备注').get_by_role('textbox').fill('测试用户')
# 点击确定
await self.page.get_by_role('button', name='确 定').click()
# 等待成功消息
await self.wait_for_selector("div:has-text('成功')", timeout=10000)
async def search_user(self, user_name: str) -> None:
"""搜索用户"""
search_form = self.page.locator('form').first
await search_form.get_by_role('textbox', name='用户名称').fill(user_name)
await search_form.get_by_role('button', name='搜索').click()
# 等待加载
await self.page.wait_for_timeout(1000)
async def edit_user(self) -> None:
"""编辑用户"""
# 点击修改按钮 (第一行)
row = self.page.locator('tbody tr').first
await row.get_by_role('button').nth(0).click()
dialog = self.page.get_by_role('dialog')
await dialog.wait_for()
# 修改性别为女
await dialog.locator('div.el-form-item').filter(has_text='用户性别').locator('.el-select').click()
await self.page.get_by_role('option', name='女').click()
# 确定
await self.page.get_by_role('button', name='确 定').click()
# 等待成功提示
await self.wait_for_selector('div:has-text("修改成功")', timeout=10000)
async def change_user_status(self) -> None:
"""修改用户状态"""
# 点击开关 (第一行)
switch = self.page.locator('.el-switch').first
await switch.click()
# 确认对话框
await self.page.get_by_role('button', name='确定').click()
await self.wait_for_selector('div:has-text("成功")', timeout=5000)
# 再次点击恢复
await self.page.wait_for_timeout(1000)
await switch.click()
await self.page.get_by_role('button', name='确定').click()
await self.wait_for_selector('div:has-text("成功")', timeout=5000)
async def delete_user(self) -> None:
"""删除用户"""
# 点击删除按钮 (第一行第二个按钮)
row = self.page.locator('tbody tr').first
await row.get_by_role('button').nth(1).click()
# 确认删除
await self.page.get_by_role('button', name='确定').click()
# 等待成功提示
await self.wait_for_selector('div:has-text("删除成功")', timeout=10000)
# 重置搜索
await self.page.get_by_role('button', name='重置').click()
await self.page.wait_for_timeout(1000)
async def test_user_crud_operations(self) -> None:
"""测试用户管理增删查改"""
# 访问页面
await self.goto_page(Config.frontend_url + '/system/user')
await self.wait_for_page_title('用户管理', timeout=10000)
# 生成测试数据
data = self.generate_user_data()
# 1. 新增
await self.create_user(data['user_name'], data['nick_name'], data['phone'])
# 验证新增结果
await self.search_user(data['user_name'])
rows = await self.get_table_total_rows()
assert rows >= 1, '新增后搜索应有结果'
# 2. 修改状态
await self.change_user_status()
# 3. 编辑
await self.edit_user()
# 4. 删除
await self.delete_user()
# 验证删除结果
await self.search_user(data['user_name'])
rows_after = await self.get_table_total_rows()
assert rows_after == 0, '删除后搜索应无结果'
@pytest.mark.asyncio
async def test_user_management_page() -> None:
"""测试用户管理页面功能"""
async with async_playwright() as p:
test_instance = UserManagementTest()
await test_instance.setup(p)
try:
await test_instance.test_user_crud_operations()
finally:
await test_instance.teardown()
+202
View File
@@ -0,0 +1,202 @@
import pytest
from playwright.async_api import async_playwright
from common.config import Config
from common.login_helper import LoginHelper
@pytest.mark.asyncio
async def test_login_page_loads() -> None:
"""测试登录页面是否能正常加载"""
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
page = await browser.new_page()
# 访问登录页面
await page.goto(Config.frontend_url + '/login')
# 检查页面标题或特定元素是否存在
title = await page.title()
assert 'vfadmin' in title
# 检查登录表单元素是否存在
username_input = await page.query_selector('input[placeholder="账号"]')
password_input = await page.query_selector('input[placeholder="密码"]')
assert username_input is not None
assert password_input is not None
await browser.close()
@pytest.mark.asyncio
async def test_captcha_generation() -> None:
"""测试验证码是否正常生成(在测试环境中,验证码已禁用,但仍应能访问登录页面)"""
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
page = await browser.new_page()
# 访问登录页面
await page.goto(Config.frontend_url + '/login')
# 在测试环境中验证码已禁用,但仍应能访问登录页面
title = await page.title()
assert 'vfadmin' in title
await browser.close()
def test_login_without_captcha() -> None:
"""测试在禁用验证码的情况下登录流程"""
helper = LoginHelper()
# 尝试登录(测试环境中验证码已禁用)
token = helper.login(username='admin', password='admin123')
# 验证登录结果
assert token is not None, '登录应该成功'
assert len(token) > 0, '应该返回有效的token'
@pytest.mark.asyncio
async def test_login_flow_with_playwright() -> None:
"""使用Playwright测试完整的登录流程(测试环境中验证码已禁用)"""
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
page = await browser.new_page()
# 访问登录页面
await page.goto(Config.frontend_url + '/login')
# 等待页面加载
await page.wait_for_selector('input[placeholder="账号"]')
# 输入用户名和密码
await page.fill('input[placeholder="账号"]', 'admin')
await page.fill('input[placeholder="密码"]', 'admin123')
# 在测试环境中验证码已禁用,所以不需要处理验证码
# 直接点击登录按钮
# 尝试多种可能的选择器
try:
await page.click('button:has-text("登录")')
except Exception:
try:
await page.click('button[type="submit"]')
except Exception:
await page.click('button.el-button')
# 等待页面跳转或检查登录结果
try:
# 等待跳转到主页或检查登录成功消息
await page.wait_for_url('**/index**', timeout=10000)
success = True
except Exception:
# 检查是否有错误消息
error_message = await page.query_selector('.el-message')
if error_message:
error_text = await error_message.text_content()
print(f'登录失败: {error_text}')
success = False
else:
success = False
assert success, '登录应该成功'
await browser.close()
@pytest.mark.asyncio
async def test_protected_routes_require_auth() -> None:
"""测试受保护的路由需要认证"""
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
page = await browser.new_page()
# 直接访问受保护的页面(如用户管理)
await page.goto(Config.frontend_url + '/system/user')
# 检查是否被重定向到登录页面
current_url = page.url
# 检查页面是否包含登录相关的元素(如账号密码输入框)
try:
has_username_input = await page.is_visible('input[placeholder*="账号" i], input[placeholder*="username" i]')
except Exception:
has_username_input = False
try:
has_password_input = await page.is_visible('input[placeholder*="密码" i], input[placeholder*="password" i]')
except Exception:
has_password_input = False
try:
has_login_button = await page.is_visible('button:has-text("登录"), button:has-text("Login"), .login-btn')
except Exception:
has_login_button = False
# 检查URL是否包含login路径
is_redirected_to_login = '/login' in current_url.lower() or '/#/login' in current_url
# 断言:应该重定向到登录页面或页面包含登录相关元素
assert is_redirected_to_login or has_username_input or has_password_input or has_login_button, (
f'未登录用户访问受保护页面时,没有重定向到登录页。当前URL: {current_url}, 页面内容: {await page.content()}'
)
await browser.close()
@pytest.mark.asyncio
async def test_authenticated_access() -> None:
"""测试认证后的访问"""
# 首先通过API登录获取token
helper = LoginHelper()
token = helper.login(username='admin', password='admin123')
assert token is not None, '登录应该成功'
# 使用Playwright测试带认证的访问
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
context = await browser.new_context(
storage_state={ # 使用storage state来保持登录状态
'cookies': [
{
'name': 'Admin-Token',
'value': token,
'domain': 'localhost',
'path': '/',
'httpOnly': False,
'secure': False,
'sameSite': 'Lax',
}
],
'origins': [],
}
)
page = await context.new_page()
# 访问受保护的页面
await page.goto(Config.frontend_url + '/system/user')
# 检查是否能够访问受保护的页面(而不是被重定向到登录页)
current_url = page.url
assert '/login' not in current_url, '已登录用户应该能够访问受保护页面'
# 检查页面是否包含用户管理相关的元素
try:
# 等待页面加载
await page.wait_for_selector('div:has-text("用户管理")', timeout=10000)
has_user_management = True
except Exception:
has_user_management = False
# 或者检查是否有表格或其他用户管理组件
table_element = await page.query_selector('el-table')
if not has_user_management and table_element:
has_user_management = True
assert has_user_management, '应该能够访问用户管理页面'
await context.close()
await browser.close()
+119
View File
@@ -0,0 +1,119 @@
import pytest
from playwright.async_api import async_playwright
from common.config import Config
from common.login_helper import LoginHelper
@pytest.mark.asyncio
async def test_dashboard_page() -> None:
"""测试仪表盘页面"""
# 首先登录获取token
helper = LoginHelper()
token = helper.login(username='admin', password='admin123')
assert token is not None, '登录应该成功'
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
context = await browser.new_context()
# 设置认证token
await context.add_cookies(
[
{
'name': 'Admin-Token',
'value': token,
'domain': 'localhost',
'path': '/',
'httpOnly': False,
'secure': False,
}
]
)
page = await context.new_page()
# 访问仪表盘页面
await page.goto(Config.frontend_url + '/index')
# 检查页面是否包含仪表盘相关元素
await page.wait_for_selector('div:has-text("首页")', timeout=10000)
title = await page.inner_text('div:has-text("首页")')
assert '首页' in title
await context.close()
await browser.close()
@pytest.mark.asyncio
async def test_druid_page() -> None:
"""测试数据监控页面"""
# 首先登录获取token
helper = LoginHelper()
token = helper.login(username='admin', password='admin123')
assert token is not None, '登录应该成功'
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
context = await browser.new_context()
# 设置认证token
await context.add_cookies(
[
{
'name': 'Admin-Token',
'value': token,
'domain': 'localhost',
'path': '/',
'httpOnly': False,
'secure': False,
}
]
)
page = await context.new_page()
# 访问数据库监控页面
await page.goto(Config.frontend_url + '/monitor/druid')
# 检查页面是否包含缓存监控相关元素
await page.wait_for_selector('div:has-text("数据监控")', timeout=10000)
title = await page.inner_text('div:has-text("数据监控")')
assert '数据监控' in title
await context.close()
await browser.close()
@pytest.mark.asyncio
async def test_build_page() -> None:
"""测试表单构建页面"""
# 首先登录获取token
helper = LoginHelper()
token = helper.login(username='admin', password='admin123')
assert token is not None, '登录应该成功'
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
context = await browser.new_context()
# 设置认证token
await context.add_cookies(
[
{
'name': 'Admin-Token',
'value': token,
'domain': 'localhost',
'path': '/',
'httpOnly': False,
'secure': False,
}
]
)
page = await context.new_page()
# 访问数据库监控页面
await page.goto(Config.frontend_url + '/tool/build')
# 检查页面是否包含缓存监控相关元素
await page.wait_for_selector('div:has-text("Form Generator")', timeout=10000)
title = await page.inner_text('div:has-text("Form Generator")')
assert '表单构建' in title
await context.close()
await browser.close()
@@ -0,0 +1,228 @@
import re
import pytest
from playwright.async_api import async_playwright, expect
from common.base_page_test import BasePageTest
from common.config import Config
class GenTableTest(BasePageTest):
"""代码生成业务测试"""
async def test_gen_code_flow(self) -> None:
"""测试代码生成全流程:导入 -> 编辑 -> 预览 -> 删除"""
table_name = 'sys_post'
# 1. 导航到代码生成页面
await self.navigate_to_gen_page()
# 确保环境清理 (如果存在 sys_post 则先删除)
await self.ensure_table_not_exists(table_name)
# 2. 导入表
await self.import_table(table_name)
# 3. 编辑表
await self.edit_table(table_name, '测试生成')
# 4. 预览代码
await self.preview_code(table_name)
await self.page.wait_for_timeout(1000)
# 5. 删除表 (清理环境)
await self.delete_table(table_name)
async def navigate_to_gen_page(self) -> None:
"""导航到代码生成页面"""
await self.goto_page(Config.frontend_url + '/tool/gen')
await self.wait_for_selector('.app-container')
async def ensure_table_not_exists(self, table_name: str) -> None:
"""确保表不存在 (如果存在则删除)"""
await self.delete_table(table_name, verify=False)
async def search_table(self, table_name: str) -> None:
"""搜索表"""
# 确保在主搜索表单中搜索
search_form = self.page.locator('.el-form').first
await search_form.get_by_role('textbox', name='表名称').fill(table_name)
await search_form.get_by_role('button', name='搜索').click()
# 等待加载
loading = self.page.locator('.el-loading-mask')
if await loading.count() > 0:
await expect(loading.first).to_be_hidden(timeout=10000)
await self.page.wait_for_timeout(300)
async def import_table(self, table_name: str) -> None:
"""导入表"""
await self.page.get_by_role('button', name='导入').click()
dialog = self.page.locator("div[role='dialog'][aria-label='导入表']")
await dialog.wait_for()
# 搜索要导入的表
await dialog.get_by_placeholder('请输入表名称').fill(table_name)
await dialog.get_by_role('button', name='搜索').click()
# 等待搜索结果
await dialog.locator(f"tr:has-text('{table_name}')").wait_for()
# 选中行
row = dialog.locator('tr').filter(has=self.page.get_by_text(table_name, exact=True))
# 点击复选框
checkbox = row.locator('.el-checkbox')
await checkbox.click()
# 验证已选中
# Element Plus checkbox 选中时,最外层 label.el-checkbox 会有 is-checked 类
await expect(checkbox).to_have_class(re.compile(r'is-checked'))
await self.page.wait_for_timeout(500)
await dialog.get_by_role('button', name='确 定').click()
# 检查是否有"请选择要导入的表"错误
try:
await expect(self.page.get_by_text('请选择要导入的表')).to_be_visible(timeout=2000)
print("ERROR: Selection failed, '请选择要导入的表' appeared.")
except AssertionError:
pass
# 等待一会,让弹窗自动关闭
await self.page.wait_for_timeout(5000)
# 如果弹窗还在,尝试关闭它以免阻塞后续操作
if await dialog.is_visible():
print('WARNING: Import dialog still visible after timeout. Forcing close.')
# Check for error messages
if await self.page.locator('.el-message--error').count() > 0:
msg = await self.page.locator('.el-message--error').all_inner_texts()
print(f'ERROR MESSAGE: {msg}')
# 点击取消关闭弹窗
await dialog.get_by_role('button', name='取 消').click()
await expect(dialog).to_be_hidden()
# 手动刷新列表
await self.page.get_by_role('button', name='搜索').click()
# 验证导入成功 (搜索并在列表中看到)
# 使用 .app-container 限定在主页面表格,避免匹配到弹窗中的隐藏行
await self.search_table(table_name)
row = self.page.locator('.app-container .el-table__body-wrapper tbody tr').filter(
has=self.page.get_by_text(table_name, exact=True)
)
for _i in range(5):
try:
await expect(row.first).to_be_visible(timeout=3000)
break
except AssertionError:
await self.search_table(table_name)
await expect(row.first).to_be_visible(timeout=5000)
async def edit_table(self, table_name: str, remark: str) -> None:
"""编辑表"""
await self.search_table(table_name)
row = self.page.locator(f"tbody tr:has-text('{table_name}')")
# 点击编辑 (操作列第2个按钮,索引1)
# 按钮顺序: 预览, 编辑, 删除, 同步, 生成
await row.locator('button').nth(1).click()
# 等待编辑页面 (tab页)
await self.page.wait_for_selector("div[role='tablist']")
# 修改基本信息 -> 表描述
# 确保在基本信息 Tab
await self.page.get_by_text('基本信息').click()
await self.page.get_by_role('textbox', name='表描述').fill(remark)
# 提交
await self.page.get_by_role('button', name='提交').click()
# 验证回到列表
await self.wait_for_selector('.app-container')
# 验证描述已更新
await self.search_table(table_name)
await self.wait_for_selector(f"tbody tr:has-text('{remark}')")
async def preview_code(self, table_name: str) -> None:
"""预览代码"""
await self.search_table(table_name)
row = self.page.locator(f"tbody tr:has-text('{table_name}')")
# 点击预览 (操作列第1个按钮,索引0)
await row.locator('button').nth(0).click()
# 等待预览弹窗
dialog = self.page.locator("div[role='dialog'][aria-label='代码预览']")
await dialog.wait_for()
# 验证存在代码内容
# pre 可能有多个(多tab),只检查可见的
await dialog.locator('pre:visible').first.wait_for()
content = await dialog.locator('pre:visible').first.text_content()
assert 'class' in content or 'import' in content or 'package' in content
# 关闭预览 (点击右上角关闭按钮)
await dialog.locator('.el-dialog__headerbtn').click()
async def delete_table(self, table_name: str, verify: bool = True) -> None:
"""删除表 (支持删除多条重复数据)"""
# 循环删除直到不存在
for _i in range(5):
await self.search_table(table_name)
# 使用 strict matching
# 限制在 .app-container .el-table__body-wrapper 以避免固定列导致的重复 以及 避免匹配到弹窗中的行
row = self.page.locator('.app-container .el-table__body-wrapper tbody tr').filter(
has=self.page.get_by_text(table_name, exact=True)
)
count = await row.count()
if count == 0:
break
# 针对第一行操作
# 使用 force=True 确保点击,防止遮挡
btns = row.first.locator('button')
await btns.nth(2).click(force=True)
# 处理确认弹窗
await self.page.get_by_role('button', name='确定').click()
# 等待删除成功提示
# 使用 specific selector 避免匹配到代码预览中的文本
await expect(self.page.locator('.el-message__content').filter(has_text='删除成功')).to_be_visible(
timeout=5000
)
# 等待提示消失,防止干扰下一次操作
await expect(self.page.locator('.el-message__content').filter(has_text='删除成功')).to_be_hidden(
timeout=5000
)
if verify:
# 验证删除成功
# 重新搜索验证不存在
await self.search_table(table_name)
# 使用 strict matching
row = self.page.locator('.app-container .el-table__body-wrapper tbody tr').filter(
has=self.page.get_by_text(table_name, exact=True)
)
# 期望找不到或者 count为0
await expect(row).to_have_count(0)
@pytest.mark.asyncio
async def test_gen_table_page() -> None:
"""测试代码生成页面功能"""
async with async_playwright() as p:
test_instance = GenTableTest()
await test_instance.setup(p)
try:
await test_instance.test_gen_code_flow()
finally:
await test_instance.teardown()
@@ -0,0 +1,38 @@
import pytest
from playwright.async_api import async_playwright, expect
from common.base_page_test import BasePageTest
from common.config import Config
class SwaggerTest(BasePageTest):
async def check_swagger_interface(self) -> None:
"""测试系统接口页面 (Swagger UI)"""
# 1. 直接导航到系统接口页面
await self.goto_page(Config.frontend_url + '/tool/swagger')
# 2. 验证页面加载
# 等待 iframe 出现
iframe = self.page.locator('iframe')
await expect(iframe).to_be_visible()
# 获取 iframe 内容框架
frame = self.page.frame_locator('iframe')
# 后端未禁用Swagger时,验证 iframe 内部的标题包含 "ShiErFeng-FastAPI"
# 当前生产环境已默认禁用Swagger,此处验证标题是否包含默认禁用提示
h1_locator = frame.locator('h1')
await expect(h1_locator).to_contain_text('Swagger UI has been disabled. Please enable it first.', timeout=15000)
@pytest.mark.asyncio
async def test_swagger_page() -> None:
"""测试系统接口页面功能"""
async with async_playwright() as p:
test_instance = SwaggerTest()
await test_instance.setup(p)
try:
await test_instance.check_swagger_interface()
finally:
await test_instance.teardown()