feat: 框架增加登录态复用机制,绕过图形验证码

Web 端 (storage_state):
- 新增 tests/web/save_auth.py: 手动登录后保存 cookies+localStorage
- conftest.py: skip_login:true 时自动加载 storage_state,跳过 UI 登录
- ensure_login fixture: 登录态有效则直接通过,过期则给出明确指引

App 端 (noReset):
- conftest.py: ensure_logged_in fixture,手动登录后无需重新登录
- noReset=true 持久化登录态,未登录自动检测并给出明确引导

配置文件:
- web.skip_login: true + storage_state_dir
- app.skip_login: true + 手动登录说明
This commit is contained in:
xst
2026-07-15 10:47:34 +08:00
parent aade9e39d0
commit 7b4a54f1ce
6 changed files with 518 additions and 161 deletions
+110 -38
View File
@@ -1,29 +1,31 @@
"""
Web 端根级 fixture:浏览器实例 + 失败自动截图。
Web 端根级 fixture:浏览器实例 + 登录态复用 + 失败自动截图。
核心机制 — 跳过图形验证码:
配置 web.skip_login: true 时,读取已保存的 storage_statecookies + localStorage),
跳过 UI 登录步骤。首次需要手动登录一次来生成 auth 文件。
首次初始化流程:
1. python tests/web/save_auth.py
2. 打开的浏览器中手动完成验证码登录
3. 登录态自动保存到 tests/web/auth/<role>.json
后续用例执行时自动复用,过期后重新运行 save_auth.py 即可。
提供:
browser_name — 参数化 fixture,每个浏览器执行一轮
page — Playwright Page 对象(每个用例独立
login — 可复用的登录 helper
Usage:
def test_something(page, browser_name):
page.goto("/admin")
...
def test_admin_flow(page, login):
login("super_admin") # 以管理员身份登录
...
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
from playwright.sync_api import sync_playwright, Page, BrowserContext
# 截图根目录
SCREENSHOTS_DIR = Path("output/screenshots")
AUTH_DIR = Path(__file__).parent / "auth"
@pytest.fixture(scope="session")
@@ -33,28 +35,45 @@ def web_config(config):
@pytest.fixture(params=["chromium", "firefox"])
def browser_name(request):
"""每个浏览器执行一次同一套用例。"""
def browser_name(web_config):
"""每个浏览器执行一次同一套用例。
可从命令行覆盖: pytest --browser=chromium tests/web/
"""
return request.param
@pytest.fixture
def page(web_config, browser_name, request):
"""每个用例独立的 Playwright Page,失败自动截图。"""
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 = browser.new_context(
viewport={"width": viewport["width"], "height": viewport["height"]},
locale="zh-CN",
)
# ── 构建 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
# ── 失败自动截图 ──
@@ -70,40 +89,93 @@ def page(web_config, browser_name, request):
@pytest.fixture
def login(page, web_config, config):
"""返回一个登录 helper 函数,按角色名自动填入账号并等待登录完成
def ensure_login(web_config, page, config):
"""确保已登录状态,未登录则自动执行 UI 登录(含验证码则报错)
Usage:
def test_admin(page, login):
login("super_admin")
page.getByTestId("nav-dashboard").click()
用法一(推荐 — 配置 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 _login(role: str = "super_admin") -> None:
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"]
# 如果已经打开某个页面且未过期,直接返回
if page.url and not page.url.startswith("about:") and "/login" not in page.url:
return
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)
if "/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.getByPlaceholder("请输入用户名").fill(account["phone"])
page.getByPlaceholder("请输入密码").fill(account["password"])
page.getByRole("button", name="登录").click()
# 等待登录成功跳转(10 秒超时
page.wait_for_url(f"{base_url}/**", timeout=10_000)
# 如果有图形验证码,这里会超时
try:
page.wait_for_url(f"{base_url}/**", timeout=10_000)
except Exception:
raise RuntimeError(
"登录失败!可能存在图形验证码。\n"
"请改为手动登录生成 auth 文件: python tests/web/save_auth.py --role {role}"
)
return _login
return _ensure_login
# ── pytest 报告 hook:记录每个用例的失败状态 ──
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)
# 从文件名提取角色名(格式: <role>_<browser>.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):
"""在每个用例执行后记录结果,供 fixture 中的失败截图逻辑使用。"""
"""记录用例执行结果,供失败截图使用。"""
outcome = yield
rep = outcome.get_result()
setattr(item, f"rep_{rep.when}", rep)