"""UI-смоук фронта arm-core/web против стенда регрессии (regress.sh, этап ui; после сценария stand). Обходит основные экраны под ka_go (токен — в sessionStorage, как после входа), на каждом ждёт затишья сети и собирает: исключения страницы, ошибки консоли, ответы /api со статусом >= 400. Красный экран — исключение страницы, ответ /api >= 500 или 4xx (кроме перечисленных в ALLOWED_4XX), пустая страница. Скриншоты — /<экран>.png, итог — /report.json. Идентификаторы версий и документов берёт из report.json сценария стенда. uv run --with playwright==1.55.0 python ui_smoke.py --web http://127.0.0.1:13000 --api http://127.0.0.1:18000 \\ --stand-report DIR/stand/report.json --out DIR/ui """ import argparse import json import sys import urllib.request from pathlib import Path from playwright.sync_api import sync_playwright # 4xx, которые фронт получает штатно (пусто — любой 4xx красный; дополнять с причиной) ALLOWED_4XX: set[tuple[str, int]] = set() def token(api: str, email: str) -> str: req = urllib.request.Request( api.rstrip("/") + "/api/v1/dev/token", data=json.dumps({"email": email}).encode(), headers={"Content-Type": "application/json"}, method="POST", ) with urllib.request.urlopen(req, timeout=30) as r: return json.loads(r.read())["access_token"] def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--web", required=True) ap.add_argument("--api", required=True) ap.add_argument("--stand-report", required=True, type=Path) ap.add_argument("--out", required=True, type=Path) ap.add_argument("--user", default="ka_go@armka.local") a = ap.parse_args() a.out.mkdir(parents=True, exist_ok=True) ids = json.loads(a.stand_report.read_text()).get("ids", {}) g = ids.get("group_id") pages = [("groups", "/groups"), ("catalogue-groups", "/catalogue/groups"), ("catalogue-le", "/catalogue/jur_l")] if g: pages.append(("group", f"/groups/{g}")) for sid, vid in (ids.get("versions") or {}).items(): pages += [(f"{sid}-osv", f"/groups/{g}/versions/{vid}/osv"), (f"{sid}-chess", f"/groups/{g}/versions/{vid}/chess")] for name, doc in (ids.get("documents") or {}).items(): pages.append((f"doc-{name}", f"/documents/{doc}")) tok = token(a.api, a.user) results = [] with sync_playwright() as p: browser = p.chromium.launch() ctx = browser.new_context(viewport={"width": 1600, "height": 1000}, locale="ru-RU") ctx.add_init_script(f"try {{ sessionStorage.setItem('armka.token', {json.dumps(tok)}); }} catch (e) {{}}") for name, path in pages: page = ctx.new_page() errors, console, bad_api = [], [], [] page.on("pageerror", lambda e, errors=errors: errors.append(str(e)[:300])) page.on("console", lambda m, console=console: console.append(m.text[:300]) if m.type == "error" else None) def on_response(r, bad_api=bad_api): if "/api/" in r.url and r.status >= 400: bad_api.append(f"{r.status} {r.request.method} {r.url.split('/api/', 1)[1][:150]}") page.on("response", on_response) try: page.goto(a.web.rstrip("/") + path, wait_until="networkidle", timeout=60000) page.wait_for_timeout(1500) text = page.inner_text("body") except Exception as e: # noqa: BLE001 — экран красный, обход продолжается errors.append(f"{type(e).__name__}: {e}"[:300]) text = "" page.screenshot(path=str(a.out / f"{name}.png"), full_page=False) red_api = [ x for x in bad_api if int(x.split()[0]) >= 500 or (x.split()[1], int(x.split()[0])) not in ALLOWED_4XX ] ok = not errors and not red_api and len(text.strip()) > 20 results.append({"name": name, "path": path, "ok": ok, "page_errors": errors, "api_errors": bad_api, "console_errors": console[:10]}) print(("ok " if ok else "FAIL ") + f"{name} {path}" + ("" if ok else f": {errors or red_api or 'пустая страница'}"), flush=True) page.close() browser.close() (a.out / "report.json").write_text(json.dumps(results, ensure_ascii=False, indent=1)) red = [r for r in results if not r["ok"]] print(f"экранов {len(results)}, красных {len(red)}") return 1 if red else 0 if __name__ == "__main__": sys.exit(main())