feat(e2e-tests): 添加端到端测试框架及测试用例
refactor(components): 调整头部和页脚布局样式 style(hero-section): 更新徽章动画效果 docs: 添加测试框架README文档 test: 实现首页、导航和联系表单的测试用例 ci: 添加CI测试脚本和配置
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# Utils模块
|
||||
@@ -0,0 +1,413 @@
|
||||
"""
|
||||
测试数据生成模块
|
||||
提供测试过程中需要的各种测试数据生成功能
|
||||
"""
|
||||
|
||||
import random
|
||||
import string
|
||||
import uuid
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||
from dataclasses import dataclass, field
|
||||
from faker import Faker
|
||||
|
||||
from config.settings import get_settings
|
||||
|
||||
|
||||
class ChineseFaker:
|
||||
"""中文测试数据生成器"""
|
||||
|
||||
def __init__(self, locale: str = "zh_CN"):
|
||||
self.faker_zh = Faker("zh_CN")
|
||||
self.faker_en = Faker("en_US")
|
||||
|
||||
def name(self) -> str:
|
||||
"""生成中文姓名"""
|
||||
return self.faker_zh.name()
|
||||
|
||||
def first_name(self) -> str:
|
||||
"""生成中文名字"""
|
||||
return self.faker_zh.first_name()
|
||||
|
||||
def last_name(self) -> str:
|
||||
"""生成中文姓氏"""
|
||||
return self.faker_zh.last_name()
|
||||
|
||||
def phone_number(self) -> str:
|
||||
"""生成中国手机号"""
|
||||
# 生成以13-19开头的11位手机号
|
||||
prefix = random.choice(["13", "14", "15", "16", "17", "18", "19"])
|
||||
suffix = "".join(random.choices(string.digits, k=8))
|
||||
return prefix + suffix
|
||||
|
||||
def email(self, domain: Optional[str] = None) -> str:
|
||||
"""生成邮箱"""
|
||||
if domain:
|
||||
return self.faker_zh.email(domain=domain)
|
||||
return self.faker_zh.email()
|
||||
|
||||
def company_name(self) -> str:
|
||||
"""生成公司名称"""
|
||||
prefixes = ["四川", "成都", "西部", "西南", "中国", "高新"]
|
||||
suffixes = ["科技", "信息", "网络", "软件", "数据", "智能", "创新", "未来"]
|
||||
name = f"{random.choice(prefixes)}{self.faker_zh.company()}{random.choice(suffixes)}"
|
||||
return name
|
||||
|
||||
def address(self) -> str:
|
||||
"""生成中文地址"""
|
||||
return self.faker_zh.address().replace("\n", "")
|
||||
|
||||
def city(self) -> str:
|
||||
"""生成城市名"""
|
||||
return self.faker_zh.city()
|
||||
|
||||
def province(self) -> str:
|
||||
"""生成省份名"""
|
||||
return self.faker_zh.province()
|
||||
|
||||
def job_title(self) -> str:
|
||||
"""生成职位名称"""
|
||||
titles = [
|
||||
"软件工程师", "产品经理", "UI设计师", "测试工程师",
|
||||
"项目主管", "技术总监", "架构师", "数据分析师",
|
||||
"运维工程师", "产品运营", "市场经理", "销售代表"
|
||||
]
|
||||
return random.choice(titles)
|
||||
|
||||
def username(self) -> str:
|
||||
"""生成用户名"""
|
||||
return self.faker_zh.user_name()
|
||||
|
||||
def password(self, length: int = 12) -> str:
|
||||
"""生成密码"""
|
||||
chars = string.ascii_letters + string.digits + "!@#$%^&*"
|
||||
return "".join(random.choices(chars, k=length))
|
||||
|
||||
def text(self, max_chars: int = 200) -> str:
|
||||
"""生成随机中文文本"""
|
||||
paragraphs = []
|
||||
for _ in range(random.randint(1, 3)):
|
||||
sentences = []
|
||||
for _ in range(random.randint(3, 8)):
|
||||
sentence_len = random.randint(10, 30)
|
||||
sentence = self.faker_zh.sentence(nb_words=sentence_len)
|
||||
sentences.append(sentence)
|
||||
paragraphs.append("。".join(sentences) + "。")
|
||||
return "".join(paragraphs)[:max_chars]
|
||||
|
||||
def sentence(self, nb_words: int = 20) -> str:
|
||||
"""生成随机句子"""
|
||||
return self.faker_zh.sentence(nb_words=nb_words)
|
||||
|
||||
def word(self) -> str:
|
||||
"""生成随机词语"""
|
||||
return self.faker_zh.word()
|
||||
|
||||
def words(self, nb: int = 5) -> List[str]:
|
||||
"""生成随机词语列表"""
|
||||
return self.faker_zh.words(nb=nb)
|
||||
|
||||
def date_of_birth(self, start_year: int = 1960, end_year: int = 2000) -> str:
|
||||
"""生成出生日期"""
|
||||
return self.faker_zh.date_of_birth(
|
||||
minimum_age=end_year - datetime.now().year,
|
||||
maximum_age=start_year - datetime.now().year
|
||||
).strftime("%Y-%m-%d")
|
||||
|
||||
def credit_card_number(self) -> str:
|
||||
"""生成信用卡号(测试用)"""
|
||||
return self.faker_zh.credit_card_number()
|
||||
|
||||
def credit_card_provider(self) -> str:
|
||||
"""生成信用卡提供商"""
|
||||
providers = ["Visa", "MasterCard", "银联", "JCB", "American Express"]
|
||||
return random.choice(providers)
|
||||
|
||||
def ipv4(self) -> str:
|
||||
"""生成IPv4地址"""
|
||||
return self.faker_zh.ipv4()
|
||||
|
||||
def mac_address(self) -> str:
|
||||
"""生成MAC地址"""
|
||||
return self.faker_zh.mac_address()
|
||||
|
||||
def url(self) -> str:
|
||||
"""生成URL"""
|
||||
return self.faker_zh.url()
|
||||
|
||||
def uri_path(self) -> str:
|
||||
"""生成URI路径"""
|
||||
return self.faker_zh.uri_path()
|
||||
|
||||
def user_agent(self) -> str:
|
||||
"""生成User-Agent"""
|
||||
return self.faker_zh.user_agent()
|
||||
|
||||
def hex_color(self) -> str:
|
||||
"""生成十六进制颜色"""
|
||||
return self.faker_zh.hex_color()
|
||||
|
||||
def rgb_color(self) -> Tuple[int, int, int]:
|
||||
"""生成RGB颜色"""
|
||||
return self.faker_zh.rgb_color()
|
||||
|
||||
|
||||
class EnglishFaker:
|
||||
"""英文测试数据生成器"""
|
||||
|
||||
def __init__(self):
|
||||
self.faker = Faker("en_US")
|
||||
|
||||
def name(self) -> str:
|
||||
"""生成英文姓名"""
|
||||
return self.faker.name()
|
||||
|
||||
def first_name(self) -> str:
|
||||
"""生成英文名字"""
|
||||
return self.faker.first_name()
|
||||
|
||||
def last_name(self) -> str:
|
||||
"""生成英文姓氏"""
|
||||
return self.faker.last_name()
|
||||
|
||||
def email(self, domain: Optional[str] = None) -> str:
|
||||
"""生成邮箱"""
|
||||
if domain:
|
||||
return self.faker.email(domain=domain)
|
||||
return self.faker.email()
|
||||
|
||||
def phone_number(self) -> str:
|
||||
"""生成美国电话号码"""
|
||||
return self.faker.phone_number()
|
||||
|
||||
def company(self) -> str:
|
||||
"""生成公司名称"""
|
||||
return self.faker.company()
|
||||
|
||||
def address(self) -> str:
|
||||
"""生成地址"""
|
||||
return self.faker.address().replace("\n", ", ")
|
||||
|
||||
def city(self) -> str:
|
||||
"""生成城市名"""
|
||||
return self.faker.city()
|
||||
|
||||
def state(self) -> str:
|
||||
"""生成州/省名"""
|
||||
return self.faker.state()
|
||||
|
||||
def country(self) -> str:
|
||||
"""生成国家名"""
|
||||
return self.faker.country()
|
||||
|
||||
def zip_code(self) -> str:
|
||||
"""生成邮编"""
|
||||
return self.faker.zipcode()
|
||||
|
||||
def username(self) -> str:
|
||||
"""生成用户名"""
|
||||
return self.faker.user_name()
|
||||
|
||||
def password(self, length: int = 12) -> str:
|
||||
"""生成密码"""
|
||||
chars = string.ascii_letters + string.digits + "!@#$%^&*"
|
||||
return "".join(random.choices(chars, k=length))
|
||||
|
||||
def text(self, max_chars: int = 200) -> str:
|
||||
"""生成随机英文文本"""
|
||||
return self.faker.text(max_nb_chars=max_chars)
|
||||
|
||||
def sentence(self, nb_words: int = 10) -> str:
|
||||
"""生成随机句子"""
|
||||
return self.faker.sentence(nb_words=nb_words)
|
||||
|
||||
def paragraph(self, nb_sentences: int = 3) -> str:
|
||||
"""生成段落"""
|
||||
return self.faker.paragraph(nb_sentences=nb_sentences)
|
||||
|
||||
def date_of_birth(self, start_year: int = 1960, end_year: int = 2000) -> str:
|
||||
"""生成出生日期"""
|
||||
return self.faker.date_of_birth(
|
||||
minimum_age=end_year - datetime.now().year,
|
||||
maximum_age=start_year - datetime.now().year
|
||||
).strftime("%Y-%m-%d")
|
||||
|
||||
def date_between(self, start_date: str, end_date: str) -> str:
|
||||
"""生成日期范围"""
|
||||
start = datetime.strptime(start_date, "%Y-%m-%d")
|
||||
end = datetime.strptime(end_date, "%Y-%m-%d")
|
||||
return self.faker.date_between(start, end).strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
class TestDataGenerator:
|
||||
"""测试数据生成器主类"""
|
||||
|
||||
def __init__(self):
|
||||
self.settings = get_settings()
|
||||
self.zh_faker = ChineseFaker()
|
||||
self.en_faker = EnglishFaker()
|
||||
|
||||
def generate_contact_form_data(
|
||||
self,
|
||||
use_valid: bool = True,
|
||||
lang: str = "zh"
|
||||
) -> Dict[str, str]:
|
||||
"""生成联系表单数据"""
|
||||
if use_valid:
|
||||
if lang == "zh":
|
||||
return {
|
||||
"name": self.zh_faker.name(),
|
||||
"phone": self.zh_faker.phone_number(),
|
||||
"email": self.zh_faker.email(domain="example.com"),
|
||||
"subject": self.zh_faker.sentence(nb_words=8),
|
||||
"message": self.zh_faker.text(max_chars=300)
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"name": self.en_faker.name(),
|
||||
"phone": self.en_faker.phone_number(),
|
||||
"email": self.en_faker.email(domain="example.com"),
|
||||
"subject": self.en_faker.sentence(nb_words=8),
|
||||
"message": self.en_faker.paragraph(nb_sentences=3)
|
||||
}
|
||||
else:
|
||||
return self.settings.test_form_data.get("invalid", {
|
||||
"email": "invalid-email",
|
||||
"phone": "123"
|
||||
})
|
||||
|
||||
def generate_user_profile(self, lang: str = "zh") -> Dict[str, Any]:
|
||||
"""生成用户资料数据"""
|
||||
if lang == "zh":
|
||||
return {
|
||||
"name": self.zh_faker.name(),
|
||||
"email": self.zh_faker.email(domain="example.com"),
|
||||
"phone": self.zh_faker.phone_number(),
|
||||
"address": self.zh_faker.address(),
|
||||
"job_title": self.zh_faker.job_title(),
|
||||
"company": self.zh_faker.company_name(),
|
||||
"date_of_birth": self.zh_faker.date_of_birth()
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"name": self.en_faker.name(),
|
||||
"email": self.en_faker.email(domain="example.com"),
|
||||
"phone": self.en_faker.phone_number(),
|
||||
"address": self.en_faker.address(),
|
||||
"job_title": random.choice([
|
||||
"Software Engineer", "Product Manager", "Designer",
|
||||
"Marketing Manager", "Sales Representative", "Data Analyst"
|
||||
]),
|
||||
"company": self.en_faker.company(),
|
||||
"date_of_birth": self.en_faker.date_of_birth()
|
||||
}
|
||||
|
||||
def generate_search_query(self) -> str:
|
||||
"""生成搜索查询"""
|
||||
topics = [
|
||||
"软件开发", "云计算", "人工智能", "数据分析",
|
||||
"数字化转型", "企业服务", "智能制造", "物联网"
|
||||
]
|
||||
return random.choice(topics)
|
||||
|
||||
def generate_news_article(self) -> Dict[str, Any]:
|
||||
"""生成新闻文章数据"""
|
||||
return {
|
||||
"title": self.zh_faker.sentence(nb_words=12),
|
||||
"summary": self.zh_faker.text(max_chars=200),
|
||||
"content": self.zh_faker.text(max_chars=1000),
|
||||
"author": self.zh_faker.name(),
|
||||
"publish_date": datetime.now().strftime("%Y-%m-%d"),
|
||||
"category": random.choice(["公司新闻", "行业动态", "产品发布", "技术文章"])
|
||||
}
|
||||
|
||||
def generate_product_data(self) -> Dict[str, Any]:
|
||||
"""生成产品数据"""
|
||||
products = [
|
||||
{"name": "睿新ERP管理系统", "category": "企业软件"},
|
||||
{"name": "睿新客户关系管理系统", "category": "企业软件"},
|
||||
{"name": "睿新协同办公平台", "category": "企业软件"},
|
||||
{"name": "睿新商业智能平台", "category": "数据产品"},
|
||||
{"name": "睿新物联网平台", "category": "物联网"},
|
||||
{"name": "睿新AI智能应用套件", "category": "人工智能"}
|
||||
]
|
||||
product = random.choice(products)
|
||||
return {
|
||||
"name": product["name"],
|
||||
"category": product["category"],
|
||||
"description": self.zh_faker.text(max_chars=300),
|
||||
"features": random.sample([
|
||||
"高性能", "高可用", "易扩展", "安全可靠",
|
||||
"智能化", "云原生", "移动优先", "低代码"
|
||||
], k=4),
|
||||
"price": round(random.uniform(1000, 100000), 2)
|
||||
}
|
||||
|
||||
def generate_company_info(self) -> Dict[str, str]:
|
||||
"""生成公司信息"""
|
||||
return {
|
||||
"name": self.zh_faker.company_name(),
|
||||
"short_name": "".join(self.zh_faker.company_name()[:4]),
|
||||
"slogan": self.zh_faker.sentence(nb_words=6),
|
||||
"description": self.zh_faker.text(max_chars=200),
|
||||
"address": self.zh_faker.address(),
|
||||
"phone": self.zh_faker.phone_number(),
|
||||
"email": self.zh_faker.email(domain="example.com"),
|
||||
"website": self.zh_faker.url()
|
||||
}
|
||||
|
||||
def generate_dates(self, count: int = 10) -> List[str]:
|
||||
"""生成日期列表"""
|
||||
dates = []
|
||||
base_date = datetime.now()
|
||||
for i in range(count):
|
||||
date = base_date - timedelta(days=random.randint(0, 365))
|
||||
dates.append(date.strftime("%Y-%m-%d"))
|
||||
return dates
|
||||
|
||||
def generate_unique_id(self) -> str:
|
||||
"""生成唯一ID"""
|
||||
return str(uuid.uuid4())
|
||||
|
||||
def generate_order_number(self) -> str:
|
||||
"""生成订单号"""
|
||||
prefix = "ORD"
|
||||
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
|
||||
random_suffix = "".join(random.choices(string.ascii_uppercase + string.digits, k=6))
|
||||
return f"{prefix}{timestamp}{random_suffix}"
|
||||
|
||||
def generate_numeric_range(
|
||||
self,
|
||||
min_val: int = 1,
|
||||
max_val: int = 100,
|
||||
decimals: int = 0
|
||||
) -> Union[int, float]:
|
||||
"""生成数值范围"""
|
||||
value = random.uniform(min_val, max_val)
|
||||
return round(value, decimals) if decimals else int(value)
|
||||
|
||||
def generate_boolean(self) -> bool:
|
||||
"""生成布尔值"""
|
||||
return random.choice([True, False])
|
||||
|
||||
def generate_choice(self, options: List[Any]) -> Any:
|
||||
"""从列表中随机选择一个"""
|
||||
return random.choice(options)
|
||||
|
||||
def generate_color(self, format: str = "hex") -> Union[str, Tuple[int, int, int]]:
|
||||
"""生成颜色值"""
|
||||
if format == "hex":
|
||||
return self.zh_faker.hex_color()
|
||||
elif format == "rgb":
|
||||
return self.zh_faker.rgb_color()
|
||||
return self.zh_faker.hex_color()
|
||||
|
||||
|
||||
# 全局测试数据生成器实例
|
||||
test_data_generator = TestDataGenerator()
|
||||
|
||||
|
||||
def get_test_data_generator() -> TestDataGenerator:
|
||||
"""获取测试数据生成器"""
|
||||
return test_data_generator
|
||||
@@ -0,0 +1,584 @@
|
||||
"""
|
||||
辅助工具模块
|
||||
提供常用的测试辅助函数和工具
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import hashlib
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple, Union, Callable
|
||||
from urllib.parse import urlparse, parse_qs, urljoin
|
||||
from functools import lru_cache
|
||||
|
||||
from playwright.sync_api import Page, Locator, FrameLocator
|
||||
from playwright.sync_api import Error as PlaywrightError, TimeoutError as PlaywrightTimeoutError
|
||||
|
||||
from config.settings import get_settings
|
||||
from utils.logger import get_logger, PerformanceTimer
|
||||
|
||||
|
||||
class WaitCondition:
|
||||
"""等待条件类"""
|
||||
|
||||
@staticmethod
|
||||
def for_element_visible(selector: str, timeout: Optional[int] = None) -> Callable:
|
||||
"""等待元素可见"""
|
||||
def condition(page: Page) -> bool:
|
||||
try:
|
||||
element = page.locator(selector).first
|
||||
return element.is_visible(timeout=timeout or 1000)
|
||||
except (PlaywrightError, PlaywrightTimeoutError):
|
||||
return False
|
||||
return condition
|
||||
|
||||
@staticmethod
|
||||
def for_element_hidden(selector: str, timeout: Optional[int] = None) -> Callable:
|
||||
"""等待元素隐藏"""
|
||||
def condition(page: Page) -> bool:
|
||||
try:
|
||||
element = page.locator(selector).first
|
||||
return not element.is_visible(timeout=timeout or 1000)
|
||||
except (PlaywrightError, PlaywrightTimeoutError):
|
||||
return True
|
||||
return condition
|
||||
|
||||
@staticmethod
|
||||
def for_element_enabled(selector: str, timeout: Optional[int] = None) -> Callable:
|
||||
"""等待元素可用"""
|
||||
def condition(page: Page) -> bool:
|
||||
try:
|
||||
element = page.locator(selector).first
|
||||
return element.is_enabled(timeout=timeout or 1000)
|
||||
except (PlaywrightError, PlaywrightTimeoutError):
|
||||
return False
|
||||
return condition
|
||||
|
||||
@staticmethod
|
||||
def for_url_change(expected_url: str, timeout: Optional[int] = None) -> Callable:
|
||||
"""等待URL变化"""
|
||||
def condition(page: Page) -> bool:
|
||||
return expected_url in page.url
|
||||
return condition
|
||||
|
||||
@staticmethod
|
||||
def for_load_state(state: str = "networkidle", timeout: Optional[int] = None) -> Callable:
|
||||
"""等待页面加载状态"""
|
||||
def condition(page: Page) -> bool:
|
||||
try:
|
||||
page.wait_for_load_state(state, timeout=timeout)
|
||||
return True
|
||||
except PlaywrightTimeoutError:
|
||||
return False
|
||||
return condition
|
||||
|
||||
@staticmethod
|
||||
def for_function_return_true(func: Callable[[], bool], timeout: int = 5000, interval: int = 100) -> Callable:
|
||||
"""等待函数返回True"""
|
||||
def condition(page: Page) -> bool:
|
||||
start_time = time.time() * 1000
|
||||
while time.time() * 1000 - start_time < timeout:
|
||||
try:
|
||||
result = page.evaluate(f"() => ({func.__code__.co_code})")
|
||||
if result:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(interval / 1000)
|
||||
return False
|
||||
return condition
|
||||
|
||||
|
||||
class ElementHelper:
|
||||
"""元素操作辅助类"""
|
||||
|
||||
def __init__(self, page: Page):
|
||||
self.page = page
|
||||
self.logger = get_logger()
|
||||
|
||||
def find_element(
|
||||
self,
|
||||
selector: str,
|
||||
timeout: Optional[int] = None,
|
||||
state: str = "visible"
|
||||
) -> Locator:
|
||||
"""查找元素"""
|
||||
timeout = timeout or get_settings().element_timeout
|
||||
locator = self.page.locator(selector).first
|
||||
|
||||
try:
|
||||
locator.wait_for(state=state, timeout=timeout)
|
||||
return locator
|
||||
except PlaywrightTimeoutError:
|
||||
raise PlaywrightTimeoutError(
|
||||
f"未找到元素: {selector} (超时: {timeout}ms)"
|
||||
)
|
||||
|
||||
def find_elements(self, selector: str) -> List[Locator]:
|
||||
"""查找多个元素"""
|
||||
return self.page.locator(selector).all()
|
||||
|
||||
def click_element(
|
||||
self,
|
||||
selector: str,
|
||||
timeout: Optional[int] = None,
|
||||
force: bool = False,
|
||||
**kwargs
|
||||
) -> None:
|
||||
"""点击元素"""
|
||||
self.logger.log_action(f"点击元素: {selector}")
|
||||
element = self.find_element(selector, timeout)
|
||||
element.click(force=force, **kwargs)
|
||||
|
||||
def fill_input(
|
||||
self,
|
||||
selector: str,
|
||||
value: str,
|
||||
timeout: Optional[int] = None,
|
||||
clear: bool = True
|
||||
) -> None:
|
||||
"""填充输入框"""
|
||||
self.logger.log_action(f"填充输入框: {selector} = '{value}'")
|
||||
element = self.find_element(selector, timeout)
|
||||
|
||||
if clear:
|
||||
element.clear()
|
||||
|
||||
element.fill(value)
|
||||
|
||||
def type_text(
|
||||
self,
|
||||
selector: str,
|
||||
text: str,
|
||||
timeout: Optional[int] = None,
|
||||
delay: Optional[int] = None
|
||||
) -> None:
|
||||
"""输入文本(逐字符)"""
|
||||
self.logger.log_action(f"输入文本: {selector} = '{text}'")
|
||||
element = self.find_element(selector, timeout)
|
||||
element.type(text, delay=delay)
|
||||
|
||||
def get_element_text(
|
||||
self,
|
||||
selector: str,
|
||||
timeout: Optional[int] = None,
|
||||
strip: bool = True
|
||||
) -> str:
|
||||
"""获取元素文本"""
|
||||
element = self.find_element(selector, timeout)
|
||||
text = element.text_content()
|
||||
return text.strip() if strip and text else text
|
||||
|
||||
def get_element_attribute(
|
||||
self,
|
||||
selector: str,
|
||||
attribute: str,
|
||||
timeout: Optional[int] = None
|
||||
) -> Optional[str]:
|
||||
"""获取元素属性"""
|
||||
element = self.find_element(selector, timeout)
|
||||
return element.get_attribute(attribute)
|
||||
|
||||
def is_element_visible(
|
||||
self,
|
||||
selector: str,
|
||||
timeout: Optional[int] = None
|
||||
) -> bool:
|
||||
"""检查元素是否可见"""
|
||||
try:
|
||||
element = self.find_element(selector, timeout)
|
||||
return element.is_visible()
|
||||
except PlaywrightTimeoutError:
|
||||
return False
|
||||
|
||||
def is_element_enabled(
|
||||
self,
|
||||
selector: str,
|
||||
timeout: Optional[int] = None
|
||||
) -> bool:
|
||||
"""检查元素是否可用"""
|
||||
try:
|
||||
element = self.find_element(selector, timeout)
|
||||
return element.is_enabled()
|
||||
except PlaywrightTimeoutError:
|
||||
return False
|
||||
|
||||
def wait_for_selector(
|
||||
self,
|
||||
selector: str,
|
||||
timeout: Optional[int] = None,
|
||||
state: str = "visible"
|
||||
) -> Locator:
|
||||
"""等待选择器出现"""
|
||||
timeout = timeout or get_settings().element_timeout
|
||||
return self.page.wait_for_selector(selector, timeout=timeout, state=state)
|
||||
|
||||
def wait_for_url(
|
||||
self,
|
||||
pattern: Union[str, re.Pattern],
|
||||
timeout: Optional[int] = None
|
||||
) -> None:
|
||||
"""等待URL匹配模式"""
|
||||
timeout = timeout or get_settings().page_load_timeout
|
||||
self.page.wait_for_url(pattern, timeout=timeout)
|
||||
|
||||
def wait_for_load_state(
|
||||
self,
|
||||
state: str = "networkidle",
|
||||
timeout: Optional[int] = None
|
||||
) -> None:
|
||||
"""等待加载状态"""
|
||||
timeout = timeout or get_settings().page_load_timeout
|
||||
self.page.wait_for_load_state(state, timeout=timeout)
|
||||
|
||||
|
||||
class PageHelper:
|
||||
"""页面操作辅助类"""
|
||||
|
||||
def __init__(self, page: Page):
|
||||
self.page = page
|
||||
self.logger = get_logger()
|
||||
self.element_helper = ElementHelper(page)
|
||||
|
||||
def navigate(
|
||||
self,
|
||||
url: str,
|
||||
wait_until: str = "networkidle",
|
||||
timeout: Optional[int] = None
|
||||
) -> None:
|
||||
"""导航到指定URL"""
|
||||
self.logger.log_action(f"导航到: {url}")
|
||||
timeout = timeout or get_settings().page_load_timeout
|
||||
self.page.goto(url, wait_until=wait_until, timeout=timeout)
|
||||
|
||||
def reload_page(self, wait_until: str = "networkidle") -> None:
|
||||
"""刷新页面"""
|
||||
self.logger.log_action("刷新页面")
|
||||
self.page.reload(wait_until=wait_until)
|
||||
|
||||
def go_back(self, wait_until: str = "networkidle") -> None:
|
||||
"""返回上一页"""
|
||||
self.logger.log_action("返回上一页")
|
||||
self.page.go_back(wait_until=wait_until)
|
||||
|
||||
def go_forward(self, wait_until: str = "networkidle") -> None:
|
||||
"""前进到下一页"""
|
||||
self.logger.log_action("前进到下一页")
|
||||
self.page.go_forward(wait_until=wait_until)
|
||||
|
||||
def scroll_to_top(self) -> None:
|
||||
"""滚动到页面顶部"""
|
||||
self.page.evaluate("window.scrollTo(0, 0)")
|
||||
|
||||
def scroll_to_bottom(self) -> None:
|
||||
"""滚动到页面底部"""
|
||||
self.page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
|
||||
|
||||
def scroll_to_element(self, selector: str) -> None:
|
||||
"""滚动到指定元素"""
|
||||
element = self.page.locator(selector).first
|
||||
element.scroll_into_view_if_needed()
|
||||
|
||||
def get_current_url(self) -> str:
|
||||
"""获取当前URL"""
|
||||
return self.page.url
|
||||
|
||||
def get_page_title(self) -> str:
|
||||
"""获取页面标题"""
|
||||
return self.page.title()
|
||||
|
||||
def get_page_source(self) -> str:
|
||||
"""获取页面源码"""
|
||||
return self.page.content()
|
||||
|
||||
def take_screenshot(
|
||||
self,
|
||||
filename: str,
|
||||
path: Optional[str] = None,
|
||||
full_page: bool = False
|
||||
) -> str:
|
||||
"""截取页面截图"""
|
||||
path = path or get_settings().screenshots_dir
|
||||
Path(path).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
filepath = str(Path(path) / filename)
|
||||
self.page.screenshot(path=filepath, full_page=full_page)
|
||||
|
||||
self.logger.log_action(f"截图已保存: {filepath}")
|
||||
return filepath
|
||||
|
||||
def execute_javascript(self, script: str, *args) -> Any:
|
||||
"""执行JavaScript代码"""
|
||||
return self.page.evaluate(script, *args)
|
||||
|
||||
def wait_for_load_state(self, state: str = "networkidle", timeout: Optional[int] = None) -> None:
|
||||
"""等待页面加载状态"""
|
||||
timeout = timeout or get_settings().page_load_timeout
|
||||
self.page.wait_for_load_state(state, timeout=timeout)
|
||||
|
||||
|
||||
class AssertionHelper:
|
||||
"""断言辅助类"""
|
||||
|
||||
def __init__(self, page: Page):
|
||||
self.page = page
|
||||
self.logger = get_logger()
|
||||
|
||||
def assert_element_visible(
|
||||
self,
|
||||
selector: str,
|
||||
timeout: Optional[int] = None,
|
||||
message: Optional[str] = None
|
||||
) -> None:
|
||||
"""断言元素可见"""
|
||||
try:
|
||||
element = ElementHelper(self.page).find_element(selector, timeout)
|
||||
assert element.is_visible(), message or f"元素不可见: {selector}"
|
||||
self.logger.log_assertion(f"元素可见: {selector}", True)
|
||||
except (PlaywrightError, PlaywrightTimeoutError, AssertionError):
|
||||
self.logger.log_assertion(f"元素可见: {selector}", False)
|
||||
raise
|
||||
|
||||
def assert_element_hidden(
|
||||
self,
|
||||
selector: str,
|
||||
timeout: Optional[int] = None,
|
||||
message: Optional[str] = None
|
||||
) -> None:
|
||||
"""断言元素隐藏"""
|
||||
self.logger.log_assertion(f"元素隐藏: {selector}", True)
|
||||
element = ElementHelper(self.page).find_element(selector, timeout)
|
||||
assert not element.is_visible(), message or f"元素应该隐藏但可见: {selector}"
|
||||
|
||||
def assert_element_text_contains(
|
||||
self,
|
||||
selector: str,
|
||||
expected_text: str,
|
||||
timeout: Optional[int] = None,
|
||||
message: Optional[str] = None
|
||||
) -> None:
|
||||
"""断言元素文本包含预期文本"""
|
||||
self.logger.log_assertion(f"文本包含: {selector} 包含 '{expected_text}'", True)
|
||||
element = ElementHelper(self.page).find_element(selector, timeout)
|
||||
actual_text = element.text_content()
|
||||
assert expected_text in actual_text, message or (
|
||||
f"元素文本不匹配: 预期包含 '{expected_text}',实际为 '{actual_text}'"
|
||||
)
|
||||
|
||||
def assert_element_text_equals(
|
||||
self,
|
||||
selector: str,
|
||||
expected_text: str,
|
||||
timeout: Optional[int] = None,
|
||||
message: Optional[str] = None
|
||||
) -> None:
|
||||
"""断言元素文本等于预期文本"""
|
||||
self.logger.log_assertion(f"文本相等: {selector} == '{expected_text}'", True)
|
||||
element = ElementHelper(self.page).find_element(selector, timeout)
|
||||
actual_text = element.text_content()
|
||||
assert actual_text == expected_text, message or (
|
||||
f"元素文本不匹配: 预期 '{expected_text}',实际为 '{actual_text}'"
|
||||
)
|
||||
|
||||
def assert_url_contains(self, expected_url: str, message: Optional[str] = None) -> None:
|
||||
"""断言URL包含预期文本"""
|
||||
self.logger.log_assertion(f"URL包含: {expected_url}", True)
|
||||
assert expected_url in self.page.url, message or (
|
||||
f"URL不匹配: 预期包含 '{expected_url}',实际为 '{self.page.url}'"
|
||||
)
|
||||
|
||||
def assert_url_equals(self, expected_url: str, message: Optional[str] = None) -> None:
|
||||
"""断言URL等于预期URL"""
|
||||
self.logger.log_assertion(f"URL相等: {expected_url}", True)
|
||||
assert self.page.url == expected_url, message or (
|
||||
f"URL不匹配: 预期 '{expected_url}',实际为 '{self.page.url}'"
|
||||
)
|
||||
|
||||
def assert_page_title_contains(self, expected_title: str, message: Optional[str] = None) -> None:
|
||||
"""断言页面标题包含预期文本"""
|
||||
self.logger.log_assertion(f"标题包含: {expected_title}", True)
|
||||
actual_title = self.page.title()
|
||||
assert expected_title in actual_title, message or (
|
||||
f"页面标题不匹配: 预期包含 '{expected_title}',实际为 '{actual_title}'"
|
||||
)
|
||||
|
||||
def assert_element_count(
|
||||
self,
|
||||
selector: str,
|
||||
expected_count: int,
|
||||
message: Optional[str] = None
|
||||
) -> None:
|
||||
"""断言元素数量"""
|
||||
self.logger.log_assertion(f"元素数量: {selector} == {expected_count}", True)
|
||||
elements = self.page.locator(selector).all()
|
||||
assert len(elements) == expected_count, message or (
|
||||
f"元素数量不匹配: 预期 {expected_count},实际 {len(elements)}"
|
||||
)
|
||||
|
||||
def assert_element_attribute_equals(
|
||||
self,
|
||||
selector: str,
|
||||
attribute: str,
|
||||
expected_value: str,
|
||||
timeout: Optional[int] = None,
|
||||
message: Optional[str] = None
|
||||
) -> None:
|
||||
"""断言元素属性等于预期值"""
|
||||
self.logger.log_assertion(f"属性相等: {selector}.{attribute} == '{expected_value}'", True)
|
||||
element = ElementHelper(self.page).find_element(selector, timeout)
|
||||
actual_value = element.get_attribute(attribute)
|
||||
assert actual_value == expected_value, message or (
|
||||
f"元素属性不匹配: {attribute} 预期 '{expected_value}',实际 '{actual_value}'"
|
||||
)
|
||||
|
||||
|
||||
class UrlHelper:
|
||||
"""URL辅助类"""
|
||||
|
||||
@staticmethod
|
||||
def parse_url(url: str) -> Dict[str, Any]:
|
||||
"""解析URL"""
|
||||
parsed = urlparse(url)
|
||||
return {
|
||||
"scheme": parsed.scheme,
|
||||
"netloc": parsed.netloc,
|
||||
"path": parsed.path,
|
||||
"params": parse_qs(parsed.query),
|
||||
"query": parsed.query,
|
||||
"fragment": parsed.fragment
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def get_domain(url: str) -> str:
|
||||
"""获取URL域名"""
|
||||
parsed = urlparse(url)
|
||||
return parsed.netloc
|
||||
|
||||
@staticmethod
|
||||
def get_path(url: str) -> str:
|
||||
"""获取URL路径"""
|
||||
parsed = urlparse(url)
|
||||
return parsed.path
|
||||
|
||||
@staticmethod
|
||||
def is_absolute_url(url: str) -> bool:
|
||||
"""判断是否为绝对URL"""
|
||||
return bool(urlparse(url).scheme)
|
||||
|
||||
@staticmethod
|
||||
def join_url(base: str, path: str) -> str:
|
||||
"""拼接URL"""
|
||||
return urljoin(base, path)
|
||||
|
||||
@staticmethod
|
||||
def remove_trailing_slash(url: str) -> str:
|
||||
"""移除URL末尾的斜杠"""
|
||||
if url.endswith("/") and len(url) > 1:
|
||||
return url[:-1]
|
||||
return url
|
||||
|
||||
|
||||
class FileHelper:
|
||||
"""文件操作辅助类"""
|
||||
|
||||
@staticmethod
|
||||
def read_json(filepath: str) -> Dict[str, Any]:
|
||||
"""读取JSON文件"""
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
|
||||
@staticmethod
|
||||
def write_json(filepath: str, data: Any, indent: int = 2) -> None:
|
||||
"""写入JSON文件"""
|
||||
Path(filepath).parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(filepath, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=indent)
|
||||
|
||||
@staticmethod
|
||||
def read_text(filepath: str) -> str:
|
||||
"""读取文本文件"""
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
return f.read()
|
||||
|
||||
@staticmethod
|
||||
def write_text(filepath: str, content: str) -> None:
|
||||
"""写入文本文件"""
|
||||
Path(filepath).parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(filepath, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
|
||||
@staticmethod
|
||||
def create_directory(path: str) -> None:
|
||||
"""创建目录"""
|
||||
Path(path).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@staticmethod
|
||||
def delete_directory(path: str) -> None:
|
||||
"""删除目录"""
|
||||
import shutil
|
||||
if Path(path).exists():
|
||||
shutil.rmtree(path)
|
||||
|
||||
@staticmethod
|
||||
def cleanup_directory(path: str, pattern: str = "*") -> None:
|
||||
"""清理目录中的文件"""
|
||||
import glob
|
||||
files = glob.glob(str(Path(path) / pattern))
|
||||
for file in files:
|
||||
try:
|
||||
os.remove(file)
|
||||
except IsADirectoryError:
|
||||
FileHelper.delete_directory(file)
|
||||
|
||||
|
||||
def wait(
|
||||
condition: Callable,
|
||||
timeout: int = 10000,
|
||||
interval: int = 500,
|
||||
message: str = "等待条件超时"
|
||||
) -> bool:
|
||||
"""等待条件满足"""
|
||||
start_time = time.time() * 1000
|
||||
|
||||
while time.time() * 1000 - start_time < timeout:
|
||||
if condition():
|
||||
return True
|
||||
time.sleep(interval / 1000)
|
||||
|
||||
raise TimeoutError(message)
|
||||
|
||||
|
||||
def retry(
|
||||
func: Callable,
|
||||
max_retries: int = 3,
|
||||
delay: int = 1000,
|
||||
exceptions: Tuple[Exception, ...] = (Exception,)
|
||||
) -> Any:
|
||||
"""重试函数"""
|
||||
last_exception = None
|
||||
|
||||
for attempt in range(max_retries + 1):
|
||||
try:
|
||||
return func()
|
||||
except exceptions as e:
|
||||
last_exception = e
|
||||
if attempt < max_retries:
|
||||
time.sleep(delay / 1000)
|
||||
|
||||
raise last_exception
|
||||
|
||||
|
||||
def generate_test_id(prefix: str = "test") -> str:
|
||||
"""生成测试ID"""
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
random_hash = hashlib.md5(f"{timestamp}_{os.getpid()}".encode()).hexdigest()[:8]
|
||||
return f"{prefix}_{timestamp}_{random_hash}"
|
||||
|
||||
|
||||
@lru_cache(maxsize=128)
|
||||
def normalize_text(text: str) -> str:
|
||||
"""标准化文本(去除多余空白)"""
|
||||
return re.sub(r'\s+', ' ', text).strip()
|
||||
@@ -0,0 +1,272 @@
|
||||
"""
|
||||
日志工具模块
|
||||
提供测试过程中的日志记录功能
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional, Union
|
||||
from functools import wraps
|
||||
import traceback
|
||||
|
||||
from config.settings import get_settings
|
||||
|
||||
|
||||
class ColoredFormatter(logging.Formatter):
|
||||
"""彩色日志格式化器"""
|
||||
|
||||
# ANSI颜色代码
|
||||
COLORS = {
|
||||
'DEBUG': '\033[36m', # 青色
|
||||
'INFO': '\033[32m', # 绿色
|
||||
'WARNING': '\033[33m', # 黄色
|
||||
'ERROR': '\033[31m', # 红色
|
||||
'CRITICAL': '\033[35m', # 紫色
|
||||
'RESET': '\033[0m', # 重置
|
||||
}
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
# 获取颜色
|
||||
color = self.COLORS.get(record.levelname, self.COLORS['RESET'])
|
||||
|
||||
# 格式化消息
|
||||
message = super().format(record)
|
||||
|
||||
# 添加颜色(如果不是纯文本输出)
|
||||
if sys.stdout.isatty():
|
||||
return f"{color}{message}{self.COLORS['RESET']}"
|
||||
return message
|
||||
|
||||
|
||||
class TestLogger:
|
||||
"""测试日志管理器"""
|
||||
|
||||
_instance: Optional['TestLogger'] = None
|
||||
_initialized: bool = False
|
||||
|
||||
def __new__(cls) -> 'TestLogger':
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
if not TestLogger._initialized:
|
||||
self._setup_logging()
|
||||
TestLogger._initialized = True
|
||||
|
||||
def _setup_logging(self) -> None:
|
||||
"""设置日志配置"""
|
||||
self.settings = get_settings()
|
||||
self.logger = logging.getLogger("e2e_tests")
|
||||
self.logger.setLevel(getattr(logging, self.settings.log_level))
|
||||
|
||||
# 清除现有处理器
|
||||
self.logger.handlers.clear()
|
||||
|
||||
# 创建日志目录
|
||||
log_dir = Path(self.settings.log_file).parent
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 文件处理器
|
||||
file_handler = logging.FileHandler(
|
||||
self.settings.log_file,
|
||||
encoding='utf-8',
|
||||
mode='a'
|
||||
)
|
||||
file_handler.setLevel(logging.DEBUG)
|
||||
|
||||
# 控制台处理器
|
||||
console_handler = logging.StreamHandler(sys.stdout)
|
||||
console_handler.setLevel(getattr(logging, self.settings.log_level))
|
||||
|
||||
# 设置格式化器
|
||||
file_format = logging.Formatter(
|
||||
'%(asctime)s | %(levelname)-8s | %(name)s | %(filename)s:%(lineno)d | %(message)s',
|
||||
datefmt='%Y-%m-%d %H:%M:%S'
|
||||
)
|
||||
|
||||
console_format_str = '%(asctime)s | %(levelname)-8s | %(message)s'
|
||||
|
||||
file_handler.setFormatter(file_format)
|
||||
|
||||
if sys.stdout.isatty():
|
||||
console_handler.setFormatter(ColoredFormatter(console_format_str, datefmt='%H:%M:%S'))
|
||||
else:
|
||||
console_handler.setFormatter(logging.Formatter(console_format_str, datefmt='%H:%M:%S'))
|
||||
|
||||
self.logger.addHandler(file_handler)
|
||||
self.logger.addHandler(console_handler)
|
||||
|
||||
def debug(self, message: str, **kwargs) -> None:
|
||||
"""记录DEBUG级别日志"""
|
||||
self.logger.debug(self._format_message(message, **kwargs))
|
||||
|
||||
def info(self, message: str, **kwargs) -> None:
|
||||
"""记录INFO级别日志"""
|
||||
self.logger.info(self._format_message(message, **kwargs))
|
||||
|
||||
def warning(self, message: str, **kwargs) -> None:
|
||||
"""记录WARNING级别日志"""
|
||||
self.logger.warning(self._format_message(message, **kwargs))
|
||||
|
||||
def error(self, message: str, exc_info: bool = True, **kwargs) -> None:
|
||||
"""记录ERROR级别日志"""
|
||||
self.logger.error(
|
||||
self._format_message(message, **kwargs),
|
||||
exc_info=exc_info
|
||||
)
|
||||
|
||||
def critical(self, message: str, exc_info: bool = True, **kwargs) -> None:
|
||||
"""记录CRITICAL级别日志"""
|
||||
self.logger.critical(
|
||||
self._format_message(message, **kwargs),
|
||||
exc_info=exc_info
|
||||
)
|
||||
|
||||
def exception(self, message: str, **kwargs) -> None:
|
||||
"""记录异常日志(自动包含堆栈信息)"""
|
||||
self.error(message, exc_info=True, **kwargs)
|
||||
|
||||
def _format_message(self, message: str, **kwargs) -> str:
|
||||
"""格式化日志消息"""
|
||||
if kwargs:
|
||||
extra_info = " | ".join(f"{k}={v}" for k, v in kwargs.items())
|
||||
return f"{message} | {extra_info}"
|
||||
return message
|
||||
|
||||
def log_test_start(self, test_name: str, **extra_info) -> None:
|
||||
"""记录测试开始"""
|
||||
self.info(f"🧪 测试开始: {test_name}", **extra_info)
|
||||
|
||||
def log_test_end(self, test_name: str, status: str, duration: float, **extra_info) -> None:
|
||||
"""记录测试结束"""
|
||||
emoji = "✅" if status == "PASSED" else "❌" if status == "FAILED" else "⏭️"
|
||||
self.info(f"{emoji} 测试结束: {test_name} | 状态: {status} | 耗时: {duration:.2f}s", **extra_info)
|
||||
|
||||
def log_step(self, step_name: str, **extra_info) -> None:
|
||||
"""记录测试步骤"""
|
||||
self.info(f"📋 步骤: {step_name}", **extra_info)
|
||||
|
||||
def log_action(self, action: str, **extra_info) -> None:
|
||||
"""记录用户操作"""
|
||||
self.info(f"👆 操作: {action}", **extra_info)
|
||||
|
||||
def log_assertion(self, assertion: str, result: bool, **extra_info) -> None:
|
||||
"""记录断言结果"""
|
||||
status = "✅ 通过" if result else "❌ 失败"
|
||||
self.info(f"🔍 断言: {assertion} | {status}", **extra_info)
|
||||
|
||||
def log_performance(self, metric: str, value: float, threshold: Optional[float] = None, **extra_info) -> None:
|
||||
"""记录性能指标"""
|
||||
if threshold and value > threshold:
|
||||
self.warning(f"📊 性能指标 - {metric}: {value:.2f}ms (阈值: {threshold:.2f}ms)", **extra_info)
|
||||
else:
|
||||
self.info(f"📊 性能指标 - {metric}: {value:.2f}ms", **extra_info)
|
||||
|
||||
def log_error_context(self, context: str, error: Exception, **extra_info) -> None:
|
||||
"""记录错误上下文"""
|
||||
self.error(f"🚨 错误上下文: {context}", exc_info=False, **extra_info)
|
||||
self.error(f"错误信息: {str(error)}", exc_info=False)
|
||||
self.debug(f"堆栈跟踪:\n{traceback.format_exc()}")
|
||||
|
||||
def section(self, title: str) -> None:
|
||||
"""记录分段标题"""
|
||||
separator = "=" * 60
|
||||
self.info(f"\n{separator}")
|
||||
self.info(f" {title}")
|
||||
self.info(f"{separator}\n")
|
||||
|
||||
def divider(self, char: str = "-", length: int = 40) -> None:
|
||||
"""记录分隔线"""
|
||||
self.info(char * length)
|
||||
|
||||
|
||||
def get_logger() -> TestLogger:
|
||||
"""获取日志管理器实例"""
|
||||
return TestLogger()
|
||||
|
||||
|
||||
def log_decorator(func):
|
||||
"""函数日志装饰器"""
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
logger = get_logger()
|
||||
func_name = func.__name__
|
||||
|
||||
logger.log_test_start(func_name)
|
||||
logger.divider()
|
||||
|
||||
try:
|
||||
result = func(*args, **kwargs)
|
||||
logger.log_test_end(func_name, "PASSED", 0)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.log_test_end(func_name, "FAILED", 0)
|
||||
logger.log_error_context(func_name, e)
|
||||
raise
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
class PerformanceTimer:
|
||||
"""性能计时器"""
|
||||
|
||||
def __init__(self, logger: Optional[TestLogger] = None):
|
||||
self.logger = logger or get_logger()
|
||||
self.start_time: Optional[float] = None
|
||||
self.end_time: Optional[float] = None
|
||||
self.elapsed: Optional[float] = None
|
||||
|
||||
def start(self) -> 'PerformanceTimer':
|
||||
"""开始计时"""
|
||||
self.start_time = self._time_ms()
|
||||
return self
|
||||
|
||||
def stop(self) -> 'PerformanceTimer':
|
||||
"""停止计时"""
|
||||
self.end_time = self._time_ms()
|
||||
self.elapsed = self.end_time - self.start_time
|
||||
return self
|
||||
|
||||
def reset(self) -> 'PerformanceTimer':
|
||||
"""重置计时器"""
|
||||
self.start_time = None
|
||||
self.end_time = None
|
||||
self.elapsed = None
|
||||
return self
|
||||
|
||||
@staticmethod
|
||||
def _time_ms() -> float:
|
||||
"""获取当前时间(毫秒)"""
|
||||
import time
|
||||
return time.time() * 1000
|
||||
|
||||
@property
|
||||
def seconds(self) -> float:
|
||||
"""获取经过时间(秒)"""
|
||||
return self.elapsed / 1000 if self.elapsed else 0
|
||||
|
||||
@property
|
||||
def milliseconds(self) -> float:
|
||||
"""获取经过时间(毫秒)"""
|
||||
return self.elapsed if self.elapsed else 0
|
||||
|
||||
def log(self, operation: str, threshold: Optional[float] = None) -> None:
|
||||
"""记录操作耗时"""
|
||||
self.logger.log_performance(
|
||||
operation,
|
||||
self.milliseconds,
|
||||
threshold
|
||||
)
|
||||
|
||||
def __enter__(self) -> 'PerformanceTimer':
|
||||
"""上下文管理器入口"""
|
||||
self.start()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
||||
"""上下文管理器出口"""
|
||||
self.stop()
|
||||
@@ -0,0 +1,606 @@
|
||||
"""
|
||||
测试报告生成模块
|
||||
提供HTML、JSON、Markdown等多种格式的测试报告生成功能
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from html import escape
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||
from collections import defaultdict
|
||||
import base64
|
||||
from io import BytesIO
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
import matplotlib
|
||||
matplotlib.use('Agg') # 使用非GUI后端
|
||||
import matplotlib.pyplot as plt
|
||||
import numpy as np
|
||||
|
||||
from config.settings import get_settings
|
||||
|
||||
|
||||
class TestStatus(Enum):
|
||||
"""测试状态枚举"""
|
||||
PASSED = "passed"
|
||||
FAILED = "failed"
|
||||
SKIPPED = "skipped"
|
||||
ERROR = "error"
|
||||
XFAIL = "xfail"
|
||||
XPASS = "xpass"
|
||||
|
||||
|
||||
@dataclass
|
||||
class TestResult:
|
||||
"""单个测试结果"""
|
||||
test_id: str
|
||||
test_name: str
|
||||
test_file: str
|
||||
test_class: str
|
||||
status: TestStatus
|
||||
start_time: datetime
|
||||
end_time: datetime
|
||||
duration: float
|
||||
error_message: Optional[str] = None
|
||||
error_traceback: Optional[str] = None
|
||||
screenshot_path: Optional[str] = None
|
||||
logs: List[str] = field(default_factory=list)
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
parameters: Dict[str, Any] = field(default_factory=dict)
|
||||
browser: Optional[str] = None
|
||||
viewport: Optional[Tuple[int, int]] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""转换为字典"""
|
||||
data = asdict(self)
|
||||
data["status"] = self.status.value
|
||||
data["start_time"] = self.start_time.isoformat()
|
||||
data["end_time"] = self.end_time.isoformat()
|
||||
return data
|
||||
|
||||
@property
|
||||
def passed(self) -> bool:
|
||||
"""是否通过"""
|
||||
return self.status == TestStatus.PASSED
|
||||
|
||||
@property
|
||||
def failed(self) -> bool:
|
||||
"""是否失败"""
|
||||
return self.status in [TestStatus.FAILED, TestStatus.ERROR]
|
||||
|
||||
|
||||
@dataclass
|
||||
class TestSuiteResult:
|
||||
"""测试套件结果"""
|
||||
suite_name: str
|
||||
test_count: int = 0
|
||||
passed_count: int = 0
|
||||
failed_count: int = 0
|
||||
skipped_count: int = 0
|
||||
error_count: int = 0
|
||||
duration: float = 0.0
|
||||
test_results: List[TestResult] = field(default_factory=list)
|
||||
start_time: Optional[datetime] = None
|
||||
end_time: Optional[datetime] = None
|
||||
|
||||
@property
|
||||
def pass_rate(self) -> float:
|
||||
"""通过率"""
|
||||
if self.test_count == 0:
|
||||
return 0.0
|
||||
return (self.passed_count / self.test_count) * 100
|
||||
|
||||
@property
|
||||
def success(self) -> bool:
|
||||
"""是否全部通过"""
|
||||
return self.failed_count == 0 and self.error_count == 0
|
||||
|
||||
|
||||
class ReportGenerator(ABC):
|
||||
"""报告生成器抽象基类"""
|
||||
|
||||
@abstractmethod
|
||||
def generate(self, suite_results: List[TestSuiteResult], output_path: str) -> str:
|
||||
"""生成报告"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_format(self) -> str:
|
||||
"""获取报告格式"""
|
||||
pass
|
||||
|
||||
|
||||
class HTMLReportGenerator(ReportGenerator):
|
||||
"""HTML报告生成器"""
|
||||
|
||||
def __init__(self):
|
||||
self.settings = get_settings()
|
||||
self.env = Environment(
|
||||
loader=FileSystemLoader(Path(__file__).parent / "templates"),
|
||||
autoescape=True
|
||||
)
|
||||
self.template = self.env.get_template("html_report.html")
|
||||
|
||||
def get_format(self) -> str:
|
||||
return "html"
|
||||
|
||||
def generate(
|
||||
self,
|
||||
suite_results: List[TestSuiteResult],
|
||||
output_path: str
|
||||
) -> str:
|
||||
"""生成HTML报告"""
|
||||
# 汇总所有测试结果
|
||||
all_results = []
|
||||
for suite in suite_results:
|
||||
all_results.extend(suite.test_results)
|
||||
|
||||
# 计算统计信息
|
||||
stats = self._calculate_stats(suite_results, all_results)
|
||||
|
||||
# 生成图表
|
||||
charts = self._generate_charts(suite_results, all_results)
|
||||
|
||||
# 准备模板数据
|
||||
context = {
|
||||
"title": self.settings.report_title,
|
||||
"description": self.settings.report_description,
|
||||
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"stats": stats,
|
||||
"charts": charts,
|
||||
"suites": suite_results,
|
||||
"all_results": all_results,
|
||||
"git_info": self._get_git_info(),
|
||||
"settings": self.settings
|
||||
}
|
||||
|
||||
# 渲染模板
|
||||
html_content = self.template.render(**context)
|
||||
|
||||
# 确保输出目录存在
|
||||
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 写入文件
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
f.write(html_content)
|
||||
|
||||
return output_path
|
||||
|
||||
def _calculate_stats(
|
||||
self,
|
||||
suite_results: List[TestSuiteResult],
|
||||
all_results: List[TestResult]
|
||||
) -> Dict[str, Any]:
|
||||
"""计算统计信息"""
|
||||
total = len(all_results)
|
||||
passed = sum(1 for r in all_results if r.passed)
|
||||
failed = sum(1 for r in all_results if r.failed)
|
||||
skipped = sum(1 for r in all_results if r.status == TestStatus.SKIPPED)
|
||||
|
||||
total_duration = sum(r.duration for r in all_results)
|
||||
|
||||
# 按状态分组
|
||||
by_status = defaultdict(list)
|
||||
for result in all_results:
|
||||
by_status[result.status.value].append(result)
|
||||
|
||||
# 按浏览器分组
|
||||
by_browser = defaultdict(list)
|
||||
for result in all_results:
|
||||
if result.browser:
|
||||
by_browser[result.browser].append(result)
|
||||
|
||||
# 按文件分组
|
||||
by_file = defaultdict(list)
|
||||
for result in all_results:
|
||||
by_file[result.test_file].append(result)
|
||||
|
||||
# 失败和错误的测试
|
||||
failed_tests = [r for r in all_results if r.failed]
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"passed": passed,
|
||||
"failed": failed,
|
||||
"skipped": skipped,
|
||||
"pass_rate": round((passed / total * 100) if total > 0 else 0, 2),
|
||||
"total_duration": round(total_duration, 2),
|
||||
"average_duration": round(total_duration / total, 2) if total > 0 else 0,
|
||||
"by_status": dict(by_status),
|
||||
"by_browser": dict(by_browser),
|
||||
"by_file": dict(by_file),
|
||||
"failed_tests": failed_tests,
|
||||
"suite_count": len(suite_results),
|
||||
"success": failed == 0
|
||||
}
|
||||
|
||||
def _generate_charts(
|
||||
self,
|
||||
suite_results: List[TestSuiteResult],
|
||||
all_results: List[TestResult]
|
||||
) -> Dict[str, str]:
|
||||
"""生成图表"""
|
||||
charts = {}
|
||||
|
||||
# 1. 测试状态饼图
|
||||
charts["status_pie"] = self._create_status_pie_chart(all_results)
|
||||
|
||||
# 2. 套件结果条形图
|
||||
charts["suite_results"] = self._create_suite_results_chart(suite_results)
|
||||
|
||||
# 3. 执行时间图表
|
||||
charts["duration"] = self._create_duration_chart(all_results)
|
||||
|
||||
# 4. 浏览器分布图
|
||||
browsers = defaultdict(int)
|
||||
for result in all_results:
|
||||
if result.browser:
|
||||
browsers[result.browser] += 1
|
||||
if browsers:
|
||||
charts["browser_distribution"] = self._create_browser_chart(dict(browsers))
|
||||
|
||||
return charts
|
||||
|
||||
def _create_status_pie_chart(self, results: List[TestResult]) -> str:
|
||||
"""创建状态饼图"""
|
||||
counts = defaultdict(int)
|
||||
for r in results:
|
||||
counts[r.status.value] += 1
|
||||
|
||||
labels = []
|
||||
sizes = []
|
||||
colors = []
|
||||
color_map = {
|
||||
"passed": "#22c55e",
|
||||
"failed": "#ef4444",
|
||||
"skipped": "#94a3b8",
|
||||
"error": "#f97316",
|
||||
"xfail": "#eab308",
|
||||
"xpass": "#3b82f6"
|
||||
}
|
||||
|
||||
for status, count in counts.items():
|
||||
labels.append(f"{status} ({count})")
|
||||
sizes.append(count)
|
||||
colors.append(color_map.get(status, "#6b7280"))
|
||||
|
||||
if not sizes:
|
||||
return ""
|
||||
|
||||
fig, ax = plt.subplots(figsize=(8, 8))
|
||||
ax.pie(sizes, labels=labels, colors=colors, autopct="%1.1f%%",
|
||||
startangle=90, textprops={"fontsize": 12})
|
||||
ax.axis("equal")
|
||||
|
||||
return self._fig_to_base64(fig)
|
||||
|
||||
def _create_suite_results_chart(self, suites: List[TestSuiteResult]) -> str:
|
||||
"""创建套件结果图表"""
|
||||
names = [s.suite_name for s in suites]
|
||||
passed = [s.passed_count for s in suites]
|
||||
failed = [s.failed_count for s in suites]
|
||||
|
||||
fig, ax = plt.subplots(figsize=(12, 6))
|
||||
x = np.arange(len(names))
|
||||
width = 0.35
|
||||
|
||||
bars1 = ax.bar(x - width/2, passed, width, label="Passed", color="#22c55e")
|
||||
bars2 = ax.bar(x + width/2, failed, width, label="Failed", color="#ef4444")
|
||||
|
||||
ax.set_xlabel("Test Suite")
|
||||
ax.set_ylabel("Test Count")
|
||||
ax.set_title("Test Results by Suite")
|
||||
ax.set_xticks(x)
|
||||
ax.set_xticklabels(names, rotation=45, ha="right")
|
||||
ax.legend()
|
||||
|
||||
# 添加数值标签
|
||||
for bar in bars1:
|
||||
height = bar.get_height()
|
||||
ax.annotate(f"{int(height)}",
|
||||
xy=(bar.get_x() + bar.get_width() / 2, height),
|
||||
xytext=(0, 3), textcoords="offset points",
|
||||
ha="center", va="bottom", fontsize=8)
|
||||
|
||||
for bar in bars2:
|
||||
height = bar.get_height()
|
||||
if height > 0:
|
||||
ax.annotate(f"{int(height)}",
|
||||
xy=(bar.get_x() + bar.get_width() / 2, height),
|
||||
xytext=(0, 3), textcoords="offset points",
|
||||
ha="center", va="bottom", fontsize=8)
|
||||
|
||||
return self._fig_to_base64(fig)
|
||||
|
||||
def _create_duration_chart(self, results: List[TestResult]) -> str:
|
||||
"""创建执行时间图表"""
|
||||
# 获取前20个最耗时的测试
|
||||
sorted_results = sorted(results, key=lambda x: x.duration, reverse=True)[:20]
|
||||
|
||||
names = [r.test_name[:30] + "..." if len(r.test_name) > 30 else r.test_name
|
||||
for r in sorted_results]
|
||||
durations = [r.duration for r in sorted_results]
|
||||
|
||||
fig, ax = plt.subplots(figsize=(12, 8))
|
||||
bars = ax.barh(names, durations, color="#3b82f6")
|
||||
|
||||
ax.set_xlabel("Duration (seconds)")
|
||||
ax.set_title("Top 20 Slowest Tests")
|
||||
ax.invert_yaxis()
|
||||
|
||||
# 添加数值标签
|
||||
for bar, duration in zip(bars, durations):
|
||||
ax.annotate(f"{duration:.2f}s",
|
||||
xy=(duration, bar.get_y() + bar.get_height() / 2),
|
||||
xytext=(3, 0), textcoords="offset points",
|
||||
ha="left", va="center", fontsize=8)
|
||||
|
||||
return self._fig_to_base64(fig)
|
||||
|
||||
def _create_browser_chart(self, browsers: Dict[str, int]) -> str:
|
||||
"""创建浏览器分布图"""
|
||||
labels = list(browsers.keys())
|
||||
sizes = list(browsers.values())
|
||||
colors = ["#4285f4", "#ea4335", "#fbbc05", "#34a853"]
|
||||
|
||||
fig, ax = plt.subplots(figsize=(8, 8))
|
||||
ax.pie(sizes, labels=labels, colors=colors[:len(labels)],
|
||||
autopct="%1.1f%%", startangle=90, textprops={"fontsize": 12})
|
||||
ax.axis("equal")
|
||||
|
||||
return self._fig_to_base64(fig)
|
||||
|
||||
def _fig_to_base64(self, fig) -> str:
|
||||
"""将matplotlib图表转换为base64字符串"""
|
||||
buffer = BytesIO()
|
||||
fig.savefig(buffer, format="png", dpi=100, bbox_inches="tight")
|
||||
buffer.seek(0)
|
||||
img_str = base64.b64encode(buffer.read()).decode("utf-8")
|
||||
plt.close(fig)
|
||||
return f"data:image/png;base64,{img_str}"
|
||||
|
||||
def _get_git_info(self) -> Dict[str, str]:
|
||||
"""获取Git信息"""
|
||||
git_info = {
|
||||
"branch": self.settings.git_branch,
|
||||
"commit": self.settings.git_commit,
|
||||
"repository": self.settings.git_repository
|
||||
}
|
||||
|
||||
# 尝试从环境变量获取
|
||||
if not git_info["branch"]:
|
||||
git_info["branch"] = os.environ.get("GIT_BRANCH", "")
|
||||
if not git_info["commit"]:
|
||||
git_info["commit"] = os.environ.get("GIT_COMMIT", os.environ.get("GITHUB_SHA", ""))
|
||||
|
||||
return git_info
|
||||
|
||||
|
||||
class JSONReportGenerator(ReportGenerator):
|
||||
"""JSON报告生成器"""
|
||||
|
||||
def get_format(self) -> str:
|
||||
return "json"
|
||||
|
||||
def generate(
|
||||
self,
|
||||
suite_results: List[TestSuiteResult],
|
||||
output_path: str
|
||||
) -> str:
|
||||
"""生成JSON报告"""
|
||||
report = {
|
||||
"report_info": {
|
||||
"title": get_settings().report_title,
|
||||
"description": get_settings().report_description,
|
||||
"generated_at": datetime.now().isoformat(),
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"summary": self._calculate_summary(suite_results),
|
||||
"suites": []
|
||||
}
|
||||
|
||||
for suite in suite_results:
|
||||
suite_data = {
|
||||
"name": suite.suite_name,
|
||||
"test_count": suite.test_count,
|
||||
"passed": suite.passed_count,
|
||||
"failed": suite.failed_count,
|
||||
"skipped": suite.skipped_count,
|
||||
"duration": suite.duration,
|
||||
"pass_rate": suite.pass_rate,
|
||||
"success": suite.success,
|
||||
"tests": [r.to_dict() for r in suite.test_results]
|
||||
}
|
||||
report["suites"].append(suite_data)
|
||||
|
||||
# 确保输出目录存在
|
||||
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 写入文件
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
json.dump(report, f, ensure_ascii=False, indent=2)
|
||||
|
||||
return output_path
|
||||
|
||||
def _calculate_summary(self, suites: List[TestSuiteResult]) -> Dict[str, Any]:
|
||||
"""计算汇总信息"""
|
||||
all_results = []
|
||||
for suite in suites:
|
||||
all_results.extend(suite.test_results)
|
||||
|
||||
return {
|
||||
"total": len(all_results),
|
||||
"passed": sum(1 for r in all_results if r.passed),
|
||||
"failed": sum(1 for r in all_results if r.failed),
|
||||
"skipped": sum(1 for r in all_results if r.status == TestStatus.SKIPPED),
|
||||
"duration": sum(r.duration for r in all_results)
|
||||
}
|
||||
|
||||
|
||||
class MarkdownReportGenerator(ReportGenerator):
|
||||
"""Markdown报告生成器"""
|
||||
|
||||
def get_format(self) -> str:
|
||||
return "markdown"
|
||||
|
||||
def generate(
|
||||
self,
|
||||
suite_results: List[TestSuiteResult],
|
||||
output_path: str
|
||||
) -> str:
|
||||
"""生成Markdown报告"""
|
||||
lines = [
|
||||
f"# {get_settings().report_title}",
|
||||
"",
|
||||
f"**生成时间**: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
|
||||
"",
|
||||
"## 📊 测试汇总",
|
||||
""
|
||||
]
|
||||
|
||||
# 汇总信息
|
||||
all_results = []
|
||||
for suite in suite_results:
|
||||
all_results.extend(suite.test_results)
|
||||
|
||||
total = len(all_results)
|
||||
passed = sum(1 for r in all_results if r.passed)
|
||||
failed = sum(1 for r in all_results if r.failed)
|
||||
|
||||
lines.extend([
|
||||
f"- **总计**: {total} 个测试",
|
||||
f"- **通过**: {passed} 个 ✅",
|
||||
f"- **失败**: {failed} 个 ❌",
|
||||
f"- **通过率**: {round(passed / total * 100, 2) if total > 0 else 0}%",
|
||||
""
|
||||
])
|
||||
|
||||
# 套件详情
|
||||
lines.append("## 📁 套件详情")
|
||||
lines.append("")
|
||||
|
||||
for suite in suite_results:
|
||||
lines.append(f"### {suite.suite_name}")
|
||||
lines.append("")
|
||||
lines.append(f"- 测试数: {suite.test_count}")
|
||||
lines.append(f"- 通过: {suite.passed_count}")
|
||||
lines.append(f"- 失败: {suite.failed_count}")
|
||||
lines.append(f"- 耗时: {suite.duration:.2f}s")
|
||||
lines.append(f"- 通过率: {suite.pass_rate:.2f}%")
|
||||
lines.append("")
|
||||
|
||||
# 失败测试详情
|
||||
failed_tests = [r for r in all_results if r.failed]
|
||||
if failed_tests:
|
||||
lines.append("## ❌ 失败测试")
|
||||
lines.append("")
|
||||
|
||||
for result in failed_tests:
|
||||
lines.append(f"### {result.test_name}")
|
||||
lines.append("")
|
||||
lines.append(f"- **文件**: {result.test_file}")
|
||||
lines.append(f"- **状态**: {result.status.value}")
|
||||
lines.append(f"- **耗时**: {result.duration:.2f}s")
|
||||
if result.error_message:
|
||||
lines.append(f"- **错误**: {result.error_message}")
|
||||
lines.append("")
|
||||
|
||||
content = "\n".join(lines)
|
||||
|
||||
# 确保输出目录存在
|
||||
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 写入文件
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
|
||||
return output_path
|
||||
|
||||
|
||||
class ReportManager:
|
||||
"""报告管理器"""
|
||||
|
||||
def __init__(self):
|
||||
self.settings = get_settings()
|
||||
self.generators = {
|
||||
"html": HTMLReportGenerator(),
|
||||
"json": JSONReportGenerator(),
|
||||
"markdown": MarkdownReportGenerator()
|
||||
}
|
||||
self.current_results: List[TestSuiteResult] = []
|
||||
|
||||
def add_result(self, result: TestResult) -> None:
|
||||
"""添加测试结果"""
|
||||
# 查找或创建对应的套件
|
||||
suite_name = result.test_class or "default"
|
||||
suite = next(
|
||||
(s for s in self.current_results if s.suite_name == suite_name),
|
||||
None
|
||||
)
|
||||
|
||||
if suite is None:
|
||||
suite = TestSuiteResult(suite_name=suite_name)
|
||||
self.current_results.append(suite)
|
||||
|
||||
suite.test_results.append(result)
|
||||
suite.test_count += 1
|
||||
|
||||
if result.passed:
|
||||
suite.passed_count += 1
|
||||
elif result.status == TestStatus.FAILED:
|
||||
suite.failed_count += 1
|
||||
elif result.status == TestStatus.SKIPPED:
|
||||
suite.skipped_count += 1
|
||||
else:
|
||||
suite.error_count += 1
|
||||
|
||||
suite.duration += result.duration
|
||||
|
||||
def generate_reports(self, output_dir: Optional[str] = None) -> Dict[str, str]:
|
||||
"""生成所有格式的报告"""
|
||||
output_dir = output_dir or str(self.settings.get_reports_path())
|
||||
output_dir = Path(output_dir)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
generated = {}
|
||||
|
||||
for format_name, generator in self.generators.items():
|
||||
output_path = output_dir / f"test_report.{format_name}"
|
||||
generator.generate(self.current_results, str(output_path))
|
||||
generated[format_name] = str(output_path)
|
||||
|
||||
return generated
|
||||
|
||||
def get_summary(self) -> Dict[str, Any]:
|
||||
"""获取汇总信息"""
|
||||
all_results = []
|
||||
for suite in self.current_results:
|
||||
all_results.extend(suite.test_results)
|
||||
|
||||
total = len(all_results)
|
||||
passed = sum(1 for r in all_results if r.passed)
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"passed": passed,
|
||||
"failed": total - passed,
|
||||
"pass_rate": round(passed / total * 100, 2) if total > 0 else 0,
|
||||
"suites": len(self.current_results),
|
||||
"duration": sum(s.duration for s in self.current_results)
|
||||
}
|
||||
|
||||
def clear_results(self) -> None:
|
||||
"""清空当前结果"""
|
||||
self.current_results.clear()
|
||||
|
||||
|
||||
def get_report_manager() -> ReportManager:
|
||||
"""获取报告管理器"""
|
||||
return ReportManager()
|
||||
Reference in New Issue
Block a user