refactor(core/utils): 重构装饰器架构与日志追踪逻辑
- 优化 将 trace_step 移至 decorators.py,并引入 ContextVar 实现日志层级缩进。 - 新增 完善 StepTracer 和 action_screenshot 的 Docstrings,明确参数含义。 - 移除 清理了 logger.py - 优化 main.py 中重复的目录创建逻辑及旧版冗余注释。 - 规范 修正函数命名,提升代码在 BasePage 方案下的复用性。
This commit is contained in:
29
conftest.py
29
conftest.py
@@ -16,14 +16,22 @@ import allure
|
|||||||
import pytest
|
import pytest
|
||||||
from core.run_appium import start_appium_service, stop_appium_service
|
from core.run_appium import start_appium_service, stop_appium_service
|
||||||
from core.driver import CoreDriver
|
from core.driver import CoreDriver
|
||||||
from core.settings import ANDROID_CAPS
|
from utils.dirs_manager import ensure_dirs_ok
|
||||||
|
|
||||||
|
|
||||||
|
def pytest_configure(config):
|
||||||
|
"""
|
||||||
|
Pytest 钩子函数:在测试收集开始前执行
|
||||||
|
"""
|
||||||
|
# ensure_dirs_ok()
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
def app_server():
|
def app_server():
|
||||||
"""
|
"""
|
||||||
第一层:管理 Appium Server 进程。
|
第一层:管理 Appium Server 进程。
|
||||||
利用你原本 start_appium_service 里的 40 次轮询和 sys.exit(1) 逻辑。
|
利用start_appium_service 里的 40 次轮询和 sys.exit(1) 逻辑。
|
||||||
"""
|
"""
|
||||||
# 启动服务
|
# 启动服务
|
||||||
service = start_appium_service()
|
service = start_appium_service()
|
||||||
@@ -67,15 +75,17 @@ def pytest_exception_interact(node, call, report):
|
|||||||
我们在这里手动把错误信息喂给 logging。
|
我们在这里手动把错误信息喂给 logging。
|
||||||
"""
|
"""
|
||||||
# 获取名为 'Error' 的记录器,它会遵循 pytest.ini 中的 log_file 配置
|
# 获取名为 'Error' 的记录器,它会遵循 pytest.ini 中的 log_file 配置
|
||||||
logger = logging.getLogger("Error")
|
logger = logging.getLogger("pytest")
|
||||||
|
|
||||||
if report.failed:
|
if report.failed:
|
||||||
# 获取详细的错误堆栈(包含 assert 的对比信息)
|
# 获取详细的错误堆栈(包含 assert 的对比信息)
|
||||||
exc_info = call.excinfo.getrepr(style='no-locals')
|
# long,short,no-locals
|
||||||
name = f"异常截图_{secrets.token_hex(8)}"
|
exc_info = call.excinfo.getrepr(style='short')
|
||||||
|
screenshot_name = f"异常截图_{secrets.token_hex(4)}"
|
||||||
|
|
||||||
logger.error(f"TEST FAILED: {node.nodeid}")
|
logger.error("\n" + "=" * 40 + " TEST FAILED " + "=" * 40)
|
||||||
logger.error(f"截图名称: {name}")
|
logger.error(f"Node ID: {node.nodeid}")
|
||||||
|
logger.error(f"截图名称: {screenshot_name}")
|
||||||
logger.error(f"详细错误信息如下:\n{exc_info}")
|
logger.error(f"详细错误信息如下:\n{exc_info}")
|
||||||
|
|
||||||
# 3. 自动截图:尝试从 fixture 中获取 driver
|
# 3. 自动截图:尝试从 fixture 中获取 driver
|
||||||
@@ -83,10 +93,13 @@ def pytest_exception_interact(node, call, report):
|
|||||||
driver = node.funcargs.get("driver")
|
driver = node.funcargs.get("driver")
|
||||||
if driver:
|
if driver:
|
||||||
try:
|
try:
|
||||||
|
# 同时保存到 Allure 和本地(如果你需要本地有一份备份)
|
||||||
|
screenshot_data = driver.get_screenshot_as_png()
|
||||||
allure.attach(
|
allure.attach(
|
||||||
driver.get_screenshot_as_png(),
|
screenshot_data,
|
||||||
name=name,
|
name=name,
|
||||||
attachment_type=allure.attachment_type.PNG
|
attachment_type=allure.attachment_type.PNG
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"执行异常截图失败: {e}")
|
logger.error(f"执行异常截图失败: {e}")
|
||||||
|
logger.error("=" * 93 + "\n")
|
||||||
|
|||||||
@@ -9,35 +9,37 @@
|
|||||||
@date: 2026/1/19 16:54
|
@date: 2026/1/19 16:54
|
||||||
@desc:
|
@desc:
|
||||||
"""
|
"""
|
||||||
import os
|
|
||||||
# core/settings.py
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# 项目根目录 (core 的上一级)
|
# 项目根目录 (core 的上一级)
|
||||||
# BASE_DIR = Path(__file__).parent.parent
|
# BASE_DIR = Path(__file__).parent.parent
|
||||||
BASE_DIR = Path(__file__).resolve().parents[1] # 获取根路径(绝对路径)
|
BASE_DIR = Path(__file__).resolve().parents[1] # 获取根路径(绝对路径)
|
||||||
# print(BASE_DIR)
|
|
||||||
# --- 目录配置 ---
|
# --- 目录配置 ---
|
||||||
OUTPUT_DIR = BASE_DIR / "outputs"
|
OUTPUT_DIR = BASE_DIR / "outputs"
|
||||||
|
SCREENSHOT_DIR = OUTPUT_DIR / "screenshots"
|
||||||
LOG_DIR = OUTPUT_DIR / "logs"
|
LOG_DIR = OUTPUT_DIR / "logs"
|
||||||
LOG_BACKUP_DIR = LOG_DIR / "backups"
|
LOG_BACKUP_DIR = LOG_DIR / "backups"
|
||||||
ALLURE_TEMP = BASE_DIR / "temp"
|
ALLURE_TEMP = BASE_DIR / "temp"
|
||||||
REPORT_DIR = BASE_DIR / "report"
|
REPORT_DIR = BASE_DIR / "report"
|
||||||
SCREENSHOT_DIR = OUTPUT_DIR / "screenshots"
|
|
||||||
# 确保必要的目录存在
|
|
||||||
for folder in [LOG_DIR, LOG_BACKUP_DIR, ALLURE_TEMP, SCREENSHOT_DIR]:
|
# 需要初始化的目录列表 (将在入口文件(mani.py)或 conftest.py 中被调用创建)
|
||||||
folder.mkdir(parents=True, exist_ok=True)
|
REQUIRED_DIRS = [LOG_DIR, LOG_BACKUP_DIR, ALLURE_TEMP, SCREENSHOT_DIR]
|
||||||
|
|
||||||
# --- 文件路径 ---
|
# --- 文件路径 ---
|
||||||
LOG_SOURCE = LOG_DIR / "pytest.log"
|
LOG_SOURCE = LOG_DIR / "pytest.log"
|
||||||
|
|
||||||
# --- 启动 Appium 最大尝试次数 ---
|
# --- 启动 Appium 最大尝试次数 ---
|
||||||
MAX_RETRIES = 40
|
MAX_RETRIES = 40
|
||||||
|
|
||||||
# --- 业务常量 (可选) ---
|
# --- 业务常量 (可选) ---
|
||||||
|
APPIUM_SERVER = "http://127.0.0.1:4723"
|
||||||
|
|
||||||
|
# --- 核心配置 ---
|
||||||
IMPLICIT_WAIT_TIMEOUT = 10
|
IMPLICIT_WAIT_TIMEOUT = 10
|
||||||
EXPLICIT_WAIT_TIMEOUT = 10
|
EXPLICIT_WAIT_TIMEOUT = 10
|
||||||
APPIUM_SERVER = "http://127.0.0.1:4723"
|
|
||||||
# --- 核心配置 ---
|
|
||||||
APPIUM_HOST = "127.0.0.1"
|
APPIUM_HOST = "127.0.0.1"
|
||||||
APPIUM_PORT = 4723
|
APPIUM_PORT = 4723
|
||||||
|
|
||||||
|
|||||||
69
main.py
69
main.py
@@ -9,53 +9,80 @@
|
|||||||
@date: 2026/1/13 16:54
|
@date: 2026/1/13 16:54
|
||||||
@desc:
|
@desc:
|
||||||
"""
|
"""
|
||||||
import os
|
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
|
||||||
import datetime
|
import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from core.settings import LOG_SOURCE, LOG_BACKUP_DIR, ALLURE_TEMP, REPORT_DIR
|
from core.settings import LOG_SOURCE, LOG_BACKUP_DIR, ALLURE_TEMP
|
||||||
|
from utils.dirs_manager import ensure_dirs_ok
|
||||||
|
from utils.report_handler import generate_allure_report
|
||||||
|
|
||||||
|
|
||||||
# netstat -ano | findstr :4723
|
# netstat -ano | findstr :4723
|
||||||
# taskkill /PID 12345 /F
|
# taskkill /PID 12345 /F
|
||||||
# 日志自动清理
|
|
||||||
|
def _archive_logs():
|
||||||
|
"""
|
||||||
|
在测试开始前,归档上一次运行的日志文件。
|
||||||
|
此时没有任何句柄占用,move 操作是 100% 安全的。
|
||||||
|
"""
|
||||||
|
# 4. 备份日志 (无论测试是否崩溃都执行)
|
||||||
|
if LOG_SOURCE.exists() and LOG_SOURCE.stat().st_size > 0:
|
||||||
|
now = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||||
|
backup_path = LOG_BACKUP_DIR / f"pytest_{now}.log"
|
||||||
|
try:
|
||||||
|
# 移动并重命名
|
||||||
|
shutil.move(str(LOG_SOURCE), str(backup_path))
|
||||||
|
print(f"已自动归档上次运行的日志: {backup_path}")
|
||||||
|
# shutil.copy2(LOG_SOURCE, backup_path)
|
||||||
|
# print(f"日志已备份至: {backup_path}")
|
||||||
|
_clean_old_logs(LOG_BACKUP_DIR)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"归档旧日志失败 (可能被外部编辑器打开): {e}")
|
||||||
|
else:
|
||||||
|
print("未找到原始日志文件,跳过备份。")
|
||||||
|
|
||||||
|
# 日志清理
|
||||||
def _clean_old_logs(backup_dir, keep_count=10):
|
def _clean_old_logs(backup_dir, keep_count=10):
|
||||||
files = sorted(Path(backup_dir).glob("pytest_*.log"), key=os.path.getmtime)
|
files = sorted(Path(backup_dir).glob("pytest_*.log"), key=lambda p: p.stat().st_mtime)
|
||||||
while len(files) > keep_count:
|
while len(files) > keep_count:
|
||||||
file_to_remove = files.pop(0)
|
file_to_remove = files.pop(0)
|
||||||
try:
|
try:
|
||||||
os.remove(file_to_remove)
|
file_to_remove.unlink(missing_ok=True)
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
print(f"清理旧日志失败 {file_to_remove}: {e}")
|
print(f"清理旧日志失败 {file_to_remove}: {e}")
|
||||||
|
|
||||||
|
def _clean_temp_dirs():
|
||||||
|
"""
|
||||||
|
可选:如果你想在测试前清理掉旧的临时文件
|
||||||
|
"""
|
||||||
|
if ALLURE_TEMP.exists():
|
||||||
|
shutil.rmtree(ALLURE_TEMP)
|
||||||
|
# 加上 ignore_errors 是为了防止文件被占用导致整个测试无法启动
|
||||||
|
shutil.rmtree(ALLURE_TEMP, ignore_errors=True)
|
||||||
|
ALLURE_TEMP.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
try:
|
try:
|
||||||
# 2. 执行 Pytest
|
# 1. 创建目录
|
||||||
# 建议保留你之前配置的 -s -v 等参数
|
ensure_dirs_ok()
|
||||||
|
|
||||||
|
# 2. 处理日志
|
||||||
|
_archive_logs()
|
||||||
|
|
||||||
|
# 3. 执行 Pytest
|
||||||
# 注意:-x 表示遇到错误立即停止,如果是全量回归建议去掉 -x
|
# 注意:-x 表示遇到错误立即停止,如果是全量回归建议去掉 -x
|
||||||
pytest.main(["test_cases", "-x", "-v", f"--alluredir={ALLURE_TEMP}"])
|
pytest.main(["test_cases", "-x", "-v", f"--alluredir={ALLURE_TEMP}"])
|
||||||
|
|
||||||
# 3. 生成报告
|
# 4. 生成报告
|
||||||
if ALLURE_TEMP.exists():
|
generate_allure_report()
|
||||||
# 使用 subprocess 替代 os.system,更安全且跨平台兼容性更好
|
|
||||||
subprocess.run(f'allure generate {ALLURE_TEMP} -o {REPORT_DIR} --clean', shell=True, check=False)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"自动化测试执行过程中发生异常: {e}")
|
print(f"自动化测试执行过程中发生异常: {e}")
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
# 4. 备份日志 (无论测试是否崩溃都执行)
|
print("Time-of-check to Time-of-use")
|
||||||
if LOG_SOURCE.exists():
|
|
||||||
now = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
|
|
||||||
backup_path = LOG_BACKUP_DIR / f"pytest_{now}.log"
|
|
||||||
shutil.copy2(LOG_SOURCE, backup_path)
|
|
||||||
print(f"日志已备份至: {backup_path}")
|
|
||||||
_clean_old_logs(LOG_BACKUP_DIR)
|
|
||||||
else:
|
|
||||||
print("未找到原始日志文件,跳过备份。")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
#!/usr/bin/env python
|
|
||||||
# coding=utf-8
|
|
||||||
|
|
||||||
"""
|
|
||||||
@author: CNWei,ChenWei
|
|
||||||
@Software: PyCharm
|
|
||||||
@contact: t6g888@163.com
|
|
||||||
@file: conftest
|
|
||||||
@date: 2026/1/19 14:08
|
|
||||||
@desc:
|
|
||||||
"""
|
|
||||||
import pytest
|
|
||||||
import allure
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
|
|
||||||
def pytest_runtest_makereport(item, call):
|
|
||||||
"""
|
|
||||||
本钩子函数会在每个测试阶段(setup, call, teardown)执行后被调用。
|
|
||||||
item: 测试用例对象
|
|
||||||
call: 测试执行阶段的信息
|
|
||||||
"""
|
|
||||||
# 1. 先执行常规的用例报告生成逻辑
|
|
||||||
outcome = yield
|
|
||||||
report = outcome.get_result()
|
|
||||||
|
|
||||||
# 2. 我们只关注测试执行阶段 ("call")
|
|
||||||
# 如果该阶段失败了(failed),则触发截图
|
|
||||||
if report.when == "call" and report.failed:
|
|
||||||
# 3. 从测试用例中获取 driver 实例
|
|
||||||
# 假设你在 fixture 中注入的参数名为 'driver'
|
|
||||||
driver_instance = item.funcargs.get("driver")
|
|
||||||
|
|
||||||
if driver_instance:
|
|
||||||
try:
|
|
||||||
# 4. 调用你在 CoreDriver 中实现的底层截图方法
|
|
||||||
# 这里的 name 我们可以动态取测试用例的名字
|
|
||||||
case_name = item.name
|
|
||||||
file_path = driver_instance.full_screen_screenshot(name=f"CRASH_{case_name}")
|
|
||||||
|
|
||||||
# 5. 如果路径存在,将其关联到 Allure 报告
|
|
||||||
if file_path:
|
|
||||||
p = Path(file_path)
|
|
||||||
if p.exists():
|
|
||||||
allure.attach.file(
|
|
||||||
source=p,
|
|
||||||
name="【故障现场自动截图】",
|
|
||||||
attachment_type=allure.attachment_type.PNG
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"故障自动截图执行失败: {e}")
|
|
||||||
@@ -11,20 +11,23 @@
|
|||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from utils.logger import trace_step
|
import allure
|
||||||
|
|
||||||
|
from utils.decorators import step_trace
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
@allure.epic("中银国际移动端重构项目")
|
||||||
@trace_step("验证失败",)
|
@allure.feature("登认证模块")
|
||||||
|
@step_trace("验证失败",)
|
||||||
def test_settings_page_display(driver):
|
def test_settings_page_display(driver):
|
||||||
"""
|
"""
|
||||||
测试设置页面是否成功加载
|
测试设置页面是否成功加载
|
||||||
"""
|
"""
|
||||||
# 此时 driver 已经通过 fixture 完成了初始化
|
# 此时 driver 已经通过 fixture 完成了初始化
|
||||||
current_act = driver.driver.current_activity
|
current_act = driver.current_activity
|
||||||
logger.info(f"捕获到当前 Activity: {current_act}")
|
logger.info(f"捕获到当前 Activity: {current_act}")
|
||||||
|
|
||||||
assert ".unihome.UniHomeLauncher" in current_act
|
assert ".app.main.launcher.LauncherActivity" in current_act
|
||||||
|
|
||||||
|
|
||||||
def test_wifi_entry_exists(driver):
|
def test_wifi_entry_exists(driver):
|
||||||
@@ -34,4 +37,4 @@ def test_wifi_entry_exists(driver):
|
|||||||
# 这里的 driver 就是 appium.webdriver.Remote 实例
|
# 这里的 driver 就是 appium.webdriver.Remote 实例
|
||||||
# 假设我们要查找“网络”相关的 ID
|
# 假设我们要查找“网络”相关的 ID
|
||||||
# el = driver.find_element(by='id', value='android:id/title')
|
# el = driver.find_element(by='id', value='android:id/title')
|
||||||
assert driver.driver.session_id is not None
|
assert driver.session_id is not None
|
||||||
@@ -1,12 +1,85 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
# coding=utf-8
|
||||||
|
|
||||||
|
"""
|
||||||
|
@author: CNWei,ChenWei
|
||||||
|
@Software: PyCharm
|
||||||
|
@contact: t6g888@163.com
|
||||||
|
@file: decorators
|
||||||
|
@date: 2026/1/15 11:30
|
||||||
|
@desc:
|
||||||
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
|
import inspect
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from typing import Union, Callable
|
from typing import Union, Callable
|
||||||
|
|
||||||
|
from contextvars import ContextVar
|
||||||
|
from contextlib import ContextDecorator
|
||||||
|
|
||||||
from core.custom_expected_conditions import get_condition
|
from core.custom_expected_conditions import get_condition
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 定义一个上下文变量,初始值为 0
|
||||||
|
indent_var = ContextVar("indent_level", default=0)
|
||||||
|
|
||||||
|
|
||||||
|
class StepTracer(ContextDecorator):
|
||||||
|
"""
|
||||||
|
既是装饰器也是上下文管理器
|
||||||
|
职责:负责计时、日志格式化和异常记录
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, step_desc, source='wrapper', func_info=None):
|
||||||
|
self.step_desc = step_desc
|
||||||
|
self.logger = logging.getLogger(source)
|
||||||
|
self.func_info = func_info
|
||||||
|
self.start_t = None
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
# 1. 获取当前层级并计算前缀
|
||||||
|
level = indent_var.get()
|
||||||
|
# 使用 " " (空格) 或 "│ " 作为缩进符号
|
||||||
|
self.prefix = "│ " * level
|
||||||
|
|
||||||
|
self.start_t = time.perf_counter()
|
||||||
|
info = f" | 方法: {self.func_info}" if self.func_info else ""
|
||||||
|
# self.logger.info(f"[步骤开始] | {self.step_desc}{info}")
|
||||||
|
self.logger.info(f"{self.prefix}┌── [步骤开始] | {self.step_desc}{info}")
|
||||||
|
|
||||||
|
# 2. 进入下一层,层级 +1
|
||||||
|
indent_var.set(level + 1)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||||
|
# 3. 恢复层级,层级 -1
|
||||||
|
level = indent_var.get() - 1
|
||||||
|
indent_var.set(level)
|
||||||
|
|
||||||
|
duration = time.perf_counter() - self.start_t
|
||||||
|
|
||||||
|
prefix = "│ " * level
|
||||||
|
|
||||||
|
if exc_type:
|
||||||
|
# 异常发生
|
||||||
|
# self.logger.error(
|
||||||
|
# f"[步骤失败] {self.step_desc} | 耗时: {duration:.2f}s | 异常: {exc_type.__name__}"
|
||||||
|
# )
|
||||||
|
self.logger.error(
|
||||||
|
f"{prefix}└── [步骤失败] {self.step_desc} | 耗时: {duration:.2f}s | 异常: {exc_type.__name__}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# 执行成功
|
||||||
|
# self.logger.info(f"[步骤成功] {self.step_desc} | 耗时: {duration:.2f}s")
|
||||||
|
self.logger.info(
|
||||||
|
f"{prefix}└── [步骤成功] {self.step_desc} | 耗时: {duration:.2f}s"
|
||||||
|
)
|
||||||
|
# return False 确保异常继续向上抛出,不拦截异常
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def resolve_wait_method(func):
|
def resolve_wait_method(func):
|
||||||
"""
|
"""
|
||||||
@@ -38,17 +111,65 @@ def resolve_wait_method(func):
|
|||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
def exception_capture(func):
|
def action_screenshot(func):
|
||||||
"""
|
"""
|
||||||
仅在原子动作失败时,触发 BasePage 层的业务截图逻辑
|
显式截图装饰器:在方法执行前(或后)立即触发 BasePage 的截图逻辑。
|
||||||
|
用于记录关键操作后的页面状态。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@wraps(func)
|
@wraps(func)
|
||||||
def wrapper(self, *args, **kwargs):
|
def wrapper(self, *args, **kwargs):
|
||||||
|
# 1. 正常执行原方法
|
||||||
|
result = func(self, *args, **kwargs)
|
||||||
|
|
||||||
|
# 2. 执行成功后立即截图(如果你希望在操作后的状态截图)
|
||||||
|
if hasattr(self, "attach_screenshot_bytes"):
|
||||||
try:
|
try:
|
||||||
return func(self, *args, **kwargs)
|
class_name = self.__class__.__name__
|
||||||
|
func_name = func.__name__
|
||||||
|
msg = f"操作记录_{class_name}_{func_name}"
|
||||||
|
# 传入当前方法名作为截图备注
|
||||||
|
self.attach_screenshot_bytes(msg)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# 自动捕获:调用 BasePage 层的 log_screenshot
|
logger.warning(f"装饰器执行截图失败: {e}")
|
||||||
if hasattr(self, "log_screenshot"):
|
|
||||||
self.log_screenshot(f"自动异常捕获{func.__name__}")
|
return result
|
||||||
raise e
|
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
def _format_params(func, *args, **kwargs):
|
||||||
|
"""辅助函数:专门处理参数过滤和格式化"""
|
||||||
|
sig = inspect.signature(func)
|
||||||
|
params = list(sig.parameters.values())
|
||||||
|
display_args = args[1:] if params and params[0].name in ('self', 'cls') else args
|
||||||
|
|
||||||
|
# 格式化参数显示,方便阅读
|
||||||
|
args_repr = [repr(a) for a in display_args]
|
||||||
|
kwargs_repr = [f"{k}={v!r}" for k, v in kwargs.items()]
|
||||||
|
all_params = ", ".join(args_repr + kwargs_repr)
|
||||||
|
return all_params
|
||||||
|
|
||||||
|
|
||||||
|
def step_trace(step_desc="", source='wrapper'):
|
||||||
|
"""
|
||||||
|
通用执行追踪装饰器:
|
||||||
|
1. 智能识别并过滤 self/cls 参数
|
||||||
|
2. 记录入参、出参、耗时
|
||||||
|
3. 异常自动捕获并记录
|
||||||
|
"""
|
||||||
|
|
||||||
|
def decorator(func):
|
||||||
|
@wraps(func)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
# 1. 提取参数显示逻辑 (抽离出来可以作为工具函数)
|
||||||
|
all_params = _format_params(func, *args, **kwargs)
|
||||||
|
func_name = f"{func.__module__}.{func.__name__}"
|
||||||
|
|
||||||
|
# 2. 使用上下文管理器
|
||||||
|
with StepTracer(step_desc, source, f"{func_name}({all_params})"):
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|||||||
32
utils/dirs_manager.py
Normal file
32
utils/dirs_manager.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
# coding=utf-8
|
||||||
|
|
||||||
|
"""
|
||||||
|
@author: CNWei,ChenWei
|
||||||
|
@Software: PyCharm
|
||||||
|
@contact: t6g888@163.com
|
||||||
|
@file: path_manager
|
||||||
|
@date: 2026/2/3 10:52
|
||||||
|
@desc:
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from core.settings import REQUIRED_DIRS
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_dirs_ok():
|
||||||
|
"""
|
||||||
|
统一管理项目目录的创建逻辑
|
||||||
|
"""
|
||||||
|
for folder in REQUIRED_DIRS:
|
||||||
|
# 使用 exist_ok=True 避免并发冲突
|
||||||
|
folder.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_dir(path: Path) -> Path:
|
||||||
|
"""确保路径存在并返回路径本身"""
|
||||||
|
if not isinstance(path, Path):
|
||||||
|
path = Path(path)
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
return path
|
||||||
@@ -9,62 +9,4 @@
|
|||||||
@date: 2026/1/15 11:30
|
@date: 2026/1/15 11:30
|
||||||
@desc:
|
@desc:
|
||||||
"""
|
"""
|
||||||
import time
|
|
||||||
import functools
|
|
||||||
import inspect
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from core.settings import LOG_DIR
|
|
||||||
|
|
||||||
# 1. 确定日志存储路径
|
|
||||||
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
# --- 核心特性:装饰器集成 ---
|
|
||||||
def trace_step(step_desc="", source='wrapper'):
|
|
||||||
"""
|
|
||||||
通用执行追踪装饰器:
|
|
||||||
1. 智能识别并过滤 self/cls 参数
|
|
||||||
2. 记录入参、出参、耗时
|
|
||||||
3. 异常自动捕获并记录
|
|
||||||
"""
|
|
||||||
|
|
||||||
def decorator(func):
|
|
||||||
@functools.wraps(func)
|
|
||||||
def wrapper(*args, **kwargs):
|
|
||||||
# 获取对应的 logger
|
|
||||||
_logger = logging.getLogger(source)
|
|
||||||
# _logger = logging.getLogger(f"{source}.{func.__name__}")
|
|
||||||
|
|
||||||
# 参数解析 (跳过 self/cls)
|
|
||||||
sig = inspect.signature(func)
|
|
||||||
params = list(sig.parameters.values())
|
|
||||||
display_args = args[1:] if params and params[0].name in ('self', 'cls') else args
|
|
||||||
|
|
||||||
# 格式化参数显示,方便阅读
|
|
||||||
args_repr = [repr(a) for a in display_args]
|
|
||||||
kwargs_repr = [f"{k}={v!r}" for k, v in kwargs.items()]
|
|
||||||
all_params = ", ".join(args_repr + kwargs_repr)
|
|
||||||
|
|
||||||
func_name = f"{func.__module__}.{func.__name__}"
|
|
||||||
|
|
||||||
_logger.info(f"[步骤开始] | {step_desc} | 方法: {func_name}({all_params})")
|
|
||||||
|
|
||||||
start_t = time.perf_counter()
|
|
||||||
try:
|
|
||||||
result = func(*args, **kwargs)
|
|
||||||
duration = time.perf_counter() - start_t
|
|
||||||
_logger.info(f"[步骤成功] {step_desc} | 耗时: {duration:.2f}s | 返回: {result!r}")
|
|
||||||
return result
|
|
||||||
except Exception as e:
|
|
||||||
duration = time.perf_counter() - start_t
|
|
||||||
# logging.exception 关键点:它会自动把详细的堆栈信息写入日志文件
|
|
||||||
_logger.error(f"[步骤失败] {step_desc}| 耗时: {duration:.2f}s|异常: {type(e).__name__}")
|
|
||||||
# f"[步骤失败] {step_desc} | 耗时: {duration:.2f}s | 异常: {type(e).__name__}: {e}")
|
|
||||||
raise e
|
|
||||||
|
|
||||||
return wrapper
|
|
||||||
|
|
||||||
return decorator
|
|
||||||
|
|||||||
48
utils/report_handler.py
Normal file
48
utils/report_handler.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
# coding=utf-8
|
||||||
|
|
||||||
|
"""
|
||||||
|
@author: CNWei,ChenWei
|
||||||
|
@Software: PyCharm
|
||||||
|
@contact: t6g888@163.com
|
||||||
|
@file: report_handler
|
||||||
|
@date: 2026/2/3 13:51
|
||||||
|
@desc:
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
import subprocess
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
from core.settings import ALLURE_TEMP, REPORT_DIR
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_allure_report() -> bool:
|
||||||
|
"""
|
||||||
|
将 JSON 原始数据转换为 HTML 报告
|
||||||
|
"""
|
||||||
|
if not ALLURE_TEMP.exists() or not any(ALLURE_TEMP.iterdir()):
|
||||||
|
logger.warning("未发现 Allure 测试数据,跳过报告生成。")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 检查环境是否有 allure 命令行工具
|
||||||
|
if not shutil.which("allure"):
|
||||||
|
logger.error("系统未安装 Allure 命令行工具,请先安装:https://allurereport.org/docs/")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info("正在生成 Allure HTML 报告...")
|
||||||
|
# --clean 会清理掉 REPORT_DIR 里的旧报告
|
||||||
|
subprocess.run(
|
||||||
|
f'allure generate "{ALLURE_TEMP}" -o "{REPORT_DIR}" --clean',
|
||||||
|
shell=True,
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True
|
||||||
|
)
|
||||||
|
logger.info(f"Allure 报告已生成至: {REPORT_DIR}")
|
||||||
|
return True
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
logger.error(f"Allure 报告生成失败: {e.stderr}")
|
||||||
|
return False
|
||||||
Reference in New Issue
Block a user