diff --git a/.gitignore b/.gitignore index 69734fa..a261c98 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ __pycache__/ .bot_token *.session* .local/ +# scratch/debug artifacts in the repo root (probes, logs) - never commit +/_* diff --git a/tests/test_static_assets.py b/tests/test_static_assets.py index ab0502b..d0ec271 100644 --- a/tests/test_static_assets.py +++ b/tests/test_static_assets.py @@ -121,3 +121,40 @@ def test_panel_typography_does_not_clip_cyrillic(): assert rule, selector line_height = re.search(r"line-height: ([\d.]+)", rule.group(0)) assert line_height and float(line_height.group(1)) >= 1.4, rule.group(0) + + +def test_sidebar_menu_never_clamps_under_its_footer(): + """Mobile kept Bootstrap's animated height, so the lower nav links slid under .sidebar-footer: + they had boxes in DevTools but no tap reached them. Desktop rail scrolls instead of overlapping.""" + html = (WEB_ROOT / "index.html").read_text(encoding="utf-8") + mobile = re.search(r"@media \(max-width: 991\.98px\) \{(.*?)\n\}", html, re.S) + assert mobile, "mobile media query missing" + assert "#sidebar-menu.show:not(.collapsing) { height: auto !important" in mobile.group(1) + assert "#sidebar-menu > #nav { flex: 0 0 auto; overflow: visible; }" in mobile.group(1) + assert ".sidebar-footer { flex: 0 0 auto;" in mobile.group(1) + desktop = re.search(r"@media \(min-width: 992px\) \{(.*?)\n\}", html, re.S) + assert desktop, "desktop media query missing" + assert "#sidebar-menu > #nav { flex: 1 1 auto; min-height: 0; overflow-y: auto" in desktop.group(1) + assert re.search(r"(?m)^\.sidebar-footer \{ flex: 0 0 auto;", html), "footer must not shrink" + + +def test_form_errors_are_labelled_for_their_situation(): + """A rejected source used to render «Не удалось загрузить данные» - a load failure title + for a plain validation mistake. failure() now takes a title, and the form passes its own.""" + html = (WEB_ROOT / "index.html").read_text(encoding="utf-8") + assert 'function failure(error,retry,title="Не удалось загрузить данные")' in html + assert '

${esc(title)}

' in html + call = ( + 'failure(new Error("Нужен адрес с протоколом https:// или http://")' + ',null,"Проверьте адрес источника")' + ) + assert call in html + + +def test_active_nav_scrolls_into_view_in_the_desktop_rail(): + """The desktop rail scrolls, so a deep-linked section below the fold must reveal itself.""" + html = (WEB_ROOT / "index.html").read_text(encoding="utf-8") + assert ( + 'if(rail.scrollHeight>rail.clientHeight)' + 'rail.querySelector(".nav-link.active")?.scrollIntoView({block:"nearest"})' + ) in html diff --git a/tools/panel_scenarios.py b/tools/panel_scenarios.py new file mode 100644 index 0000000..8eac024 --- /dev/null +++ b/tools/panel_scenarios.py @@ -0,0 +1,574 @@ +"""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()) diff --git a/tools/reachability.py b/tools/reachability.py new file mode 100644 index 0000000..b2ec6b0 --- /dev/null +++ b/tools/reachability.py @@ -0,0 +1,56 @@ +"""Pointer-reachability check for the OMNI panel. + +A control whose centre is covered by another element cannot be clicked by a human, and +Playwright stalls on it instead of failing fast. Regression guard for the mobile sidebar +bug where the collapsed menu kept a fixed height: nav links slid under `.sidebar-footer`, +so the last items ("Обзор", "Запуски", "Ключи") were visible in DevTools but untappable. + +Used by tools/ui_smoke.py and tools/panel_scenarios.py. +""" +from __future__ import annotations + +from typing import Any, Optional + +from playwright.async_api import Page + +JS = """(args) => { + const describe = (el) => { + if (!el) return 'none'; + const chain = []; + let cur = el; + for (let i = 0; i < 3 && cur; i++) { + const cls = typeof cur.className === 'string' && cur.className + ? '.' + cur.className.trim().split(/\\s+/).slice(0, 2).join('.') : ''; + chain.push(cur.tagName.toLowerCase() + (cur.id ? '#' + cur.id : '') + cls); + cur = cur.parentElement; + } + return chain.join(' < '); + }; + const covered = []; + const els = [...document.querySelectorAll(args.selector)]; + els.forEach((el, i) => { + const key = (args.keyAttr && el.dataset[args.keyAttr]) || el.id + || (el.textContent || '').trim().slice(0, 28) || (args.selector + '[' + i + ']'); + const details = el.closest('details'); + if (details && !details.open) return; + const style = getComputedStyle(el); + if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) return; + if (el.closest('#sug:not(.show), #drawer:not(.show), #modal:not(.show)')) return; + el.scrollIntoView({block: 'center'}); + const r = el.getBoundingClientRect(); + if (r.width < 2 || r.height < 2) return; + const x = r.left + r.width / 2, y = r.top + r.height / 2; + if (x < 0 || y < 0 || x > innerWidth || y > innerHeight) return; + const top = document.elementFromPoint(x, y); + if (top && (top === el || el.contains(top))) return; + covered.push({key, covered_by: describe(top), at: [Math.round(x), Math.round(y)], + box: {x: Math.round(r.left), y: Math.round(r.top), + w: Math.round(r.width), h: Math.round(r.height)}}); + }); + return covered.slice(0, 12); +}""" + + +async def find_covered(page: Page, selector: str, key_attr: Optional[str] = None) -> list[dict[str, Any]]: + """Return controls matched by `selector` whose centre is painted over by another element.""" + return await page.evaluate(JS, {"selector": selector, "keyAttr": key_attr}) diff --git a/tools/ui_smoke.py b/tools/ui_smoke.py index 5b8d152..c4ea0c1 100644 --- a/tools/ui_smoke.py +++ b/tools/ui_smoke.py @@ -12,6 +12,9 @@ from urllib.parse import parse_qs, quote, urljoin, urlsplit from playwright.async_api import async_playwright +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) +from reachability import find_covered # noqa: E402 + async def run(url, out): out.mkdir(parents=True, exist_ok=True) @@ -96,6 +99,12 @@ async def run(url, out): assert not hits, f"Text painted on text at {label}: {hits}" report.setdefault("no_text_collision", []).append(label) + async def assert_reachable(label, selector, key_attr=None): + """Fail when a visible control is painted over: a human could not click it.""" + covered = await find_covered(page, selector, key_attr) + assert not covered, f"Unreachable controls at {label}: {covered}" + report.setdefault("reachable", []).append(label) + async def assert_dark_theme(label): colors = await page.evaluate("""() => { const canvas = document.createElement('canvas'); @@ -130,6 +139,11 @@ async def run(url, out): assert report["navigation_routes"] == 9 await assert_no_overflow("desktop") await assert_no_text_collision("feed 1440") + await assert_reachable("desktop rail 1440", "#sidebar-menu [data-nav]", "nav") + await assert_reachable( + "desktop controls 1440", + "#page-actions .btn, #view .nav-tabs .nav-link, #export-csv, #export-json, #q", + ) await assert_dark_theme("feed") step("feed") await page.screenshot(path=str(out / "black_feed.png")) @@ -268,6 +282,11 @@ async def run(url, out): await page.locator("#sidebar-toggle").click() await page.locator("#sidebar-menu").wait_for(state="visible") assert await page.locator('[data-nav="sources"]').is_visible() + await assert_reachable( + "mobile menu 390", + "#sidebar-menu [data-nav], .sidebar-footer button, #sidebar-toggle", + "nav", + ) await assert_no_overflow("mobile navigation 390") await assert_dark_theme("mobile navigation") await page.screenshot(path=str(out / "black_mobile_navigation.png")) diff --git a/web/index.html b/web/index.html index 4f7f673..abd57c9 100644 --- a/web/index.html +++ b/web/index.html @@ -170,7 +170,7 @@ a:hover { color: var(--omni-foreground); } .navbar-vertical .nav-link-icon .icon { width: var(--omni-space-4); height: var(--omni-space-4); } .navbar details > summary { cursor: pointer; } .navbar details[open] > summary { margin-bottom: var(--omni-space-2); } -.sidebar-footer { padding: var(--omni-space-4) var(--omni-space-3); } +.sidebar-footer { flex: 0 0 auto; padding: var(--omni-space-4) var(--omni-space-3); } #health { line-height: 1.6; } .sidebar-footer .btn { padding-inline: 0; white-space: normal; text-align: start; } #sidebar-toggle { position: static; inset: auto; transform: none; width: auto; min-width: 5.5rem; height: auto; min-height: var(--omni-control-height); flex: 0 0 auto; gap: var(--omni-space-2); padding: var(--omni-space-2) var(--omni-space-3); border: 1px solid var(--omni-border); border-radius: var(--omni-radius); color: var(--omni-foreground); font-size: var(--omni-text-base); background: var(--omni-background); box-shadow: none; } @@ -277,15 +277,19 @@ pre { padding: var(--omni-space-4); } @media (min-width: 992px) { .navbar.navbar-vertical > .container-fluid { height: 100%; padding: var(--omni-space-6) var(--omni-space-2) 0; } .navbar.navbar-vertical .navbar-brand { padding: 0 var(--omni-space-3) var(--omni-space-6); } - #sidebar-menu { flex: 1; align-items: stretch; } + #sidebar-menu { display: flex; flex-direction: column; flex: 1; align-items: stretch; min-height: 0; } + #sidebar-menu > #nav { flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain; } } @media (max-width: 991.98px) { .navbar.navbar-vertical { border-inline-end: 0; border-bottom: 1px solid var(--omni-border); } .navbar.navbar-vertical > .container-fluid { padding: var(--omni-space-3) var(--omni-space-4); justify-content: space-between; flex-wrap: wrap; } .navbar.navbar-vertical .navbar-brand { flex: 1; padding: 0; } - #sidebar-menu { flex-basis: 100%; } + #sidebar-menu { flex-basis: 100%; flex-direction: column; align-items: stretch; } + /* Без фикс-высоты после раскрытия: иначе список уезжает под футер и пункты не нажимаются. */ + #sidebar-menu.show:not(.collapsing) { height: auto !important; max-height: none !important; overflow: visible !important; } + #sidebar-menu > #nav { flex: 0 0 auto; overflow: visible; } .navbar-vertical .navbar-nav { padding-inline: 0; } - .sidebar-footer { margin-top: var(--omni-space-4) !important; padding-inline: 0; } + .sidebar-footer { flex: 0 0 auto; margin-top: var(--omni-space-4) !important; padding-inline: 0; } .page-wrapper .container-xl { padding-inline: var(--omni-space-6); } } @media (max-width: 575.98px) { @@ -407,7 +411,7 @@ async function api(path,opt={}){ const post=(path,body)=>api(path,{method:"POST",body:JSON.stringify(body||{})}); async function copy(value){if(!value){toast("Нет данных для копирования",true);return}try{if(!navigator.clipboard)throw new Error("clipboard");await navigator.clipboard.writeText(value);toast("Скопировано")}catch{toast("Не удалось скопировать. Разрешите доступ к буферу обмена.",true)}} const retries=new Map();let retrySerial=0; -function failure(error,retry){const id=String(++retrySerial);if(retry){retries.set(id,retry);if(retries.size>40)retries.delete(retries.keys().next().value)}return ``} +function failure(error,retry,title="Не удалось загрузить данные"){const id=String(++retrySerial);if(retry){retries.set(id,retry);if(retries.size>40)retries.delete(retries.keys().next().value)}return ``} function loading(label="Загрузка данных…",rows=3){return `
${esc(label)}
`} function empty(title,description,action=""){return `

${esc(title)}

${esc(description)}

${action?`
${action}
`:""}
`} const BUSY=new Set(); @@ -458,6 +462,7 @@ function paintNav(){ const systemOpen=$("#system-nav")?.open||["overview","runs","keys"].includes(S.view); const link=id=>{const n=NAV.find(v=>v.id===id);return ``}; $("#nav").innerHTML=`
Рабочая область
Инструменты
Система
`; + const rail=$("#nav");if(rail.scrollHeight>rail.clientHeight)rail.querySelector(".nav-link.active")?.scrollIntoView({block:"nearest"}); } function pageHeader(){const n=NAV.find(n=>n.id===S.view);document.title="OMNI — "+n.label;$("#crumb").textContent=n.label;$("#page-group").textContent=n.group;$("#page-description").textContent=n.description; const primary={feed:'',sources:'',abuse:S.tab==="signals"?'':"",digest:'',overview:'Открыть ленту'}; @@ -548,7 +553,7 @@ async function startJob(path,body,key,el,label,after){return busy(el,key,async() function showModal(title,body,footer){modalReturnFocus=document.activeElement;$("#modal-box").innerHTML=``;tabler.Modal.getOrCreateInstance($("#modal")).show()} $("#modal").addEventListener("hidden.bs.modal",()=>{if(modalReturnFocus?.isConnected)modalReturnFocus.focus({preventScroll:true});modalReturnFocus=null}); function addSource(){showModal("Новый источник",`
${S.cats.map(c=>`
`,'')} -async function saveSource(){const origin=activeContext,body={name:$("#a-name").value.trim(),category:$("#a-cat").value.trim()||"community",url:$("#a-url").value.trim(),adapter:$("#a-adapter").value,limit:int($("#a-lim").value,1,500),tags:$("#a-tags").value.split(",").map(t=>t.trim()).filter(Boolean)};if(!safeUrl(body.url)){$("#add-source-error").innerHTML=failure(new Error("Нужен адрес с протоколом https:// или http://"));return}try{await post("/api/sources/add",body);tabler.Modal.getOrCreateInstance($("#modal")).hide();toast("Источник добавлен");if(origin.valid())await render()}catch(error){if($("#add-source-error"))$("#add-source-error").innerHTML=failure(error)}} +async function saveSource(){const origin=activeContext,body={name:$("#a-name").value.trim(),category:$("#a-cat").value.trim()||"community",url:$("#a-url").value.trim(),adapter:$("#a-adapter").value,limit:int($("#a-lim").value,1,500),tags:$("#a-tags").value.split(",").map(t=>t.trim()).filter(Boolean)};if(!safeUrl(body.url)){$("#add-source-error").innerHTML=failure(new Error("Нужен адрес с протоколом https:// или http://"),null,"Проверьте адрес источника");return}try{await post("/api/sources/add",body);tabler.Modal.getOrCreateInstance($("#modal")).hide();toast("Источник добавлен");if(origin.valid())await render()}catch(error){if($("#add-source-error"))$("#add-source-error").innerHTML=failure(error)}} async function bulkToggle(on,el){const names=selectedSrc();if(!names.length){toast("Выберите источники",true);return}await busy(el,"source-write",async()=>{const ctx=activeContext;for(const name of names)await post("/api/sources/toggle",{name,enabled:on});toast(`${on?"Включено":"Выключено"}: ${names.length}`);if(ctx.valid())await loadSources(ctx)})} async function toggleSource(name,on,el,detail=false){await busy(el,"source-write",async()=>{const ctx=activeContext,reader=readerVersion;try{await post("/api/sources/toggle",{name,enabled:on});toast(on?"Источник включён":"Источник выключен");if(ctx.valid()&&S.view==="sources")await loadSources(ctx);if(detail&&reader===readerVersion&&$("#drawer").classList.contains("show"))await srcDetail(name)}catch(error){if(el?.type==="checkbox")el.checked=!on;throw error}})} async function addWatch(q,type="all",el){if(q.trim().length<2){toast("Введите не менее двух символов",true);return}await busy(el,"watch-write",async()=>{const ctx=activeContext;await post("/api/watch/add",{q:q.trim(),type});toast("Правило отслеживания добавлено");if(ctx.valid()&&S.view==="watch")await render()})}