270 lines
9.2 KiB
Python
270 lines
9.2 KiB
Python
"""通用 Playwright 步骤执行器(不写死业务页面)。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import re
|
||
import time
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
from browser_env import interpolate, resolve_url
|
||
from browser_models import BrowserResult, BrowserScenario
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
SCREENSHOT_DIR = Path(__file__).resolve().parent / ".cache" / "screenshots"
|
||
|
||
FIELD_HINTS: dict[str, list[str]] = {
|
||
"账号": [
|
||
"#login-username",
|
||
"input#login-username",
|
||
"input[autocomplete='username']",
|
||
"username",
|
||
"account",
|
||
"phone",
|
||
"账号",
|
||
"手机号",
|
||
"企业账号",
|
||
],
|
||
"密码": [
|
||
"#login-password input",
|
||
"#login-password",
|
||
"input#login-password",
|
||
"input[type='password']",
|
||
"password",
|
||
"密码",
|
||
],
|
||
"用户名": ["#login-username", "input#login-username", "username", "account", "账号"],
|
||
}
|
||
|
||
def _step_label(step: dict[str, Any], index: int) -> str:
|
||
action = step.get("action", "?")
|
||
target = step.get("target") or step.get("field") or step.get("url") or ""
|
||
return f"{index + 1}. {action} {target}".strip()
|
||
|
||
|
||
def _resolve_fill_locator(page, field: str, step: dict[str, Any]):
|
||
if step.get("selector"):
|
||
return page.locator(interpolate(str(step["selector"])))
|
||
|
||
field_key = interpolate(str(field))
|
||
if step.get("label"):
|
||
return page.get_by_label(interpolate(str(step["label"])), exact=False)
|
||
if step.get("placeholder"):
|
||
return page.get_by_placeholder(interpolate(str(step["placeholder"])), exact=False)
|
||
|
||
hints = FIELD_HINTS.get(field_key, [field_key])
|
||
for hint in hints:
|
||
if hint.startswith("#") or hint.startswith(".") or hint.startswith("["):
|
||
locator = page.locator(hint)
|
||
if locator.count() > 0:
|
||
return locator.first
|
||
for getter in (
|
||
lambda h=hint: page.get_by_label(h, exact=False),
|
||
lambda h=hint: page.get_by_placeholder(h, exact=False),
|
||
):
|
||
locator = getter()
|
||
if locator.count() > 0:
|
||
return locator.first
|
||
|
||
return page.locator("input, textarea").filter(has_text=field_key).first
|
||
|
||
|
||
def _fill_field(page, field: str, step: dict[str, Any]) -> None:
|
||
value = interpolate(str(step.get("value", "")))
|
||
locator = _resolve_fill_locator(page, field, step)
|
||
locator.click(timeout=10_000)
|
||
locator.fill("", timeout=5_000)
|
||
locator.fill(value, timeout=10_000)
|
||
|
||
|
||
def _page_error_text(page) -> str | None:
|
||
for selector in (
|
||
".ant-message-error",
|
||
".ant-form-item-explain-error",
|
||
".ant-alert-error",
|
||
):
|
||
try:
|
||
locator = page.locator(selector).first
|
||
if locator.is_visible(timeout=300):
|
||
text = locator.inner_text(timeout=1_000).strip()
|
||
if text:
|
||
return text
|
||
except Exception:
|
||
continue
|
||
return None
|
||
|
||
|
||
def _pathname_matches(pattern: str, pathname: str) -> bool:
|
||
pattern = pattern.strip()
|
||
if pattern in {"**/app/**", "**/app/*", "/app/**"}:
|
||
return pathname.startswith("/app")
|
||
if pattern.endswith("/**"):
|
||
prefix = pattern[:-3].rstrip("/")
|
||
if prefix.startswith("**/"):
|
||
prefix = prefix[3:]
|
||
if not prefix.startswith("/"):
|
||
prefix = "/" + prefix
|
||
return pathname.startswith(prefix)
|
||
if "**" in pattern or "*" in pattern:
|
||
regex = "^" + re.escape(pattern).replace(r"\*\*", ".*").replace(r"\*", "[^/]*") + "$"
|
||
return re.search(regex, pathname) is not None
|
||
return pathname == pattern or pathname.startswith(pattern)
|
||
|
||
|
||
def _wait_for_url_pattern(page, pattern: str, timeout: int = 60_000) -> None:
|
||
"""SPA 路由用 pathname 轮询;glob 模式不依赖 navigation 事件。"""
|
||
deadline = time.monotonic() + timeout / 1000
|
||
last_error: str | None = None
|
||
|
||
while time.monotonic() < deadline:
|
||
pathname = page.evaluate("() => window.location.pathname")
|
||
if _pathname_matches(pattern, pathname):
|
||
try:
|
||
page.wait_for_load_state("networkidle", timeout=8_000)
|
||
except Exception:
|
||
page.wait_for_timeout(800)
|
||
return
|
||
|
||
err = _page_error_text(page)
|
||
if err and err != last_error:
|
||
last_error = err
|
||
logger.warning("页面提示:%s", err)
|
||
if "/login" in pathname:
|
||
raise RuntimeError(f"登录失败:{err}")
|
||
|
||
page.wait_for_timeout(400)
|
||
|
||
err = _page_error_text(page)
|
||
hint_parts = [f"当前 URL:`{page.url}`"]
|
||
if err:
|
||
hint_parts.append(f"页面错误:{err}")
|
||
elif last_error:
|
||
hint_parts.append(f"页面错误:{last_error}")
|
||
hint_parts.append("请确认 PREVIEW_LOGIN_USER/PASSWORD 正确,且登录 API(内网网关)可达。")
|
||
raise RuntimeError(f"等待 URL 匹配 `{pattern}` 超时({timeout}ms)。{' '.join(hint_parts)}")
|
||
|
||
|
||
def _click_target(page, target: str) -> None:
|
||
target = interpolate(target.strip())
|
||
if target.lower() in {"登录", "login"}:
|
||
for selector in ("button.login-submit", "button[type='submit']"):
|
||
locator = page.locator(selector)
|
||
if locator.count() > 0:
|
||
locator.first.click(timeout=10_000)
|
||
return
|
||
|
||
candidates = [
|
||
page.get_by_role("menuitem", name=target, exact=True),
|
||
page.get_by_role("button", name=target, exact=True),
|
||
page.get_by_role("link", name=target, exact=True),
|
||
page.get_by_text(target, exact=True),
|
||
page.get_by_text(target, exact=False),
|
||
]
|
||
for locator in candidates:
|
||
if locator.count() > 0:
|
||
locator.first.click(timeout=10_000)
|
||
return
|
||
raise RuntimeError(f"未找到可点击元素:{target}")
|
||
|
||
|
||
def _execute_step(page, base_url: str, step: dict[str, Any]) -> None:
|
||
action = str(step.get("action", "")).lower()
|
||
if action == "goto":
|
||
target = step.get("target") or step.get("url") or "/"
|
||
url = resolve_url(base_url, str(target))
|
||
page.goto(url, wait_until="networkidle", timeout=60_000)
|
||
return
|
||
|
||
if action == "fill":
|
||
field = str(step.get("field") or step.get("target") or "账号")
|
||
_fill_field(page, field, step)
|
||
return
|
||
|
||
if action == "click":
|
||
target = step.get("target") or step.get("text")
|
||
if not target:
|
||
raise RuntimeError("click 步骤缺少 target")
|
||
_click_target(page, str(target))
|
||
page.wait_for_timeout(800)
|
||
return
|
||
|
||
if action == "wait":
|
||
timeout = int(step.get("timeout") or 60_000)
|
||
if step.get("url"):
|
||
_wait_for_url_pattern(page, str(step["url"]), timeout=timeout)
|
||
return
|
||
if step.get("selector"):
|
||
page.locator(interpolate(str(step["selector"]))).wait_for(timeout=30_000)
|
||
return
|
||
if step.get("text"):
|
||
page.get_by_text(interpolate(str(step["text"])), exact=False).wait_for(timeout=30_000)
|
||
return
|
||
ms = int(step.get("ms") or 1500)
|
||
page.wait_for_timeout(ms)
|
||
return
|
||
|
||
if action == "press":
|
||
key = str(step.get("key") or step.get("target") or "Enter")
|
||
page.keyboard.press(key)
|
||
return
|
||
|
||
if action == "screenshot":
|
||
return
|
||
|
||
raise RuntimeError(f"未知步骤 action={action}")
|
||
|
||
|
||
def run_browser_scenario_sync(scenario: BrowserScenario) -> BrowserResult:
|
||
from playwright.sync_api import sync_playwright
|
||
|
||
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||
slug = (scenario.name or "browser").replace(" ", "-")
|
||
output = SCREENSHOT_DIR / f"{slug}-{stamp}.png"
|
||
output.parent.mkdir(parents=True, exist_ok=True)
|
||
|
||
step_log: list[str] = []
|
||
final_url = scenario.base_url
|
||
|
||
with sync_playwright() as playwright:
|
||
browser = playwright.chromium.launch(headless=True)
|
||
page = browser.new_page(viewport={"width": 1280, "height": 720})
|
||
|
||
steps = list(scenario.steps)
|
||
if steps and steps[-1].get("action") != "screenshot" and not any(
|
||
s.get("action") == "screenshot" for s in steps
|
||
):
|
||
steps.append({"action": "screenshot"})
|
||
|
||
for index, step in enumerate(steps):
|
||
label = _step_label(step, index)
|
||
logger.info("执行步骤 %s", label)
|
||
action = str(step.get("action", "")).lower()
|
||
if action == "screenshot":
|
||
page.wait_for_timeout(int(step.get("ms") or 1500))
|
||
page.screenshot(path=str(output), full_page=False, type="png")
|
||
final_url = page.url
|
||
step_log.append(label + " ✓")
|
||
continue
|
||
try:
|
||
_execute_step(page, scenario.base_url, step)
|
||
final_url = page.url
|
||
step_log.append(label + " ✓")
|
||
except Exception as exc:
|
||
err = _page_error_text(page)
|
||
detail = f"({err})" if err else ""
|
||
raise RuntimeError(f"步骤失败:{label} @ {page.url}{detail}") from exc
|
||
|
||
browser.close()
|
||
|
||
return BrowserResult(
|
||
scenario_name=scenario.name,
|
||
base_url=scenario.base_url,
|
||
final_url=final_url,
|
||
screenshot_path=output,
|
||
step_count=len(steps),
|
||
step_log=step_log,
|
||
)
|