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 пуст.
161 lines
7.1 KiB
Python
161 lines
7.1 KiB
Python
"""Local UI dependencies are served without exposing project data."""
|
||
import http.client
|
||
import pathlib
|
||
import re
|
||
import threading
|
||
from http.server import ThreadingHTTPServer
|
||
|
||
import pytest
|
||
|
||
from omni import serve
|
||
|
||
|
||
@pytest.fixture
|
||
def static_server(tmp_path, monkeypatch):
|
||
web = tmp_path / "web"
|
||
vendor = web / "vendor" / "tabler"
|
||
vendor.mkdir(parents=True)
|
||
(vendor / "tabler.min.css").write_text("body{color:#123}", encoding="utf-8")
|
||
(vendor / "tabler.min.js").write_text("window.tabler={};", encoding="utf-8")
|
||
(web / "index.html").write_text("<html>panel</html>", encoding="utf-8")
|
||
(web / "private.js").write_text("private", encoding="utf-8")
|
||
(vendor / "secret.json").write_text('{"secret":"test-only"}', encoding="utf-8")
|
||
(tmp_path / "outside.js").write_text("outside", encoding="utf-8")
|
||
monkeypatch.setattr(serve, "WEB", web)
|
||
server = ThreadingHTTPServer(("127.0.0.1", 0), serve.Handler)
|
||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||
thread.start()
|
||
|
||
def get(path):
|
||
connection = http.client.HTTPConnection("127.0.0.1", server.server_port, timeout=5)
|
||
try:
|
||
connection.request("GET", path)
|
||
response = connection.getresponse()
|
||
return response.status, dict(response.getheaders()), response.read()
|
||
finally:
|
||
connection.close()
|
||
|
||
try:
|
||
yield get
|
||
finally:
|
||
server.shutdown()
|
||
server.server_close()
|
||
thread.join(timeout=5)
|
||
|
||
|
||
@pytest.mark.parametrize("name,ctype,expected", [
|
||
("tabler.min.css", "text/css; charset=utf-8", b"body{color:#123}"),
|
||
("tabler.min.js", "text/javascript; charset=utf-8", b"window.tabler={};"),
|
||
])
|
||
def test_local_template_assets(static_server, name, ctype, expected):
|
||
status, headers, body = static_server("/assets/vendor/tabler/" + name + "?v=1.7.1")
|
||
assert status == 200 and body == expected
|
||
assert headers["Content-Type"] == ctype
|
||
assert headers["X-Content-Type-Options"] == "nosniff"
|
||
assert int(headers["Content-Length"]) == len(expected)
|
||
|
||
|
||
@pytest.mark.parametrize("path", [
|
||
"/assets/vendor/tabler/missing.js",
|
||
"/assets/vendor/tabler/",
|
||
"/assets/vendor/tabler/secret.json",
|
||
"/assets/private.js",
|
||
"/assets/index.html",
|
||
"/assets/vendor/../../private.js",
|
||
"/assets/vendor/%2e%2e/%2e%2e/outside.js",
|
||
"/assets/%2foutside.js",
|
||
"/assets/vendor/..%5c..%5cprivate.js",
|
||
"/assets/vendor/%00.js",
|
||
])
|
||
def test_assets_reject_private_files_and_traversal(static_server, path):
|
||
status, _, body = static_server(path)
|
||
assert status == 404
|
||
assert body == b"not found"
|
||
|
||
|
||
def test_index_still_served(static_server):
|
||
status, headers, body = static_server("/")
|
||
assert status == 200 and body == b"<html>panel</html>"
|
||
assert headers["Content-Type"].startswith("text/html")
|
||
|
||
|
||
WEB_ROOT = pathlib.Path(__file__).resolve().parents[1] / "web"
|
||
|
||
|
||
def test_panel_html_is_self_contained_black():
|
||
html = (WEB_ROOT / "index.html").read_text(encoding="utf-8")
|
||
# Black theme is declared three ways so first paint cannot flash white.
|
||
assert 'data-bs-theme="dark"' in html
|
||
assert "color-scheme: dark" in html
|
||
assert "--omni-background: #09090b" in html
|
||
# No remote dependencies: every src/href must stay inside /assets/ or be a data URL.
|
||
for attr in re.findall(r'(?:src|href)="([^"]+)"', html):
|
||
assert not attr.startswith(("http://", "https://", "//")), attr
|
||
assert "/assets/vendor/tabler/tabler.min.css" in html
|
||
assert "/assets/vendor/tabler/tabler.min.js" in html
|
||
|
||
|
||
def test_panel_self_hosts_inter_for_latin_and_cyrillic():
|
||
html = (WEB_ROOT / "index.html").read_text(encoding="utf-8")
|
||
assert html.count('font-family: "Inter Variable"') == 4
|
||
assert 'format("woff2-variations")' not in html # rejected by Firefox/Safari
|
||
assert html.count('format("woff2")') == 4
|
||
assert html.count("font-display: swap") == 4
|
||
assert "--tblr-font-sans-serif: \"Inter Variable\"" in html
|
||
files = WEB_ROOT / "vendor" / "inter" / "files"
|
||
for subset in ("latin", "latin-ext", "cyrillic", "cyrillic-ext"):
|
||
face = files / f"inter-{subset}-wght-normal.woff2"
|
||
assert face.is_file() and face.stat().st_size > 2000, face
|
||
assert f"/assets/vendor/inter/files/{face.name}" in html
|
||
licence = (WEB_ROOT / "vendor" / "inter" / "LICENSE").read_text(encoding="utf-8")
|
||
assert "SIL OPEN FONT LICENSE" in licence.upper()
|
||
|
||
|
||
def test_panel_typography_does_not_clip_cyrillic():
|
||
html = (WEB_ROOT / "index.html").read_text(encoding="utf-8")
|
||
badge = re.search(r"\.badge \{[^}]*\}", html)
|
||
assert badge and "line-height: 1.4" in badge.group(0)
|
||
assert "letter-spacing: -.01em" not in html # negative tracking on Cyrillic titles
|
||
for selector in (".brand-caption", ".feed-title", ".feed-preview"):
|
||
rule = re.search(r"(?m)^" + re.escape(selector) + r" \{[^}]*\}", html)
|
||
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 '<h3 class="alert-title">${esc(title)}</h3>' 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
|