hugin/scripts/csp/web_actions.py
2026-10-27 08:33:21 +01:00

203 lines
12 KiB
Python

"""Weboberfläche im echten Chrome bedienen: Live-Suche, Tabs, Kopieren, Rückfragen, Formulare.
Ergänzt web_browser.py (CSP und Bildschirmfotos) um das, was nur mit JavaScript im Browser
prüfbar ist: htmx-Trigger, hugin.js und die Aktionen des Frameworks auf den echten Seiten.
uv run --project server --with websockets --with httpx python scripts/csp/web_actions.py
"""
import asyncio
import os
import subprocess
import sys
import tempfile
import time
from pathlib import Path
import httpx
import websockets
sys.path.insert(0, str(Path(__file__).parent))
from web_browser import ADMIN, CHROME, ROOT, cdp, cookie_for, create_data, free_port # noqa: E402
async def js(ws, expression):
result = await cdp(ws, "Runtime.evaluate", expression=expression, returnByValue=True, awaitPromise=True)
return result["result"].get("value")
async def press(ws, key, code, key_code):
for kind in ("keyDown", "keyUp"):
await cdp(ws, "Input.dispatchKeyEvent", type=kind, key=key, code=code, windowsVirtualKeyCode=key_code)
async def run(port_chrome: int, url: str, cookies: dict[str, str]) -> bool:
target = httpx.put(f"http://127.0.0.1:{port_chrome}/json/new?about:blank").json()
results = []
def check(name, ok):
results.append(bool(ok))
print(("OK " if ok else "FEHLT ") + name)
async with websockets.connect(target["webSocketDebuggerUrl"], max_size=50_000_000) as ws:
await cdp(ws, "Page.enable")
await cdp(ws, "Network.enable")
async def go(path, who):
await cdp(ws, "Network.clearBrowserCookies")
await cdp(ws, "Network.setCookie", name="hugin_session", value=cookies[who], url=url)
await cdp(ws, "Page.navigate", url=url + path)
await asyncio.sleep(1.2)
# Topbar: Abmelden bleibt sichtbar, auch unten auf langen Seiten und auf dem Handy
logout_visible = ("(function () { var b = [...document.querySelectorAll('button')]"
".find(b => b.textContent.trim() == 'Abmelden'); var r = b.getBoundingClientRect();"
"return r.width > 0 && r.top >= 0 && r.bottom <= innerHeight && r.left >= 0"
" && r.right <= document.documentElement.clientWidth; })()")
await cdp(ws, "Emulation.setDeviceMetricsOverride", width=1280, height=800, deviceScaleFactor=1, mobile=False)
await go("/manage?tenant=shop", "Admin")
await js(ws, "scrollTo(0, document.body.scrollHeight)")
await asyncio.sleep(0.3)
check("Topbar: Abmelden unten auf langer Seite sichtbar", await js(ws, logout_visible))
await cdp(ws, "Emulation.setDeviceMetricsOverride", width=390, height=844, deviceScaleFactor=1, mobile=True)
await go("/packages/pub-munin-nutzen/versions/1.4.0", "Shop CI")
check("Topbar: auf dem Handy nichts abgeschnitten", await js(ws, logout_visible))
await js(ws, "document.querySelector('[data-action=sidebar-open]').click()")
await asyncio.sleep(0.5)
check("Topbar: Menü öffnet die Sidebar",
await js(ws, "document.getElementById('sidebar').getBoundingClientRect().left >= 0"))
await cdp(ws, "Emulation.clearDeviceMetricsOverride")
# Katalog: Live-Suche über htmx
await go("/catalog", "Shop CI")
await js(ws, "var i = document.getElementById('search'); i.value = 'haus';"
"i.dispatchEvent(new Event('input', {bubbles: true}))")
await asyncio.sleep(1.2)
text = await js(ws, "document.getElementById('catalog-list').textContent")
check("Katalog: Live-Suche", "pub-hausregeln" in text and "pub-munin-nutzen" not in text)
check("Katalog: Suche steht in der URL", "q=haus" in await js(ws, "location.href"))
# Versionsseite: zurück zum ersten Tab
await go("/packages/pub-munin-nutzen/versions/1.4.0", "Shop CI")
await js(ws, "var t = document.querySelectorAll('[data-action=tab]'); t[1].click(); t[0].click()")
check("Version: Tab zurück zu Inhalt",
await js(ws, "document.getElementById('tab-content').classList.contains('active')"))
await js(ws, "document.getElementById('tabbtn-content').focus()")
await press(ws, "ArrowRight", "ArrowRight", 39)
check("Version: Pfeiltaste wechselt Tab samt aria-selected",
await js(ws, "document.activeElement.id == 'tabbtn-diff' && "
"document.getElementById('tabbtn-diff').getAttribute('aria-selected') == 'true' && "
"document.getElementById('tabbtn-content').getAttribute('aria-selected') == 'false' && "
"document.getElementById('tab-diff').classList.contains('active')"))
await press(ws, "Home", "Home", 36)
check("Version: Pos1 springt zum ersten Tab",
await js(ws, "document.getElementById('tab-content').classList.contains('active')"))
# Paketseite: Kopieren, erst ohne Fokus (kein Zugriff auf die Zwischenablage), dann mit
await go("/packages/pub-munin-nutzen", "Shop CI")
await js(ws, "document.querySelector('[data-copy=subscribe]').click()")
await asyncio.sleep(0.6)
check("Kopieren ohne Zugriff: Text markiert, Hinweis auf Tastenkürzel",
"Markiert" in await js(ws, "document.getElementById('toast-container').textContent")
and "pub-munin-nutzen" in await js(ws, "getSelection().toString()"))
await cdp(ws, "Emulation.setFocusEmulationEnabled", enabled=True)
await cdp(ws, "Browser.grantPermissions", origin=url,
permissions=["clipboardReadWrite", "clipboardSanitizedWrite"])
await js(ws, "document.querySelector('[data-copy=subscribe]').click()")
await asyncio.sleep(0.6)
check("Kopieren: Toast", "Kopiert" in await js(ws, "document.getElementById('toast-container').textContent"))
check("Kopieren: Abo in der Zwischenablage",
await js(ws, "navigator.clipboard.readText()") == "packages:\n - pub-munin-nutzen")
# Verwaltung: Rückfrage und Felder je Key-Art
await go("/manage?tenant=shop", "Admin")
await js(ws, "window.__asked = []; window.confirm = function (q) { __asked.push(q); return false; }")
await js(ws, "[...document.querySelectorAll('button')].find(b => b.textContent.trim() == 'Widerrufen').click()")
await asyncio.sleep(0.8)
asked = await js(ws, "JSON.stringify(window.__asked || null)")
check("Verwaltung: Rückfrage vor Widerrufen, Abbrechen bleibt auf der Seite",
asked and "widerrufen?" in asked and await js(ws, "Array.isArray(window.__asked)"))
def fields():
return js(ws, "JSON.stringify(['key-project', 'key-projects'].map(id => {"
"var f = document.getElementById(id); return [f.disabled, f.closest('[data-when]').hidden]; }))")
check("Key-Art Projekt: nur Feld Projekt", await fields() == "[[false,false],[true,true]]")
await js(ws, "var s = document.getElementById('key-kind'); s.value = 'integration';"
"s.dispatchEvent(new Event('change', {bubbles: true}))")
check("Key-Art Integration: nur Projektauswahl", await fields() == "[[true,true],[false,false]]")
await js(ws, "var s = document.getElementById('key-kind'); s.value = 'tenant';"
"s.dispatchEvent(new Event('change', {bubbles: true}))")
check("Key-Art Tenant: keins von beiden", await fields() == "[[true,true],[true,true]]")
check("Ausgeblendetes Feld ist wirklich unsichtbar",
await js(ws, "document.getElementById('key-project').offsetParent === null"))
# Audit: Live-Filter über htmx
await go("/audit", "Admin")
await js(ws, "var s = document.getElementById('action'); s.value = 'key.';"
"s.dispatchEvent(new Event('change', {bubbles: true}))")
await asyncio.sleep(1.2)
actions = await js(ws, "[...document.querySelectorAll('#audit-list tbody tr td:nth-child(4) code')]"
".map(c => c.textContent)")
check("Audit: Live-Filter", actions and all(x.startswith("key.") for x in actions))
# Prüfung: Zurückgeben verlangt einen Kommentar, Freigeben nicht
await go("/packages/pub-munin-nutzen/versions/1.5.0", "Prüfer")
await js(ws, "document.querySelector('[data-target=decide]').click()")
await js(ws, "document.querySelector('button[value=return]').click()")
await asyncio.sleep(0.8)
check("Prüfung: Zurückgeben ohne Kommentar bleibt im Dialog",
await js(ws, "location.pathname.endsWith('/versions/1.5.0') && "
"document.getElementById('comment').validity.valueMissing"))
await js(ws, "document.querySelector('button[value=approve]').click()")
await asyncio.sleep(1.2)
check("Prüfung: Freigeben ohne Kommentar geht", await js(ws, "location.pathname") == "/reviews")
# Editor zuletzt: Nach der Änderung hält die Warnung beim Verlassen jede weitere Navigation auf
# Editor: Tab rückt ein, Esc + Tab verlässt das Feld, Warnung bei ungespeicherten Änderungen
await go("/packages/pub-munin-nutzen/versions/1.4.0/edit", "Autorin")
check("Editor: ohne Änderung keine Warnung beim Verlassen",
not await js(ws, "var e = new Event('beforeunload', {cancelable: true}); dispatchEvent(e); e.defaultPrevented"))
await js(ws, "var t = document.querySelector('textarea[name=manifest]'); t.focus(); t.setSelectionRange(0, 0)")
await press(ws, "Tab", "Tab", 9)
check("Editor: Tab rückt ein",
await js(ws, "document.querySelector('textarea[name=manifest]').value.startsWith(' id:')"))
check("Editor: nach Änderung Warnung beim Verlassen",
await js(ws, "var e = new Event('beforeunload', {cancelable: true}); dispatchEvent(e); e.defaultPrevented"))
await press(ws, "Escape", "Escape", 27)
await press(ws, "Tab", "Tab", 9)
check("Editor: Esc und Tab springt weiter",
await js(ws, "document.activeElement.name") != "manifest")
return all(results)
def main() -> None:
work = Path(tempfile.mkdtemp())
port, port_chrome = free_port(), free_port()
url = f"http://127.0.0.1:{port}"
env = os.environ | {"HUGIN_DATABASE_URL": f"sqlite:///{work}/hugin.db", "HUGIN_ADMIN_TOKEN": ADMIN}
server_folder = ROOT / "server"
subprocess.run(["uv", "run", "-q", "hugin-server", "migrate"], cwd=server_folder, env=env, check=True)
server = subprocess.Popen(["uv", "run", "-q", "hugin-server", "run", "--host", "127.0.0.1", "--port", str(port)],
cwd=server_folder, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
chrome = subprocess.Popen([CHROME, "--headless=new", "--disable-gpu", f"--remote-debugging-port={port_chrome}",
f"--user-data-dir={work}/chrome", "about:blank"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
try:
for _ in range(100):
try:
httpx.get(f"{url}/readyz").raise_for_status()
httpx.get(f"http://127.0.0.1:{port_chrome}/json/version").raise_for_status()
break
except httpx.HTTPError:
time.sleep(0.2)
keys = create_data(url)
cookies = {name: cookie_for(url, key) for name, key in keys.items()}
sys.exit(0 if asyncio.run(run(port_chrome, url, cookies)) else 1)
finally:
server.terminate()
chrome.terminate()
if __name__ == "__main__":
main()