""" Web 端根级 fixture:浏览器实例 + 登录态复用 + 失败自动截图。 核心机制 — 跳过图形验证码: 配置 web.skip_login: true 时,读取已保存的 storage_state(cookies + localStorage), 跳过 UI 登录步骤。首次需要手动登录一次来生成 auth 文件。 首次初始化流程: 1. python tests/web/save_auth.py 2. 打开的浏览器中手动完成验证码登录 3. 登录态自动保存到 tests/web/auth/.json 后续用例执行时自动复用,过期后重新运行 save_auth.py 即可。 提供: browser_name — 参数化 fixture,每个浏览器执行一轮 page — Playwright Page 对象(已加载登录态) ensure_login — 确保指定角色的登录态有效,未登录则报错提示 """ from datetime import datetime from pathlib import Path import pytest from playwright.sync_api import sync_playwright, Page, BrowserContext SCREENSHOTS_DIR = Path("output/screenshots") AUTH_DIR = Path(__file__).parent / "auth" @pytest.fixture(scope="session") def web_config(config): """session 级:Web 配置段。""" return config["web"] @pytest.fixture(params=["chromium", "firefox"]) def browser_name(web_config): """每个浏览器执行一次同一套用例。 可从命令行覆盖: pytest --browser=chromium tests/web/ """ return request.param @pytest.fixture def page(web_config, browser_name, request) -> Page: """每个用例独立的 Playwright Page,自动加载登录态,失败自动截图。""" viewport = web_config.get("viewport", {"width": 1920, "height": 1080}) timeout = web_config.get("timeout_seconds", 120) * 1000 screenshot_on_failure = web_config.get("screenshot_on_failure", True) skip_login = web_config.get("skip_login", True) storage_state_dir = Path(web_config.get("storage_state_dir", "tests/web/auth")) with sync_playwright() as p: browser_launcher = getattr(p, browser_name) browser = browser_launcher.launch(headless=True) # ── 构建 context ── context_kwargs = { "viewport": {"width": viewport["width"], "height": viewport["height"]}, "locale": "zh-CN", } # 如果启用跳过登录,尝试加载 storage_state loaded_role = None if skip_login: loaded_role = _load_auth_state(context_kwargs, storage_state_dir, browser_name) context = browser.new_context(**context_kwargs) page = context.new_page() page.set_default_timeout(timeout) # 将当前 context 的登录角色注入 page(用例中可检查) page._auth_role = loaded_role yield page # ── 失败自动截图 ── if screenshot_on_failure and hasattr(request.node, "rep_call") and request.node.rep_call.failed: scenario_dir = SCREENSHOTS_DIR / request.node.name scenario_dir.mkdir(parents=True, exist_ok=True) ts = datetime.now().strftime("%Y%m%d_%H%M%S") path = str(scenario_dir / f"FAIL_{browser_name}_{ts}.png") page.screenshot(path=path, full_page=True) context.close() browser.close() @pytest.fixture def ensure_login(web_config, page, config): """确保已登录状态,未登录则自动执行 UI 登录(含验证码则报错)。 用法一(推荐 — 配置 skip_login: true): def test_xxx(page, ensure_login): ensure_login("super_admin") # page 已处于登录后的管理后台 用法二(skip_login: false — 完整 UI 登录): def test_xxx(page, ensure_login): ensure_login("super_admin") # 执行完整 UI 登录 """ def _ensure_login(role: str = "super_admin") -> None: account = config["accounts"].get(role) if not account: raise ValueError(f"未知角色 '{role}',可用: {list(config['accounts'].keys())}") base_url = web_config["base_url"] skip_login = web_config.get("skip_login", True) # 如果 storage_state 已加载且角色匹配,直接验证 if skip_login: auth_role = getattr(page, "_auth_role", None) if auth_role == role: # 验证登录态是否仍然有效 page.goto(base_url) # Hash 路由模式下判断是否重定向到登录页 page.wait_for_timeout(2000) # 等 hash 路由完成 if "/#/login" not in page.url and "/login" not in page.url: return # 登录态有效 # 登录态过期,给出明确指引 raise RuntimeError( f"登录态已过期或未生成!请运行以下命令重新生成:\n" f" python tests/web/save_auth.py --role {role} --browser {_get_browser_name(page)}" ) if auth_role and auth_role != role: raise RuntimeError( f"当前 storage_state 角色为 '{auth_role}',请求为 '{role}'。\n" f"请运行: python tests/web/save_auth.py --role {role}" ) # 没有加载任何 auth,报错 raise RuntimeError( f"未找到角色 '{role}' 的登录态文件!请运行:\n" f" python tests/web/save_auth.py --role {role}" ) # ── skip_login: false,执行 UI 登录(可能有验证码,会失败) ── page.goto(f"{base_url}/#/login") page.get_by_placeholder("请输入用户名").fill(account["phone"]) page.get_by_placeholder("请输入密码").fill(account["password"]) page.get_by_role("button", name="登录").click() # 等几秒让页面处理路由跳转 page.wait_for_timeout(3000) if "/#/login" in page.url or "/login" in page.url: raise RuntimeError( "登录失败!可能存在图形验证码。\n" "请改为手动登录生成 auth 文件: python tests/web/save_auth.py --role {role}" ) def _load_auth_state(context_kwargs: dict, storage_dir: Path, browser: str) -> str | None: """尝试加载登录态,返回角色名或 None。""" auth_files = list(storage_dir.glob(f"*_{browser}.json")) if storage_dir.exists() else [] if not auth_files: return None auth_file = auth_files[0] # 加载第一个匹配的 auth 文件 context_kwargs["storage_state"] = str(auth_file) # 从文件名提取角色名(格式: _.json) role = auth_file.stem.replace(f"_{browser}", "") return role def _get_browser_name(page: Page) -> str: """从 page 推测浏览器名(用于错误提示)。""" return "chromium" # 默认提示 chromium # ── pytest 报告 hook ── @pytest.hookimpl(tryfirst=True, hookwrapper=True) def pytest_runtest_makereport(item, call): """记录用例执行结果,供失败截图使用。""" outcome = yield rep = outcome.get_result() setattr(item, f"rep_{rep.when}", rep)