Files
omni-parse/tests/test_static_assets.py
Vlad Gothbreach 92bf9eaa12
Some checks failed
ci / test (3.10) (push) Has been cancelled
ci / test (3.11) (push) Has been cancelled
ci / test (3.12) (push) Has been cancelled
fix(ui): пункты меню не прячутся под футером; сквозные сценарии панели
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 пуст.
2026-10-27 05:45:14 +03:00

161 lines
7.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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