"""End-to-end user scenarios for the OMNI panel (real clicks, real bytes, real geometry). Complements tools/ui_smoke.py: instead of asserting that every route paints, it walks the flows an operator actually performs - deep-link filters, exports, digest, reader download, Telegram status, source search, mobile offcanvas - and fails loudly on any of them. Every scenario runs under a hard wall-clock guard, so a stuck Playwright call becomes a named failure instead of a silent hang. Run: python tools/panel_scenarios.py [--url http://127.0.0.1:8078] [--shots DIR] """ from __future__ import annotations import argparse import asyncio import json import pathlib import sys from typing import Any from urllib.parse import quote from playwright.async_api import Page, async_playwright sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) from reachability import find_covered # noqa: E402 BASE = "http://127.0.0.1:8078" T = 12000 # per-action guard; Playwright defaults proved unreliable on Windows COLLISION_JS = """() => { const painted = (el, x, y) => { const top = document.elementFromPoint(x, y); return !!top && (top === el || el.contains(top) || top.contains(el)); }; const boxes = []; const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); let node; while ((node = walker.nextNode())) { if (!node.textContent.trim()) continue; const el = node.parentElement; const style = getComputedStyle(el); if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) continue; if (el.closest('#sug:not(.show), #drawer:not(.show), #modal:not(.show), .visually-hidden-focusable:not(:focus)')) continue; const range = document.createRange(); range.selectNodeContents(node); for (const rect of range.getClientRects()) { if (rect.width < 4 || rect.height < 4) continue; boxes.push({el, rect, text: node.textContent.trim().slice(0, 28)}); } } const found = []; for (let i = 0; i < boxes.length; i++) for (let j = i + 1; j < boxes.length; j++) { const a = boxes[i], b = boxes[j]; if (a.el === b.el || a.el.contains(b.el) || b.el.contains(a.el)) continue; const x = Math.min(a.rect.right, b.rect.right) - Math.max(a.rect.left, b.rect.left); const y = Math.min(a.rect.bottom, b.rect.bottom) - Math.max(a.rect.top, b.rect.top); if (x <= 3 || y <= 3) continue; const cx = (Math.max(a.rect.left, b.rect.left) + Math.min(a.rect.right, b.rect.right)) / 2; const cy = (Math.max(a.rect.top, b.rect.top) + Math.min(a.rect.bottom, b.rect.bottom)) / 2; if (painted(a.el, cx, cy) && painted(b.el, cx, cy)) found.push(a.text + ' <> ' + b.text); } return {collisions: found.slice(0, 5), scrollW: document.documentElement.scrollWidth, innerW: innerWidth}; }""" class Runner: """Scenario context: one page, shared report, guarded steps.""" def __init__(self, page: Page, url: str, shots: pathlib.Path): self.page = page self.url = url.rstrip("/") self.shots = shots self.out: dict[str, Any] = {"url": self.url, "scenarios": {}} self.errors: list[str] = [] async def settle(self): try: await self.page.wait_for_load_state("networkidle", timeout=5000) except Exception: pass async def goto_hash(self, hash_part: str, selector: str | None = None): """Routing lives in location.hash; page.goto() would hang on a same-document change.""" await self.page.evaluate("h => { location.hash = h; }", hash_part) if selector: await self.page.wait_for_selector(selector, timeout=25000) await self.settle() async def no_collision(self, label: str): res = await self.page.evaluate(COLLISION_JS) assert not res["collisions"], f"{label}: text on text {res['collisions']}" assert res["scrollW"] <= res["innerW"] + 1, ( f"{label}: horizontal overflow {res['scrollW']} > {res['innerW']}" ) self.out["scenarios"].setdefault("no_collision", []).append(label) # ---------------------------------------------------------------- scenarios async def deep_link(self): page = self.page await page.goto(self.url + "/#/feed", wait_until="domcontentloaded", timeout=30000) await page.wait_for_selector("#list .item", timeout=25000) await self.settle() rows_all = await page.locator("#list .item").count() source = ( await page.locator("#list .item .feed-content .small > span:first-child") .first.inner_text(timeout=T) ).strip() assert source, "feed row has no source label" await self.goto_hash("#/feed?src=" + quote(source), "#list .item") rows_src = await page.locator("#list .item").count() shown = await page.evaluate( """() => [...document.querySelectorAll('#list .item .feed-content .small > span:first-child')] .slice(0, 25).map(e => e.textContent.trim())""" ) mismatched = [s for s in shown if s != source] self.out["scenarios"]["deep_link"] = { "rows_all": rows_all, "source": source[:40], "rows_filtered": rows_src, "mismatched_rows": len(mismatched), } assert rows_src > 0, f"src deep-link returned no rows for {source!r}" assert not mismatched, f"src deep-link leaked other sources: {mismatched[:3]}" # Resetting the filter must restore the full feed. await self.goto_hash("#/feed", "#list .item") rows_reset = await page.locator("#list .item").count() self.out["scenarios"]["deep_link"]["rows_after_reset"] = rows_reset assert rows_reset >= rows_src, "resetting the deep-link filter shrank the feed" async def exports(self): page = self.page await self.goto_hash("#/feed", "#list .item") csv_href = await page.locator("#export-csv").get_attribute("href", timeout=T) json_href = await page.locator("#export-json").get_attribute("href", timeout=T) sizes: dict[str, int] = {} for key, href in (("csv", csv_href), ("json", json_href)): assert href and href.startswith("/api/export"), f"export {key} href missing: {href}" resp = await page.request.get(self.url + href, timeout=30000) assert resp.status == 200, f"export {key} -> HTTP {resp.status}" body = await resp.body() sizes[key] = len(body) assert len(body) > 200, f"export {key} suspiciously small: {len(body)} bytes" payload = await page.evaluate( """async (href) => { const r = await fetch(href); const j = await r.json(); return {ok: j.ok, total: j.total, count: j.count, first: (j.items && j.items[0]) ? Object.keys(j.items[0]).slice(0, 8) : []}; }""", json_href, ) assert payload["count"] > 0 and payload["first"], f"export json invalid: {payload}" assert "title" in payload["first"], f"export json lost columns: {payload['first']}" self.out["scenarios"]["exports"] = {"bytes": sizes, "json": payload, "csv_href": (csv_href or "")[:70]} async def reader(self): page = self.page await self.goto_hash("#/feed", "#list .item") opener = page.locator("#list .item").first.locator("[data-id]").first item_id = await opener.get_attribute("data-id", timeout=T) assert item_id, "feed row has no data-id" async with page.expect_response(lambda r: f"/api/item/{item_id}" in r.url, timeout=25000) as detail: await opener.click(timeout=T) resp = await detail.value await page.wait_for_selector("#drawer.show", timeout=15000) body_len = await page.locator("#d-body").evaluate("el => el.textContent.trim().length") title = (await page.locator("#d-title").inner_text(timeout=T)).strip() payload = await resp.json() api_url = (payload.get("item") or payload).get("url") or "" link_href = await page.locator('#drawer a[target="_blank"]').first.get_attribute("href", timeout=T) self.out["scenarios"]["reader"] = { "id": item_id, "api_status": resp.status, "title": title[:60], "body_chars": body_len, "api_url": api_url[:70], "original_link": (link_href or "")[:70], } assert resp.status == 200, f"item api -> HTTP {resp.status}" assert title, "reader opened without a title" assert body_len > 40, f"reader body empty for {item_id}" if api_url: assert link_href == api_url, f"original link {link_href!r} != api url {api_url!r}" await page.keyboard.press("Escape") await page.wait_for_timeout(600) assert not await page.evaluate("document.querySelector('#drawer').classList.contains('show')"), \ "Escape did not close the reader" await self.no_collision("reader closed") async def digest(self): page = self.page await self.goto_hash("#/digest") await page.locator('[data-act="digest"]').first.click(timeout=T) await page.wait_for_timeout(4000) html = await page.locator("#view").inner_html(timeout=T) leaked = "ai_not_configured" in html and "ИИ не настроен" not in html self.out["scenarios"]["digest"] = { "rendered_chars": len(html), "has_markdown": "```" in html or " 200, "digest rendered nothing" async def sources_and_telegram(self): page = self.page await self.goto_hash("#/sources", "#src-tbl") # The table paints a placeholder row first; wait for the real registry. await page.wait_for_function( "document.querySelectorAll('#src-tbl tbody tr').length > 5", timeout=25000 ) rows_all = await page.locator("#src-tbl tbody tr").count() needle = "telegram" await page.locator("#s-q").fill(needle, timeout=T) await page.wait_for_timeout(1800) await self.settle() rows_filtered = await page.locator("#src-tbl tbody tr").count() visible = await page.evaluate( """() => [...document.querySelectorAll('#src-tbl tbody tr')] .slice(0, 25).map(tr => tr.textContent.toLowerCase())""" ) off_topic = [row[:40] for row in visible if needle not in row] self.out["scenarios"]["source_search"] = { "rows_all": rows_all, "needle": needle, "rows_filtered": rows_filtered, "off_topic_rows": len(off_topic), } assert rows_all > 100, f"source registry looks empty: {rows_all} rows" assert 0 < rows_filtered < rows_all, f"source search broken: {rows_all} -> {rows_filtered}" assert not off_topic, f"source search returned unrelated rows: {off_topic[:3]}" await self.goto_hash("#/tg", "#tg-search-form") status = await page.evaluate( "async () => { const r = await fetch('/api/tg/status'); return {status: r.status, body: await r.json()}; }" ) body = status["body"] me = body.get("me") or {} text = (await page.locator("#view").inner_text(timeout=T)).strip() self.out["scenarios"]["telegram"] = { "http": status["status"], "enabled": body.get("enabled"), "error": body.get("error"), "account": me.get("first_name"), "account_id": me.get("id"), "session": body.get("session"), "rendered_chars": len(text), } assert status["status"] == 200, f"tg status -> HTTP {status['status']}" assert body.get("enabled") is True and not body.get("error"), f"tg session degraded: {body}" assert me.get("first_name") and me.get("id"), f"tg account missing: {body}" # the tab mounts a "Загрузка данных…" stub first; a real render is far longer assert len(text) > 200, f"Telegram tab rendered a stub: {len(text)} chars -> {text[:80]!r}" async def mobile(self): page = self.page await page.set_viewport_size({"width": 390, "height": 860}) # A query before the hash forces a real document load; hash-only goto is same-document. await page.goto(self.url + "/?v=mobile#/feed", wait_until="domcontentloaded", timeout=30000) await page.wait_for_selector("#list .item", timeout=25000) await self.settle() await asyncio.wait_for(page.locator("#sidebar-toggle").click(timeout=T), 25) await page.wait_for_selector("#sidebar-menu.show:not(.collapsing)", timeout=10000) await page.wait_for_timeout(400) # Everything painted must be tappable. find_covered skips hidden nodes and closed #
groups, so a footer or overlay covering a real control fails the run. covered = await find_covered( page, "#sidebar-menu [data-nav], .sidebar-footer button, #sidebar-toggle", "nav" ) geometry = await page.evaluate( """() => { const nav = document.querySelector('#nav').getBoundingClientRect(); const foot = document.querySelector('.sidebar-footer').getBoundingClientRect(); return {navBottom: Math.round(nav.bottom), footerTop: Math.round(foot.top), menuHeight: Math.round(document.querySelector('#sidebar-menu').getBoundingClientRect().height), menuInlineHeight: document.querySelector('#sidebar-menu').style.height || ''}; }""" ) # «Обзор» lives in the collapsible «Система» group: open it the way a user would. await asyncio.wait_for(page.locator("#system-nav > summary").click(timeout=T), 25) await page.wait_for_function("document.querySelector('#system-nav').open", timeout=10000) covered_system = await find_covered(page, "#system-nav [data-nav]", "nav") labels = [t.strip() for t in await page.locator("#sidebar-menu [data-nav]").all_inner_texts()] # A real click hangs forever when something is painted over the target - that is the point. await asyncio.wait_for(page.locator('#sidebar-menu [data-nav="overview"]').click(timeout=T), 30) await page.wait_for_timeout(1200) hash_after = await page.evaluate("location.hash") closed = await page.evaluate("!document.querySelector('#sidebar-menu').classList.contains('show')") await self.no_collision("mobile overview") self.out["scenarios"]["mobile"] = { "nav_labels": labels[:12], "covered_controls": covered, "covered_system_group": covered_system, "sidebar_geometry": geometry, "hash_after": hash_after, "menu_closed_after_nav": closed, } assert not covered, f"mobile menu has untappable controls: {covered}" assert not covered_system, f"«Система» group has untappable items: {covered_system}" assert geometry["navBottom"] <= geometry["footerTop"] + 1, f"nav overlaps footer: {geometry}" assert "overview" in hash_after, f"mobile navigation did not route: {hash_after}" assert closed, "offcanvas stayed open after navigation" assert labels and all(labels), "mobile navigation lost its labels" await page.screenshot(path=str(self.shots / "mobile_overview.png")) await page.set_viewport_size({"width": 1440, "height": 960}) async def add_source(self): """Reject an invalid source through the real form: no mutation, friendly message.""" page = self.page await page.set_viewport_size({"width": 1440, "height": 960}) await self.goto_hash("#/sources", "#src-tbl") await page.wait_for_function( "document.querySelectorAll('#src-tbl tbody tr').length > 5", timeout=25000 ) rows_before = await page.locator("#src-tbl tbody tr").count() await asyncio.wait_for(page.locator('[data-act="add-src"]').click(timeout=T), 15) await page.wait_for_selector("#modal.show #add-source-form", timeout=10000) out = {"rows_before": rows_before} # The submit button sits in the modal footer and is wired to the form by id, # so "#add-source-form button[type=submit]" matches nothing. submit = page.locator('#modal-box button[type="submit"][form="add-source-form"]') out["covered_modal_controls"] = await find_covered( page, "#modal.show button, #modal.show input, #modal.show select", None ) # 1) empty required fields: native validation must block the write await asyncio.wait_for(submit.click(timeout=T), 15) await page.wait_for_timeout(600) out["url_invalid_when_empty"] = await page.evaluate( "!document.querySelector('#a-url').checkValidity()" ) out["error_after_empty_submit"] = ( await page.locator("#add-source-error").inner_text() ).strip() # 2) a non-http scheme passes type=url but must be refused by the app await page.fill("#a-name", "scenario-probe-do-not-add") await page.fill("#a-url", "ftp://example.invalid/feed") await asyncio.wait_for(submit.click(timeout=T), 15) await page.wait_for_function( "document.querySelector('#add-source-error').textContent.trim().length > 0", timeout=10000 ) out["error_text"] = (await page.locator("#add-source-error").inner_text()).strip()[:160] out["modal_still_open"] = await page.evaluate( "document.querySelector('#modal').classList.contains('show')" ) await page.keyboard.press("Escape") await page.wait_for_function( "!document.querySelector('#modal').classList.contains('show')", timeout=10000 ) await self.goto_hash("#/sources", "#src-tbl") await page.wait_for_function( "document.querySelectorAll('#src-tbl tbody tr').length > 5", timeout=25000 ) out["rows_after"] = await page.locator("#src-tbl tbody tr").count() self.out["scenarios"]["add_source"] = out assert out["url_invalid_when_empty"], "empty URL passed native validation" assert not out["error_after_empty_submit"], "native validation leaked an app error" assert "http" in out["error_text"].lower(), f"no friendly validation message: {out['error_text']!r}" assert "не удалось загрузить" not in out["error_text"].lower(), ( f"validation error disguised as a load failure: {out['error_text']!r}" ) assert out["modal_still_open"], "modal closed despite the rejected source" assert not out["covered_modal_controls"], f"modal controls untappable: {out['covered_modal_controls']}" assert out["rows_after"] == rows_before, ( f"rejected source leaked into the registry: {rows_before} -> {out['rows_after']}" ) async def keyboard(self): """Keyboard-only user: / search, Esc, ? help, digit navigation.""" page = self.page await page.set_viewport_size({"width": 1440, "height": 960}) await self.goto_hash("#/feed", "#list .item") out = {"rows_before": await page.locator("#list .item").count()} await page.keyboard.press("/") out["slash_focus"] = await page.evaluate("document.activeElement?.id || ''") needle = "telethon" # rare enough that a filtered page is smaller than the default one await page.keyboard.type(needle, delay=40) await page.wait_for_timeout(900) out["suggestions"] = await page.evaluate("document.querySelectorAll('#sug button').length") # Typing only fills the box: the list filters when the feed form is submitted. await page.keyboard.press("Enter") await page.wait_for_selector("#list .item", timeout=25000) await page.wait_for_timeout(1200) out["query"] = await page.input_value("#q") out["hash_after_query"] = await page.evaluate("location.hash") out["rows_after_query"] = await page.locator("#list .item").count() # A match can live in the body text, so compare the rendered ids with the API answer # instead of looking for the needle in visible text. out["ui_ids"] = await page.evaluate( "[...document.querySelectorAll('#list .item .reader-open')].map(b => Number(b.dataset.id))" ) out["api_ids"] = await page.evaluate( """async q => { const r = await fetch('/api/items?q=' + encodeURIComponent(q) + '&limit=40&sort=fresh'); const d = await r.json(); return (d.items || []).map(i => Number(i.id)); }""", needle, ) await page.keyboard.press("Escape") await page.wait_for_timeout(600) out["suggestions_hidden"] = await page.evaluate( "const s=document.querySelector('#sug'); !s || s.classList.contains('d-none')" ) await page.evaluate("document.activeElement && document.activeElement.blur()") await page.keyboard.press("?") await page.wait_for_selector("#modal.show", timeout=10000) # Bootstrap drops Escape during the fade-in transition await page.wait_for_timeout(450) out["help_title"] = (await page.locator("#modal-title").inner_text()).strip() await page.keyboard.press("Escape") await page.wait_for_function( "!document.querySelector('#modal').classList.contains('show')", timeout=10000 ) await page.keyboard.press("3") await page.wait_for_timeout(1000) out["hash_after_3"] = await page.evaluate("location.hash") await page.keyboard.press("2") await page.wait_for_timeout(1000) out["hash_after_2"] = await page.evaluate("location.hash") await self.no_collision("keyboard flow") self.out["scenarios"]["keyboard"] = out assert out["slash_focus"] == "q", f"/ did not focus search: {out['slash_focus']!r}" assert out["query"] == needle, f"search box lost the query: {out}" assert "q=" + needle in out["hash_after_query"], f"query did not reach the route: {out}" assert 0 < out["rows_after_query"] < out["rows_before"], f"search did not filter: {out}" assert out["ui_ids"] and set(out["ui_ids"]) == set(out["api_ids"]), ( f"rendered rows differ from the API answer: {out['ui_ids'][:6]} vs {out['api_ids'][:6]}" ) assert out["suggestions_hidden"], "suggestions stayed open after Escape" assert out["help_title"], "help modal opened without a title" assert "abuse" in out["hash_after_3"], f"digit 3 did not switch section: {out['hash_after_3']}" assert "feed" in out["hash_after_2"], f"digit 2 did not return to feed: {out['hash_after_2']}" async def adaptivity(self): """Narrow and wide screens: no horizontal scrolling, no covered controls.""" page = self.page routes = ("feed", "sources", "abuse", "overview") results = {} for width in (320, 390, 768, 1024, 1280, 1920): await page.set_viewport_size({"width": width, "height": 900}) # query before the hash forces a real document load for this width await page.goto( f"{self.url}/?v=w{width}#/feed", wait_until="domcontentloaded", timeout=30000 ) await page.wait_for_selector("#list .item", timeout=25000) per_route = {} for route in routes: await self.goto_hash(f"#/{route}") await page.wait_for_timeout(900) per_route[route] = await page.evaluate( """() => { const contained = el => { for (let p = el.parentElement; p && p !== document.body; p = p.parentElement) { const ox = getComputedStyle(p).overflowX; if ((ox === 'auto' || ox === 'scroll' || ox === 'hidden') && p.getBoundingClientRect().right <= window.innerWidth + 1) return true; } return false; }; return {scrollW: document.documentElement.scrollWidth, inner: window.innerWidth, spilled: [...document.querySelectorAll('#view *')] .filter(e => e.getBoundingClientRect().right > window.innerWidth + 1 && !contained(e)) .slice(0, 3) .map(e => (e.id ? '#' + e.id : '') + '.' + (e.className.toString().slice(0, 40)))}; }""" ) results[width] = per_route if width in (320, 768): await self.goto_hash("#/feed", "#list .item") results[f"covered@{width}"] = await find_covered( page, "#page-actions .btn, #view .nav-tabs .nav-link, #q, #sidebar-toggle", None ) self.out["scenarios"]["adaptivity"] = results overflow = [ {"width": width, "route": route, **m} for width, per_route in results.items() if isinstance(per_route, dict) for route, m in per_route.items() if m["scrollW"] > m["inner"] + 1 or m["spilled"] ] covered = {k: v for k, v in results.items() if str(k).startswith("covered@") and v} assert not overflow, f"horizontal overflow: {overflow[:4]}" assert not covered, f"untappable controls on narrow screens: {covered}" await page.set_viewport_size({"width": 1440, "height": 960}) async def zoom(self): page = self.page await page.set_viewport_size({"width": 1440, "height": 960}) for zoom in ("1", "1.25", "1.5"): await page.evaluate(f"document.documentElement.style.zoom='{zoom}'") for route in ("feed", "sources", "abuse", "overview"): await self.goto_hash(f"#/{route}") await page.wait_for_timeout(700) await self.no_collision(f"{route} @ zoom {zoom}") await page.evaluate("document.documentElement.style.zoom=''") async def screenshots(self): page = self.page await page.set_viewport_size({"width": 1440, "height": 960}) await self.goto_hash("#/feed", "#list .item") await page.screenshot(path=str(self.shots / "scenarios_feed.png")) await self.goto_hash("#/sources", "#src-tbl") await page.screenshot(path=str(self.shots / "scenarios_sources.png")) await self.goto_hash("#/overview") await page.wait_for_timeout(1200) await page.screenshot(path=str(self.shots / "scenarios_overview.png")) await page.set_viewport_size({"width": 390, "height": 860}) await self.goto_hash("#/feed", "#list .item") await page.screenshot(path=str(self.shots / "scenarios_mobile.png")) await page.set_viewport_size({"width": 1440, "height": 960}) SCENARIOS = [ ("deep_link", 90), ("exports", 90), ("reader", 90), ("digest", 90), ("sources_and_telegram", 90), ("add_source", 90), ("keyboard", 120), ("mobile", 90), ("adaptivity", 240), ("zoom", 150), ("screenshots", 90), ] async def run(url: str, shots: pathlib.Path) -> dict: shots.mkdir(parents=True, exist_ok=True) async with async_playwright() as pw: browser = await pw.chromium.launch() runner: Runner | None = None try: page = await browser.new_page(viewport={"width": 1440, "height": 960}) page.set_default_timeout(T) page.set_default_navigation_timeout(30000) runner = Runner(page, url, shots) page.on("pageerror", lambda e: runner.errors.append(f"pageerror: {e}")) page.on( "console", lambda m: runner.errors.append(f"console: {m.text}") if m.type == "error" else None, ) for name, budget in SCENARIOS: runner.out["stage"] = name print("[scenario]", name, flush=True) await asyncio.wait_for(getattr(runner, name)(), budget) runner.out["stage"] = "done" runner.out["page_errors"] = runner.errors[:8] assert not runner.errors, f"browser errors during scenarios: {runner.errors[:4]}" except BaseException as exc: # keep the report even when a scenario blows up if runner is not None: runner.out["error"] = f"{type(exc).__name__}: {exc}"[:400] runner.out["page_errors"] = runner.errors[:8] raise finally: if runner is not None: (shots / "report.json").write_text( json.dumps(runner.out, ensure_ascii=False, indent=2), encoding="utf-8" ) print(json.dumps(runner.out, ensure_ascii=False, indent=2)) try: await asyncio.wait_for(browser.close(), timeout=20) except Exception: pass return runner.out if runner else {} def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--url", default=BASE) parser.add_argument("--shots", default="data/shots/scenarios") args = parser.parse_args() asyncio.run(run(args.url, pathlib.Path(args.shots))) return 0 if __name__ == "__main__": raise SystemExit(main())