"""Read-only Chromium acceptance checks for the black OMNI panel. python tools/ui_smoke.py [--url http://127.0.0.1:8078/] [--shots data/shots/black_shadcn] """ import argparse import asyncio import json import pathlib import re import sys 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) errors, assets, external = [], {}, set() report = {} origin = urlsplit(url).netloc async with async_playwright() as playwright: browser = await playwright.chromium.launch() page = await browser.new_page(viewport={"width": 1440, "height": 960}) page.set_default_timeout(15000) page.set_default_navigation_timeout(30000) page.on("console", lambda m: errors.append(m.text) if m.type == "error" else None) page.on("pageerror", lambda e: errors.append(str(e))) def response_seen(response): parts = urlsplit(response.url) if parts.path.startswith("/assets/"): assets[parts.path] = response.status if parts.scheme in ("http", "https") and parts.netloc != origin: external.add(parts.netloc) page.on("response", response_seen) def step(name): report["stage"] = name print("[smoke]", name, flush=True) async def settled(): # The panel polls /api/health, so "networkidle" is a best-effort hint, never a gate. try: await page.wait_for_load_state("networkidle", timeout=5000) except Exception: pass async def navigate(view): nav = page.locator(f'[data-nav="{view}"]').first if not await nav.is_visible(): summary = page.locator("#system-nav > summary") if await summary.count() and await page.locator("#system-nav").get_attribute("open") is None: await summary.click() await nav.click() await page.wait_for_function("v => location.hash.startsWith('#/' + v)", arg=view) await settled() async def assert_no_overflow(label): size = await page.evaluate("({scroll:document.documentElement.scrollWidth, width:innerWidth})") assert size["scroll"] <= size["width"] + 1, f"Horizontal overflow at {label}: {size}" async def assert_no_text_collision(label): """Only counts overlaps where both text nodes are actually painted on top of each other.""" hits = await page.evaluate("""() => { 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 overlapX = Math.min(a.rect.right, b.rect.right) - Math.max(a.rect.left, b.rect.left); const overlapY = Math.min(a.rect.bottom, b.rect.bottom) - Math.max(a.rect.top, b.rect.top); if (overlapX <= 3 || overlapY <= 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 found.slice(0, 5); }""") 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'); canvas.width = canvas.height = 1; const ctx = canvas.getContext('2d', {willReadFrequently: true}); const selectors = ['body', '.navbar', '.card', '.list-group-item', '.form-control', '.form-select', '.table', '.table thead', '.offcanvas.show', '.modal.show .modal-content', '.dropdown-menu.show']; return selectors.flatMap(selector => [...document.querySelectorAll(selector)] .filter(el => el.getClientRects().length && getComputedStyle(el).visibility !== 'hidden') .slice(0, 12).map(el => { const bg = getComputedStyle(el).backgroundColor; ctx.clearRect(0, 0, 1, 1); ctx.fillStyle = bg; ctx.fillRect(0, 0, 1, 1); return {selector, bg, rgba: [...ctx.getImageData(0, 0, 1, 1).data]}; })); }""") assert await page.evaluate("matchMedia('(prefers-color-scheme: dark)').matches || getComputedStyle(document.documentElement).colorScheme.includes('dark')") assert any(c["selector"] == "body" and c["rgba"][3] == 255 for c in colors), colors light = [c for c in colors if c["rgba"][3] > 127 and max(c["rgba"][:3]) > 64] assert not light, f"Light surfaces in {label}: {light}" report.setdefault("dark_routes", []).append(label) try: await page.goto(url, wait_until="domcontentloaded", timeout=30000) await page.wait_for_selector("#list .item", timeout=25000) await settled() assert await page.locator('[data-nav="feed"]').count() == 1 report["default_feed"] = await page.locator("#list .item").count() report["navigation_routes"] = await page.locator("[data-nav]").count() 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")) # A real search must update both results and the total, not just the input. missing = "zz_omni_smoke_no_match_7f91" async with page.expect_response(lambda r: "/api/items?" in r.url and missing in r.url) as pending: await page.locator("#q").fill(missing) await page.locator("#q").press("Enter") searched = await (await pending.value).json() await settled() assert searched["total"] == 0 assert await page.locator("#list .item").count() == 0 report["search_empty_state"] = True await page.locator("#q").fill("") await page.locator("#q").press("Enter") await page.wait_for_selector("#list .item", timeout=20000) await settled() # Reading panel: real detail response, keyboard focus containment and Escape. row = page.locator("#list .item").first opener = row.locator("[data-id]").first if await row.locator("[data-id]").count() else row async with page.expect_response(re.compile(r"/api/item/\d+")) as detail_pending: await opener.click() detail = await (await detail_pending.value).json() assert detail.get("ok") and detail.get("item", {}).get("title") await page.wait_for_selector("#drawer.show", timeout=15000) await settled() assert (await page.locator("#d-title").inner_text()).strip() await assert_dark_theme("reader") step("reader") await page.screenshot(path=str(out / "black_reader.png")) focusables = page.locator('#drawer a[href], #drawer button:not([disabled]), #drawer input:not([disabled])') if await focusables.count(): await focusables.last.focus() await page.keyboard.press("Tab") assert await page.evaluate("document.querySelector('#drawer').contains(document.activeElement)") await focusables.first.focus() await page.keyboard.press("Shift+Tab") assert await page.evaluate("document.querySelector('#drawer').contains(document.activeElement)") await page.keyboard.press("Escape") await page.wait_for_function("!document.querySelector('#drawer').classList.contains('show')") await page.locator("#drawer").wait_for(state="hidden") assert await opener.evaluate("el => document.activeElement === el"), "Reader did not restore focus" step("reader-keys") await page.locator('[data-act="help"]').first.click() step("help-clicked") await page.wait_for_selector("#modal.show") step("help-shown") # Bootstrap ignores Escape while the modal is still fading in. await page.wait_for_timeout(400) await settled() step("help-settled") await assert_dark_theme("help modal") step("help-dark") await page.keyboard.press("Escape") step("help-escape") await page.locator("#modal").wait_for(state="hidden") step("help-hidden") report["reader_keyboard"] = True # Source filtering is a real API query, not a dead text field. step("help-modal") await navigate("sources") await page.wait_for_selector("#src-tbl") async with page.expect_response(lambda r: "/api/sources?" in r.url and missing in r.url) as src_pending: await page.locator("#s-q").fill(missing) source_search = await (await src_pending.value).json() await settled() assert not source_search["items"] report["source_search"] = True await page.locator("#s-q").fill("") await settled() await assert_no_text_collision("sources 1440") await assert_dark_theme("sources") await page.screenshot(path=str(out / "black_sources.png")) step("sources") await navigate("abuse") await page.wait_for_selector("#ab-list .item", timeout=20000) report["find_rows"] = await page.locator("#ab-list .item").count() assert report["find_rows"] > 0, "Findings tab rendered no rows" await assert_no_text_collision("abuse 1440") await assert_dark_theme("abuse") await page.screenshot(path=str(out / "black_finds.png")) async with page.expect_response(lambda r: urlsplit(r.url).path == "/api/panels") as panels_pending: await page.locator('[data-ab-tab="panels"]').click() panels = await (await panels_pending.value).json() await settled() assert isinstance(panels.get("panels"), list), panels.keys() assert await page.locator("#pn-box").is_visible() await assert_dark_theme("api panels") report["api_panels"] = len(panels["panels"]) for view, selector in (("runs", "#r-tbl"), ("tg", ".card"), ("keys", "#k-tbl"), ("watch", ".card"), ("overview", ".card")): await navigate(view) await page.wait_for_selector(selector) await assert_dark_theme(view) await page.screenshot(path=str(out / f"black_{view}.png")) report[view] = "rendered" step("routes") await navigate("digest") async with page.expect_response(lambda r: "/api/digest?" in r.url) as digest_pending: await page.locator('[data-act="digest"]').click() digest = await (await digest_pending.value).json() await settled() assert digest.get("ok") and len(digest.get("md", "")) > 80 assert len((await page.locator("#d-out").inner_text()).strip()) > 80 report["digest"] = "rendered" await assert_dark_theme("digest") await page.screenshot(path=str(out / "black_digest.png")) step("digest") # Deep-link source filters and both exported formats must stay in sync. sample = await (await page.request.get(url.rstrip("/") + "/api/items?limit=1")).json() source = sample["items"][0]["source"] deep_link = url.rstrip("/") + "/#/feed?sort=fresh&days=3650&src=" + quote(source, safe="") await page.goto(deep_link, wait_until="domcontentloaded") await page.wait_for_selector("#list .item") await settled() for extension in ("json", "csv"): link = page.locator(f'a[href*="/api/export.{extension}"]').first href = await link.get_attribute("href") assert href is not None query = parse_qs(urlsplit(href).query) assert query.get("src") == [source] and query.get("days") == ["3650"] exported = await page.request.get(urljoin(url, href)) assert exported.status == 200 if extension == "json": data = await exported.json() assert data["count"] and all(item["source"] == source for item in data["items"]) report["filtered_exports"] = True step("exports") # Mobile navigation has labels, a real toggle, and no page overflow. await page.set_viewport_size({"width": 390, "height": 844}) 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")) await page.locator('[data-nav="feed"]').click() await settled() await assert_no_overflow("mobile 390") await page.screenshot(path=str(out / "black_mobile.png")) await page.set_viewport_size({"width": 768, "height": 1024}) await assert_no_overflow("tablet 768") await assert_no_text_collision("tablet 768") for width in (1100, 1024): await page.set_viewport_size({"width": width, "height": 900}) await assert_no_overflow(f"desktop {width}") await assert_no_text_collision(f"desktop {width}") report["responsive"] = [390, 768, 1024, 1100, 1440] assert assets.get("/assets/vendor/tabler/tabler.min.css") == 200 assert assets.get("/assets/vendor/tabler/tabler.min.js") == 200 assert not external, f"External asset/API dependencies: {sorted(external)}" assert not errors, errors finally: report["assets"] = assets report["external_hosts"] = sorted(external) report["console_errors"] = errors (out / "report.json").write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") print(json.dumps(report, ensure_ascii=False, indent=2)) try: await asyncio.wait_for(browser.close(), timeout=20) except Exception: pass return 0 def main(): parser = argparse.ArgumentParser() parser.add_argument("--url", default="http://127.0.0.1:8078/") parser.add_argument("--shots", default="data/shots/black_shadcn") args = parser.parse_args() sys.exit(asyncio.run(run(args.url, pathlib.Path(args.shots)))) if __name__ == "__main__": main()