239 lines
12 KiB
Python
239 lines
12 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
|
|
|
|
|
|
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.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)
|
|
|
|
async def settled():
|
|
await page.wait_for_load_state("networkidle", timeout=25000)
|
|
|
|
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_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_dark_theme("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")
|
|
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"
|
|
await page.locator('[data-act="help"]').first.click()
|
|
await page.wait_for_selector("#modal.show")
|
|
await settled()
|
|
await assert_dark_theme("help modal")
|
|
await page.keyboard.press("Escape")
|
|
await page.locator("#modal").wait_for(state="hidden")
|
|
report["reader_keyboard"] = True
|
|
|
|
# Source filtering is a real API query, not a dead text field.
|
|
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_dark_theme("sources")
|
|
await page.screenshot(path=str(out / "black_sources.png"))
|
|
|
|
await navigate("abuse")
|
|
report["find_rows"] = await page.locator("#ab-list .item").count()
|
|
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"
|
|
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"))
|
|
|
|
# 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
|
|
|
|
# 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_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")
|
|
report["responsive"] = [390, 768, 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))
|
|
await browser.close()
|
|
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()
|