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