feat(admin): 添加用户管理相关文件

添加用户管理视图、API和状态管理文件
This commit is contained in:
张翔
2026-03-28 14:37:29 +08:00
commit 08ea5fbe98
1643 changed files with 255646 additions and 0 deletions
@@ -0,0 +1,19 @@
from .data_generator import DataGenerator
from .exception_handler import (
retry_on_failure,
handle_test_failure,
)
from .screenshot_helper import ScreenshotHelper
from .form_helper import FormHelper
from .table_helper import TableHelper
from .report_helper import ReportHelper
__all__ = [
"DataGenerator",
"retry_on_failure",
"handle_test_failure",
"ScreenshotHelper",
"FormHelper",
"TableHelper",
"ReportHelper",
]
@@ -0,0 +1,115 @@
import random
import string
from datetime import datetime, timedelta
from typing import Dict, Any
class DataGenerator:
"""数据生成器类"""
@staticmethod
def random_username(length: int = 8) -> str:
"""生成随机用户名"""
return "".join(random.choices(string.ascii_lowercase + string.digits, k=length))
@staticmethod
def random_password(length: int = 12) -> str:
"""生成随机密码"""
chars = string.ascii_letters + string.digits + "!@#$%^&*"
return "".join(random.choices(chars, k=length))
@staticmethod
def random_email() -> str:
"""生成随机邮箱"""
username = "".join(random.choices(string.ascii_lowercase + string.digits, k=8))
domains = ["gmail.com", "qq.com", "163.com", "outlook.com"]
domain = random.choice(domains)
return f"{username}@{domain}"
@staticmethod
def random_phone() -> str:
"""生成随机手机号"""
prefix = random.choice(["138", "139", "150", "151", "186", "188"])
suffix = "".join(random.choices(string.digits, k=8))
return f"{prefix}{suffix}"
@staticmethod
def random_date(start_date: datetime = None, end_date: datetime = None) -> datetime:
"""生成随机日期"""
if start_date is None:
start_date = datetime.now() - timedelta(days=365)
if end_date is None:
end_date = datetime.now()
time_between = end_date - start_date
days_between = time_between.days
random_days = random.randrange(days_between)
return start_date + timedelta(days=random_days)
@staticmethod
def generate_user_data(overrides: Dict[str, Any] = None) -> Dict[str, Any]:
"""生成用户数据
Args:
overrides: 覆盖的字段
Returns:
用户数据字典
"""
data = {
"username": DataGenerator.random_username(),
"password": DataGenerator.random_password(),
"email": DataGenerator.random_email(),
"phone": DataGenerator.random_phone(),
"status": "active",
}
if overrides:
data.update(overrides)
return data
@staticmethod
def generate_role_data(overrides: Dict[str, Any] = None) -> Dict[str, Any]:
"""生成角色数据
Args:
overrides: 覆盖的字段
Returns:
角色数据字典
"""
data = {
"role_name": f"test_role_{random.randint(1000, 9999)}",
"role_code": f"test_role_{random.randint(1000, 9999)}",
"description": "测试角色",
"status": 1,
}
if overrides:
data.update(overrides)
return data
@staticmethod
def generate_menu_data(overrides: Dict[str, Any] = None) -> Dict[str, Any]:
"""生成菜单数据
Args:
overrides: 覆盖的字段
Returns:
菜单数据字典
"""
data = {
"menu_name": f"test_menu_{random.randint(1000, 9999)}",
"menu_type": 1,
"path": f"/test/{random.randint(1000, 9999)}",
"icon": "test-icon",
"status": 0,
}
if overrides:
data.update(overrides)
return data
@@ -0,0 +1,65 @@
from typing import Callable, Any
from functools import wraps
def retry_on_failure(max_retries: int = 3, delay: int = 1000):
"""失败重试装饰器
Args:
max_retries: 最大重试次数
delay: 重试延迟(毫秒)
"""
def decorator(func: Callable) -> Callable:
@wraps(func)
def wrapper(*args, **kwargs) -> Any:
import time
last_exception = None
for attempt in range(max_retries):
try:
return func(*args, **kwargs)
except Exception as e:
last_exception = e
if attempt < max_retries - 1:
time.sleep(delay / 1000)
else:
raise last_exception
return wrapper
return decorator
def handle_test_failure(test_name: str):
"""测试失败处理装饰器
Args:
test_name: 测试名称
"""
def decorator(func: Callable) -> Callable:
@wraps(func)
def wrapper(*args, **kwargs) -> Any:
try:
return func(*args, **kwargs)
except Exception as e:
import os
from datetime import datetime
os.makedirs("screenshots", exist_ok=True)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"screenshots/failure_{test_name}_{timestamp}.png"
if "page" in kwargs:
kwargs["page"].screenshot(path=filename)
print(f"测试失败: {test_name}")
print(f"错误信息: {str(e)}")
print(f"截图已保存: {filename}")
raise e
return wrapper
return decorator
@@ -0,0 +1,368 @@
from playwright.sync_api import Page
from typing import Dict, Optional
class FormHelper:
"""表单辅助工具类"""
def __init__(self, page: Page):
"""初始化表单辅助工具
Args:
page: Playwright页面对象
"""
self.page = page
def fill_input_field(self, selector: str, value: str, timeout: int = 10000) -> None:
"""填充输入框
Args:
selector: CSS选择器
value: 要填充的值
timeout: 元素等待超时时间(毫秒)
"""
self.page.wait_for_selector(selector, timeout=timeout, state="visible")
self.page.fill(selector, value)
def fill_textarea(self, selector: str, value: str, timeout: int = 10000) -> None:
"""填充文本域
Args:
selector: CSS选择器
value: 要填充的值
timeout: 元素等待超时时间(毫秒)
"""
self.page.wait_for_selector(selector, timeout=timeout, state="visible")
self.page.fill(selector, value)
def select_option(self, selector: str, value: str, timeout: int = 10000) -> None:
"""选择下拉选项
Args:
selector: CSS选择器
value: 要选择的值
timeout: 元素等待超时时间(毫秒)
"""
self.page.wait_for_selector(selector, timeout=timeout, state="visible")
self.page.select_option(selector, value)
def select_option_by_label(self, selector: str, label: str, timeout: int = 10000) -> None:
"""通过标签选择下拉选项
Args:
selector: CSS选择器
label: 选项标签
timeout: 元素等待超时时间(毫秒)
"""
self.page.wait_for_selector(selector, timeout=timeout, state="visible")
self.page.select_option(selector, label=label)
def select_option_by_index(self, selector: str, index: int, timeout: int = 10000) -> None:
"""通过索引选择下拉选项
Args:
selector: CSS选择器
index: 选项索引
timeout: 元素等待超时时间(毫秒)
"""
self.page.wait_for_selector(selector, timeout=timeout, state="visible")
self.page.select_option(selector, index=index)
def check_checkbox(self, selector: str, checked: bool = True, timeout: int = 10000) -> None:
"""勾选或取消勾选复选框
Args:
selector: CSS选择器
checked: 是否勾选
timeout: 元素等待超时时间(毫秒)
"""
self.page.wait_for_selector(selector, timeout=timeout, state="visible")
self.page.check(selector, force=True) if checked else self.page.uncheck(
selector, force=True
)
def toggle_checkbox(self, selector: str, timeout: int = 10000) -> None:
"""切换复选框状态
Args:
selector: CSS选择器
timeout: 元素等待超时时间(毫秒)
"""
self.page.wait_for_selector(selector, timeout=timeout, state="visible")
checkbox = self.page.locator(selector)
if checkbox.is_checked():
checkbox.uncheck(force=True)
else:
checkbox.check(force=True)
def select_radio_button(self, name: str, value: str, timeout: int = 10000) -> None:
"""选择单选按钮
Args:
name: 单选按钮组名称
value: 要选择的值
timeout: 元素等待超时时间(毫秒)
"""
selector = f"input[type='radio'][name='{name}'][value='{value}']"
self.page.wait_for_selector(selector, timeout=timeout, state="visible")
self.page.check(selector, force=True)
def upload_file(self, selector: str, file_path: str, timeout: int = 10000) -> None:
"""上传文件
Args:
selector: CSS选择器
file_path: 文件路径
timeout: 元素等待超时时间(毫秒)
"""
self.page.wait_for_selector(selector, timeout=timeout, state="visible")
self.page.set_input_files(selector, file_path)
def fill_form(self, form_data: Dict[str, any], timeout: int = 10000) -> None:
"""填充整个表单
Args:
form_data: 表单数据字典,键为字段名,值为字段值
timeout: 元素等待超时时间(毫秒)
"""
for field_name, field_value in form_data.items():
if field_value is None or field_value == "":
continue
selector = f"[name='{field_name}']"
element = self.page.locator(selector)
if element.count() == 0:
selector = f"#{field_name}"
element = self.page.locator(selector)
if element.count() == 0:
continue
element_type = element.get_attribute("type") or element.evaluate("el => el.tagName")
if element_type == "checkbox":
self.check_checkbox(selector, field_value, timeout)
elif element_type == "radio":
self.select_radio_button(field_name, field_value, timeout)
elif element_type == "select" or element_type == "SELECT":
self.select_option(selector, field_value, timeout)
elif element_type == "file":
self.upload_file(selector, field_value, timeout)
elif element_type == "textarea" or element_type == "TEXTAREA":
self.fill_textarea(selector, field_value, timeout)
else:
self.fill_input_field(selector, str(field_value), timeout)
def submit_form(self, selector: str = "button[type='submit']", timeout: int = 10000) -> None:
"""提交表单
Args:
selector: 提交按钮选择器
timeout: 元素等待超时时间(毫秒)
"""
self.page.wait_for_selector(selector, timeout=timeout, state="visible")
self.page.click(selector)
def reset_form(self, selector: str = "button[type='reset']", timeout: int = 10000) -> None:
"""重置表单
Args:
selector: 重置按钮选择器
timeout: 元素等待超时时间(毫秒)
"""
self.page.wait_for_selector(selector, timeout=timeout, state="visible")
self.page.click(selector)
def clear_form(self, form_selector: str = "form") -> None:
"""清空表单
Args:
form_selector: 表单选择器
"""
form = self.page.locator(form_selector)
inputs = form.locator(
"input[type='text'], input[type='email'], input[type='password'], input[type='tel']"
)
for i in range(inputs.count()):
inputs.nth(i).fill("")
textareas = form.locator("textarea")
for i in range(textareas.count()):
textareas.nth(i).fill("")
def get_form_values(self, form_selector: str = "form") -> Dict[str, str]:
"""获取表单值
Args:
form_selector: 表单选择器
Returns:
表单值字典
"""
form = self.page.locator(form_selector)
values = {}
inputs = form.locator("input")
for i in range(inputs.count()):
input_element = inputs.nth(i)
name = input_element.get_attribute("name")
input_type = input_element.get_attribute("type")
if name and input_type not in ["submit", "reset", "button"]:
if input_type == "checkbox":
values[name] = str(input_element.is_checked())
elif input_type == "radio":
if input_element.is_checked():
values[name] = input_element.get_attribute("value")
else:
values[name] = input_element.input_value()
selects = form.locator("select")
for i in range(selects.count()):
select_element = selects.nth(i)
name = select_element.get_attribute("name")
if name:
values[name] = select_element.input_value()
textareas = form.locator("textarea")
for i in range(textareas.count()):
textarea_element = textareas.nth(i)
name = textarea_element.get_attribute("name")
if name:
values[name] = textarea_element.input_value()
return values
def validate_form(self, form_data: Dict[str, any], form_selector: str = "form") -> bool:
"""验证表单
Args:
form_data: 期望的表单数据
form_selector: 表单选择器
Returns:
表单是否有效
"""
current_values = self.get_form_values(form_selector)
return current_values == form_data
def is_field_required(self, selector: str) -> bool:
"""检查字段是否必填
Args:
selector: CSS选择器
Returns:
字段是否必填
"""
element = self.page.locator(selector)
return element.get_attribute("required") is not None
def is_field_valid(self, selector: str) -> bool:
"""检查字段是否有效
Args:
selector: CSS选择器
Returns:
字段是否有效
"""
element = self.page.locator(selector)
is_valid = element.get_attribute("data-valid")
if is_valid is not None:
return is_valid.lower() == "true"
return not element.evaluate("el => el.checkValidity()")
def get_field_error(self, selector: str) -> Optional[str]:
"""获取字段错误信息
Args:
selector: CSS选择器
Returns:
错误信息,如果没有错误则返回None
"""
error_selector = f"{selector} + ~ .error-message, {selector} ~ .error"
error_element = self.page.locator(error_selector)
if error_element.count() > 0 and error_element.is_visible():
return error_element.text_content()
return None
def wait_for_form_validation(self, timeout: int = 5000) -> None:
"""等待表单验证完成
Args:
timeout: 等待超时时间(毫秒)
"""
self.page.wait_for_timeout(timeout)
def fill_date_field(
self, selector: str, date: str, format: str = "YYYY-MM-DD", timeout: int = 10000
) -> None:
"""填充日期字段
Args:
selector: CSS选择器
date: 日期字符串
format: 日期格式
timeout: 元素等待超时时间(毫秒)
"""
self.page.wait_for_selector(selector, timeout=timeout, state="visible")
date_input = self.page.locator(selector)
date_input_type = date_input.get_attribute("type")
if date_input_type == "date":
date_input.fill(date)
else:
date_input.click()
self.page.wait_for_timeout(500)
self.page.keyboard.type(date)
self.page.keyboard.press("Enter")
def fill_number_field(self, selector: str, value: int, timeout: int = 10000) -> None:
"""填充数字字段
Args:
selector: CSS选择器
value: 数字值
timeout: 元素等待超时时间(毫秒)
"""
self.page.wait_for_selector(selector, timeout=timeout, state="visible")
self.page.fill(selector, str(value))
def increment_number_field(self, selector: str, count: int = 1, timeout: int = 10000) -> None:
"""增加数字字段值
Args:
selector: CSS选择器
count: 增加的数量
timeout: 元素等待超时时间(毫秒)
"""
self.page.wait_for_selector(selector, timeout=timeout, state="visible")
element = self.page.locator(selector)
for _ in range(count):
element.press("ArrowUp")
self.page.wait_for_timeout(100)
def decrement_number_field(self, selector: str, count: int = 1, timeout: int = 10000) -> None:
"""减少数字字段值
Args:
selector: CSS选择器
count: 减少的数量
timeout: 元素等待超时时间(毫秒)
"""
self.page.wait_for_selector(selector, timeout=timeout, state="visible")
element = self.page.locator(selector)
for _ in range(count):
element.press("ArrowDown")
self.page.wait_for_timeout(100)
@@ -0,0 +1,355 @@
import os
import subprocess
from typing import Optional
from pathlib import Path
import shutil
class ReportHelper:
"""测试报告辅助工具类"""
def __init__(
self,
allure_results_dir: str = "reports/allure-results",
allure_report_dir: str = "reports/allure-report",
html_report_dir: str = "reports/html",
):
"""初始化报告辅助工具
Args:
allure_results_dir: Allure测试结果目录
allure_report_dir: Allure报告输出目录
html_report_dir: HTML报告输出目录
"""
self.allure_results_dir = allure_results_dir
self.allure_report_dir = allure_report_dir
self.html_report_dir = html_report_dir
self._ensure_directories()
def _ensure_directories(self) -> None:
"""确保报告目录存在"""
Path(self.allure_results_dir).mkdir(parents=True, exist_ok=True)
Path(self.allure_report_dir).mkdir(parents=True, exist_ok=True)
Path(self.html_report_dir).mkdir(parents=True, exist_ok=True)
def generate_allure_report(self, clean: bool = True) -> bool:
"""生成Allure测试报告
Args:
clean: 是否清理旧的报告
Returns:
是否生成成功
"""
try:
if clean and os.path.exists(self.allure_report_dir):
shutil.rmtree(self.allure_report_dir)
Path(self.allure_report_dir).mkdir(parents=True, exist_ok=True)
command = [
"allure",
"generate",
self.allure_results_dir,
"-o",
self.allure_report_dir,
"--clean",
]
result = subprocess.run(command, capture_output=True, text=True)
if result.returncode == 0:
print(f"Allure报告生成成功: {self.allure_report_dir}")
return True
else:
print(f"Allure报告生成失败: {result.stderr}")
return False
except FileNotFoundError:
print("Allure命令未找到,请先安装Allure: brew install allure")
return False
except Exception as e:
print(f"生成Allure报告时发生错误: {str(e)}")
return False
def open_allure_report(self) -> bool:
"""打开Allure测试报告
Returns:
是否打开成功
"""
try:
if not os.path.exists(self.allure_report_dir):
print(f"Allure报告目录不存在: {self.allure_report_dir}")
return False
command = ["allure", "open", self.allure_report_dir]
subprocess.Popen(command)
print(f"Allure报告已打开: {self.allure_report_dir}")
return True
except FileNotFoundError:
print("Allure命令未找到,请先安装Allure: brew install allure")
return False
except Exception as e:
print(f"打开Allure报告时发生错误: {str(e)}")
return False
def serve_allure_report(self, port: int = 8080) -> bool:
"""启动Allure报告服务器
Args:
port: 服务器端口
Returns:
是否启动成功
"""
try:
if not os.path.exists(self.allure_report_dir):
print(f"Allure报告目录不存在: {self.allure_report_dir}")
return False
command = ["allure", "serve", self.allure_report_dir, "-p", str(port)]
subprocess.Popen(command)
print(f"Allure报告服务器已启动: http://localhost:{port}")
return True
except FileNotFoundError:
print("Allure命令未找到,请先安装Allure: brew install allure")
return False
except Exception as e:
print(f"启动Allure报告服务器时发生错误: {str(e)}")
return False
def get_html_report_path(self) -> str:
"""获取HTML报告路径
Returns:
HTML报告路径
"""
html_files = list(Path(self.html_report_dir).glob("*.html"))
if html_files:
return str(html_files[0])
return os.path.join(self.html_report_dir, "report.html")
def open_html_report(self) -> bool:
"""打开HTML测试报告
Returns:
是否打开成功
"""
try:
report_path = self.get_html_report_path()
if not os.path.exists(report_path):
print(f"HTML报告文件不存在: {report_path}")
return False
import webbrowser
webbrowser.open(f"file://{os.path.abspath(report_path)}")
print(f"HTML报告已打开: {report_path}")
return True
except Exception as e:
print(f"打开HTML报告时发生错误: {str(e)}")
return False
def clean_allure_results(self) -> bool:
"""清理Allure测试结果
Returns:
是否清理成功
"""
try:
if os.path.exists(self.allure_results_dir):
shutil.rmtree(self.allure_results_dir)
Path(self.allure_results_dir).mkdir(parents=True, exist_ok=True)
print(f"Allure测试结果已清理: {self.allure_results_dir}")
return True
return False
except Exception as e:
print(f"清理Allure测试结果时发生错误: {str(e)}")
return False
def clean_allure_report(self) -> bool:
"""清理Allure报告
Returns:
是否清理成功
"""
try:
if os.path.exists(self.allure_report_dir):
shutil.rmtree(self.allure_report_dir)
Path(self.allure_report_dir).mkdir(parents=True, exist_ok=True)
print(f"Allure报告已清理: {self.allure_report_dir}")
return True
return False
except Exception as e:
print(f"清理Allure报告时发生错误: {str(e)}")
return False
def clean_html_report(self) -> bool:
"""清理HTML报告
Returns:
是否清理成功
"""
try:
if os.path.exists(self.html_report_dir):
shutil.rmtree(self.html_report_dir)
Path(self.html_report_dir).mkdir(parents=True, exist_ok=True)
print(f"HTML报告已清理: {self.html_report_dir}")
return True
return False
except Exception as e:
print(f"清理HTML报告时发生错误: {str(e)}")
return False
def clean_all_reports(self) -> bool:
"""清理所有报告
Returns:
是否清理成功
"""
success = True
success = self.clean_allure_results() and success
success = self.clean_allure_report() and success
success = self.clean_html_report() and success
if success:
print("所有报告已清理")
return success
def get_report_summary(self) -> dict:
"""获取报告摘要信息
Returns:
报告摘要字典
"""
summary = {
"allure_results_dir": self.allure_results_dir,
"allure_report_dir": self.allure_report_dir,
"html_report_dir": self.html_report_dir,
"allure_results_exists": os.path.exists(self.allure_results_dir),
"allure_report_exists": os.path.exists(self.allure_report_dir),
"html_report_exists": os.path.exists(self.html_report_dir),
"html_report_path": self.get_html_report_path(),
}
return summary
def archive_report(self, archive_name: str, archive_dir: str = "reports/archives") -> bool:
"""归档报告
Args:
archive_name: 归档名称
archive_dir: 归档目录
Returns:
是否归档成功
"""
try:
Path(archive_dir).mkdir(parents=True, exist_ok=True)
timestamp = subprocess.run(
["date", "+%Y%m%d_%H%M%S"], capture_output=True, text=True
).stdout.strip()
archive_path = os.path.join(archive_dir, f"{archive_name}_{timestamp}")
if os.path.exists(self.allure_report_dir):
shutil.copytree(self.allure_report_dir, os.path.join(archive_path, "allure"))
if os.path.exists(self.html_report_dir):
shutil.copytree(self.html_report_dir, os.path.join(archive_path, "html"))
print(f"报告已归档到: {archive_path}")
return True
except Exception as e:
print(f"归档报告时发生错误: {str(e)}")
return False
def get_allure_history(self) -> list:
"""获取Allure历史记录
Returns:
历史记录列表
"""
history_dir = os.path.join(self.allure_report_dir, "history")
if not os.path.exists(history_dir):
return []
history = []
for item in os.listdir(history_dir):
item_path = os.path.join(history_dir, item)
if os.path.isdir(item_path):
history.append(
{
"name": item,
"path": item_path,
"modified": os.path.getmtime(item_path),
}
)
return sorted(history, key=lambda x: x["modified"], reverse=True)
def generate_combined_report(self) -> bool:
"""生成组合报告(Allure + HTML
Returns:
是否生成成功
"""
success = True
allure_success = self.generate_allure_report()
success = allure_success and success
html_report_path = self.get_html_report_path()
if os.path.exists(html_report_path):
print(f"HTML报告路径: {html_report_path}")
return success
def validate_allure_installation(self) -> bool:
"""验证Allure是否已安装
Returns:
Allure是否已安装
"""
try:
result = subprocess.run(["allure", "--version"], capture_output=True, text=True)
if result.returncode == 0:
print(f"Allure版本: {result.stdout.strip()}")
return True
return False
except FileNotFoundError:
print("Allure未安装")
return False
except Exception as e:
print(f"验证Allure安装时发生错误: {str(e)}")
return False
def get_test_statistics(self) -> Optional[dict]:
"""获取测试统计信息(需要Allure已安装)
Returns:
测试统计信息字典
"""
try:
if not self.validate_allure_installation():
return None
command = ["allure", "report", "list"]
result = subprocess.run(command, capture_output=True, text=True)
if result.returncode != 0:
return None
return {
"total": 0,
"passed": 0,
"failed": 0,
"broken": 0,
"skipped": 0,
"unknown": 0,
}
except Exception as e:
print(f"获取测试统计信息时发生错误: {str(e)}")
return None
@@ -0,0 +1,179 @@
from playwright.sync_api import Page, Locator
from typing import Optional
from pathlib import Path
import os
class ScreenshotHelper:
"""截图辅助工具类"""
def __init__(self, page: Page, screenshot_dir: str = "screenshots"):
"""初始化截图辅助工具
Args:
page: Playwright页面对象
screenshot_dir: 截图保存目录
"""
self.page = page
self.screenshot_dir = screenshot_dir
self._ensure_screenshot_dir()
def _ensure_screenshot_dir(self) -> None:
"""确保截图目录存在"""
Path(self.screenshot_dir).mkdir(parents=True, exist_ok=True)
def take_full_page_screenshot(self, name: str, timeout: int = 30000) -> str:
"""截取整个页面
Args:
name: 截图文件名(不含扩展名)
timeout: 页面加载超时时间(毫秒)
Returns:
截图文件路径
"""
self.page.wait_for_load_state("networkidle", timeout=timeout)
file_path = os.path.join(self.screenshot_dir, f"{name}.png")
self.page.screenshot(path=file_path, full_page=True)
return file_path
def take_viewport_screenshot(self, name: str, timeout: int = 30000) -> str:
"""截取当前视口
Args:
name: 截图文件名(不含扩展名)
timeout: 页面加载超时时间(毫秒)
Returns:
截图文件路径
"""
self.page.wait_for_load_state("networkidle", timeout=timeout)
file_path = os.path.join(self.screenshot_dir, f"{name}.png")
self.page.screenshot(path=file_path, full_page=False)
return file_path
def take_element_screenshot(self, locator: Locator, name: str, timeout: int = 10000) -> str:
"""截取指定元素
Args:
locator: 元素定位器
name: 截图文件名(不含扩展名)
timeout: 元素等待超时时间(毫秒)
Returns:
截图文件路径
"""
element = locator.wait_for(timeout=timeout)
file_path = os.path.join(self.screenshot_dir, f"{name}.png")
element.screenshot(path=file_path)
return file_path
def take_element_screenshot_by_selector(
self, selector: str, name: str, timeout: int = 10000
) -> str:
"""通过选择器截取元素
Args:
selector: CSS选择器
name: 截图文件名(不含扩展名)
timeout: 元素等待超时时间(毫秒)
Returns:
截图文件路径
"""
locator = self.page.locator(selector)
return self.take_element_screenshot(locator, name, timeout)
def take_screenshot_with_mask(
self, name: str, mask_selectors: list, timeout: int = 30000
) -> str:
"""截取页面并遮蔽指定元素
Args:
name: 截图文件名(不含扩展名)
mask_selectors: 需要遮蔽的元素选择器列表
timeout: 页面加载超时时间(毫秒)
Returns:
截图文件路径
"""
self.page.wait_for_load_state("networkidle", timeout=timeout)
file_path = os.path.join(self.screenshot_dir, f"{name}.png")
masks = []
for selector in mask_selectors:
element = self.page.locator(selector)
if element.is_visible():
masks.append(element)
self.page.screenshot(path=file_path, full_page=True, mask=masks)
return file_path
def take_screenshot_on_failure(self, test_name: str, step_name: Optional[str] = None) -> str:
"""测试失败时截图
Args:
test_name: 测试名称
step_name: 步骤名称(可选)
Returns:
截图文件路径
"""
if step_name:
file_name = f"{test_name}_{step_name}_failed"
else:
file_name = f"{test_name}_failed"
return self.take_full_page_screenshot(file_name)
def take_screenshot_series(self, base_name: str, count: int, delay: int = 1000) -> list:
"""连续截取多张截图
Args:
base_name: 截图基础名称
count: 截图数量
delay: 每次截图之间的延迟(毫秒)
Returns:
截图文件路径列表
"""
file_paths = []
for i in range(count):
file_name = f"{base_name}_{i + 1}"
file_path = self.take_viewport_screenshot(file_name)
file_paths.append(file_path)
if i < count - 1:
self.page.wait_for_timeout(delay)
return file_paths
def take_screenshot_before_and_after(self, action, name: str) -> tuple:
"""在操作前后截图
Args:
action: 要执行的操作函数
name: 截图基础名称
Returns:
(操作前截图路径, 操作后截图路径)
"""
before_path = self.take_viewport_screenshot(f"{name}_before")
action()
after_path = self.take_viewport_screenshot(f"{name}_after")
return (before_path, after_path)
def capture_visual_diff(self, name: str, expected_path: str) -> tuple:
"""捕获视觉差异
Args:
name: 截图文件名
expected_path: 期望截图路径
Returns:
(当前截图路径, 差异截图路径)
"""
current_path = self.take_full_page_screenshot(name)
if not os.path.exists(expected_path):
return (current_path, None)
return (current_path, None)
@@ -0,0 +1,428 @@
from playwright.sync_api import Page, Locator
from typing import Dict, List, Optional
class TableHelper:
"""表格辅助工具类"""
def __init__(self, page: Page):
"""初始化表格辅助工具
Args:
page: Playwright页面对象
"""
self.page = page
def get_row_count(self, table_selector: str, timeout: int = 10000) -> int:
"""获取表格行数
Args:
table_selector: 表格选择器
timeout: 元素等待超时时间(毫秒)
Returns:
表格行数
"""
self.page.wait_for_selector(table_selector, timeout=timeout, state="visible")
rows = self.page.locator(f"{table_selector} tbody tr")
return rows.count()
def get_column_count(self, table_selector: str, timeout: int = 10000) -> int:
"""获取表格列数
Args:
table_selector: 表格选择器
timeout: 元素等待超时时间(毫秒)
Returns:
表格列数
"""
self.page.wait_for_selector(table_selector, timeout=timeout, state="visible")
header_row = self.page.locator(f"{table_selector} thead tr")
if header_row.count() > 0:
cells = header_row.locator("th, td")
return cells.count()
first_row = self.page.locator(f"{table_selector} tbody tr:first-child")
cells = first_row.locator("td")
return cells.count()
def get_cell_text(
self, table_selector: str, row_index: int, col_index: int, timeout: int = 10000
) -> str:
"""获取单元格文本
Args:
table_selector: 表格选择器
row_index: 行索引(从0开始)
col_index: 列索引(从0开始)
timeout: 元素等待超时时间(毫秒)
Returns:
单元格文本
"""
self.page.wait_for_selector(table_selector, timeout=timeout, state="visible")
cell = self.page.locator(
f"{table_selector} tbody tr:nth-child({row_index + 1}) td:nth-child({col_index + 1})"
)
return cell.text_content()
def get_row_data(self, table_selector: str, row_index: int, timeout: int = 10000) -> List[str]:
"""获取整行数据
Args:
table_selector: 表格选择器
row_index: 行索引(从0开始)
timeout: 元素等待超时时间(毫秒)
Returns:
行数据列表
"""
self.page.wait_for_selector(table_selector, timeout=timeout, state="visible")
row = self.page.locator(f"{table_selector} tbody tr:nth-child({row_index + 1})")
cells = row.locator("td")
row_data = []
for i in range(cells.count()):
row_data.append(cells.nth(i).text_content())
return row_data
def get_all_rows(self, table_selector: str, timeout: int = 10000) -> List[List[str]]:
"""获取所有行数据
Args:
table_selector: 表格选择器
timeout: 元素等待超时时间(毫秒)
Returns:
所有行数据列表
"""
self.page.wait_for_selector(table_selector, timeout=timeout, state="visible")
rows = self.page.locator(f"{table_selector} tbody tr")
all_rows = []
for i in range(rows.count()):
row_data = self.get_row_data(table_selector, i, timeout)
all_rows.append(row_data)
return all_rows
def get_column_data(
self, table_selector: str, col_index: int, timeout: int = 10000
) -> List[str]:
"""获取整列数据
Args:
table_selector: 表格选择器
col_index: 列索引(从0开始)
timeout: 元素等待超时时间(毫秒)
Returns:
列数据列表
"""
self.page.wait_for_selector(table_selector, timeout=timeout, state="visible")
rows = self.page.locator(f"{table_selector} tbody tr")
column_data = []
for i in range(rows.count()):
cell_text = self.get_cell_text(table_selector, i, col_index, timeout)
column_data.append(cell_text)
return column_data
def get_headers(self, table_selector: str, timeout: int = 10000) -> List[str]:
"""获取表头
Args:
table_selector: 表格选择器
timeout: 元素等待超时时间(毫秒)
Returns:
表头列表
"""
self.page.wait_for_selector(table_selector, timeout=timeout, state="visible")
header_row = self.page.locator(f"{table_selector} thead tr")
if header_row.count() > 0:
headers = header_row.locator("th")
header_list = []
for i in range(headers.count()):
header_list.append(headers.nth(i).text_content())
return header_list
return []
def click_row(self, table_selector: str, row_index: int, timeout: int = 10000) -> None:
"""点击表格行
Args:
table_selector: 表格选择器
row_index: 行索引(从0开始)
timeout: 元素等待超时时间(毫秒)
"""
self.page.wait_for_selector(table_selector, timeout=timeout, state="visible")
row = self.page.locator(f"{table_selector} tbody tr:nth-child({row_index + 1})")
row.click()
def click_cell(
self, table_selector: str, row_index: int, col_index: int, timeout: int = 10000
) -> None:
"""点击单元格
Args:
table_selector: 表格选择器
row_index: 行索引(从0开始)
col_index: 列索引(从0开始)
timeout: 元素等待超时时间(毫秒)
"""
self.page.wait_for_selector(table_selector, timeout=timeout, state="visible")
cell = self.page.locator(
f"{table_selector} tbody tr:nth-child({row_index + 1}) td:nth-child({col_index + 1})"
)
cell.click()
def find_row_by_cell_text(
self, table_selector: str, search_text: str, col_index: int = 0, timeout: int = 10000
) -> Optional[int]:
"""通过单元格文本查找行
Args:
table_selector: 表格选择器
search_text: 要搜索的文本
col_index: 搜索的列索引
timeout: 元素等待超时时间(毫秒)
Returns:
找到的行索引,未找到则返回None
"""
self.page.wait_for_selector(table_selector, timeout=timeout, state="visible")
rows = self.page.locator(f"{table_selector} tbody tr")
for i in range(rows.count()):
cell_text = self.get_cell_text(table_selector, i, col_index, timeout)
if cell_text and search_text in cell_text:
return i
return None
def find_rows_by_cell_text(
self, table_selector: str, search_text: str, col_index: int = 0, timeout: int = 10000
) -> List[int]:
"""通过单元格文本查找所有匹配行
Args:
table_selector: 表格选择器
search_text: 要搜索的文本
col_index: 搜索的列索引
timeout: 元素等待超时时间(毫秒)
Returns:
找到的行索引列表
"""
self.page.wait_for_selector(table_selector, timeout=timeout, state="visible")
rows = self.page.locator(f"{table_selector} tbody tr")
matching_rows = []
for i in range(rows.count()):
cell_text = self.get_cell_text(table_selector, i, col_index, timeout)
if cell_text and search_text in cell_text:
matching_rows.append(i)
return matching_rows
def sort_table_by_column(
self, table_selector: str, col_index: int, ascending: bool = True, timeout: int = 10000
) -> None:
"""按列排序表格
Args:
table_selector: 表格选择器
col_index: 列索引(从0开始)
ascending: 是否升序排序
timeout: 元素等待超时时间(毫秒)
"""
self.page.wait_for_selector(table_selector, timeout=timeout, state="visible")
header = self.page.locator(f"{table_selector} thead tr th:nth-child({col_index + 1})")
header.click()
if not ascending:
header.click()
def filter_table(self, table_selector: str, filter_text: str, timeout: int = 10000) -> None:
"""过滤表格
Args:
table_selector: 表格选择器
filter_text: 过滤文本
timeout: 元素等待超时时间(毫秒)
"""
self.page.wait_for_selector(table_selector, timeout=timeout, state="visible")
filter_input = self.page.locator(f"{table_selector} ~ .filter-input, .search-input")
if filter_input.count() > 0:
filter_input.fill(filter_text)
self.page.keyboard.press("Enter")
def select_row_checkbox(
self, table_selector: str, row_index: int, timeout: int = 10000
) -> None:
"""选择行的复选框
Args:
table_selector: 表格选择器
row_index: 行索引(从0开始)
timeout: 元素等待超时时间(毫秒)
"""
self.page.wait_for_selector(table_selector, timeout=timeout, state="visible")
row = self.page.locator(f"{table_selector} tbody tr:nth-child({row_index + 1})")
checkbox = row.locator("input[type='checkbox']")
if checkbox.count() > 0:
checkbox.check(force=True)
def select_all_rows(self, table_selector: str, timeout: int = 10000) -> None:
"""选择所有行
Args:
table_selector: 表格选择器
timeout: 元素等待超时时间(毫秒)
"""
self.page.wait_for_selector(table_selector, timeout=timeout, state="visible")
select_all_checkbox = self.page.locator(
f"{table_selector} thead input[type='checkbox'], {table_selector} ~ .select-all-checkbox"
)
if select_all_checkbox.count() > 0:
select_all_checkbox.check(force=True)
def deselect_all_rows(self, table_selector: str, timeout: int = 10000) -> None:
"""取消选择所有行
Args:
table_selector: 表格选择器
timeout: 元素等待超时时间(毫秒)
"""
self.page.wait_for_selector(table_selector, timeout=timeout, state="visible")
select_all_checkbox = self.page.locator(
f"{table_selector} thead input[type='checkbox'], {table_selector} ~ .select-all-checkbox"
)
if select_all_checkbox.count() > 0:
select_all_checkbox.uncheck(force=True)
def get_row_by_id(
self, table_selector: str, row_id: str, timeout: int = 10000
) -> Optional[Locator]:
"""通过ID获取行
Args:
table_selector: 表格选择器
row_id: 行ID
timeout: 元素等待超时时间(毫秒)
Returns:
行元素,未找到则返回None
"""
self.page.wait_for_selector(table_selector, timeout=timeout, state="visible")
row = self.page.locator(f"{table_selector} tbody tr[data-id='{row_id}']")
if row.count() > 0:
return row
return None
def click_row_action_button(
self, table_selector: str, row_index: int, action: str = "edit", timeout: int = 10000
) -> None:
"""点击行操作按钮
Args:
table_selector: 表格选择器
row_index: 行索引(从0开始)
action: 操作类型(edit/delete/view等)
timeout: 元素等待超时时间(毫秒)
"""
self.page.wait_for_selector(table_selector, timeout=timeout, state="visible")
row = self.page.locator(f"{table_selector} tbody tr:nth-child({row_index + 1})")
action_button = row.locator(f"button.{action}, .{action}-button")
if action_button.count() > 0:
action_button.click()
else:
actions_cell = row.locator("td:last-child")
actions_cell.click()
def wait_for_table_load(
self, table_selector: str, timeout: int = 10000
) -> None:
"""等待表格加载完成
Args:
table_selector: 表格选择器
timeout: 等待超时时间(毫秒)
"""
self.page.wait_for_selector(table_selector, timeout=timeout, state="visible")
self.page.wait_for_timeout(500)
def is_table_empty(self, table_selector: str, timeout: int = 10000) -> bool:
"""检查表格是否为空
Args:
table_selector: 表格选择器
timeout: 元素等待超时时间(毫秒)
Returns:
表格是否为空
"""
self.page.wait_for_selector(table_selector, timeout=timeout, state="visible")
empty_message = self.page.locator(
f"{table_selector} ~ .no-data, {table_selector} ~ .empty-message"
)
if empty_message.count() > 0 and empty_message.is_visible():
return True
return self.get_row_count(table_selector, timeout) == 0
def get_table_data_as_dict(
self, table_selector: str, timeout: int = 10000
) -> List[Dict[str, str]]:
"""获取表格数据为字典列表
Args:
table_selector: 表格选择器
timeout: 元素等待超时时间(毫秒)
Returns:
表格数据字典列表,键为列名,值为单元格内容
"""
self.page.wait_for_selector(table_selector, timeout=timeout, state="visible")
headers = self.get_headers(table_selector, timeout)
all_rows = self.get_all_rows(table_selector, timeout)
table_data = []
for row_data in all_rows:
row_dict = {}
for i, header in enumerate(headers):
if i < len(row_data):
row_dict[header] = row_data[i]
table_data.append(row_dict)
return table_data
def scroll_to_row(self, table_selector: str, row_index: int, timeout: int = 10000) -> None:
"""滚动到指定行
Args:
table_selector: 表格选择器
row_index: 行索引(从0开始)
timeout: 元素等待超时时间(毫秒)
"""
self.page.wait_for_selector(table_selector, timeout=timeout, state="visible")
row = self.page.locator(f"{table_selector} tbody tr:nth-child({row_index + 1})")
row.scroll_into_view_if_needed()