web/index.html: - мобильное меню после раскрытия не держит анимационную высоту Bootstrap: нижние пункты (Обзор/История/API-ключи) уезжали под .sidebar-footer и не нажимались - на десктопе рельс #nav скроллится сам, футер не сжимается; активный пункт доскролливается в область видимости - failure() принимает заголовок: ошибка валидации источника больше не подписана «Не удалось загрузить данные» tools/reachability.py: общая проверка «элемент виден, но перекрыт другим» — hit-test по центру с scrollIntoView, пропуск скрытых узлов и закрытых <details>. tools/panel_scenarios.py: 11 сквозных сценариев реального пользователя — deep-link фильтр, экспорт CSV/JSON по байтам, читалка и ссылка на оригинал, дайджест, поиск источников, Telegram-сессия, форма источника (отказ без записи в реестр), клавиатурный flow (/ ? Esc 1-9) со сверкой строк ленты с API, мобильное меню, адаптивность 320-1920 без горизонтального переполнения, зум. tools/ui_smoke.py: reachability-проверки десктопного рельса, контролов и мобильного меню. tests: +3 регрессии на CSS/заголовки ошибок; pytest 93 passed, smoke exit 0, сценарии exit 0, page_errors пуст.
333 lines
18 KiB
Python
333 lines
18 KiB
Python
"""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()
|