1"""Guard against JS syntax errors in every dashboard HTML page. 2 3Inline ``<script>`` blocks in the templates have, in practice, been the 4single largest source of "the whole tab stopped working" bugs. One stray 5comma or mis-placed ``+`` halts JS parsing — every onclick handler then 6silently fails with ``ReferenceError``, and the user sees a page that 7looks fine but does nothing when clicked. 8 9This test renders every HTML route through the TestClient, extracts each 10``<script>`` block from the response, and runs ``node --check`` on it. 11Parametrized one route per test so CI output points straight at the 12broken page. 13 14If node isn't on PATH the tests skip (rather than failing — we don't 15want CI to go red because a runner lacks node). 16""" 17 18from __future__ import annotations 19 20import re 21import shutil 22import subprocess 23import tempfile 24from pathlib import Path 25from unittest.mock import MagicMock 26 27import pytest 28from fastapi.testclient import TestClient 29 30from maf.config import ArenaConfig 31from maf.dashboard import api as dashboard_api 32 33 34_SCRIPT_RE = re.compile(r"<script[^>]*>(.*?)</script>", re.DOTALL) 35 36 37def _node_available() -> bool: 38 return shutil.which("node") is not None 39 40 41# Every HTML route under the dashboard. ``arena`` is templated so we hit 42# it with a known arena name. Add a row here when a new HTML page lands. 43_HTML_ROUTES: list[str] = [ 44 "/", 45 "/arenas/demo", 46 "/channels", 47 "/data", 48 "/live", 49 "/llm", 50 "/mastermind", 51 "/oracle", 52 "/sources", 53 "/modules", 54 "/wizard", 55 "/docs", 56] 57 58 59@pytest.fixture 60def fake_arena(tmp_path: Path, monkeypatch): 61 """Wire a minimal arena onto the dashboard so every page renders. 62 63 The pages reach into ``maf_app.modules`` / ``maf_app.list_arenas()`` / 64 ``maf_app.get_arena()`` — supply enough mock to make those calls 65 return sensible data without spinning up the full runtime. 66 """ 67 arena_dir = tmp_path / "arenas" 68 arena_dir.mkdir() 69 valid = { 70 "name": "demo", 71 "description": "test", 72 "schedule": None, 73 "max_iterations": 1, 74 "target_key": "ticker", 75 "sources": [], 76 "phases": [{ 77 "name": "analysis", "pattern": "sequential", "agents": [], 78 "max_rounds": 1, "transition": "END", 79 }], 80 "triggers": [], 81 } 82 arena = MagicMock() 83 arena.config = ArenaConfig(**valid) 84 85 maf_app = MagicMock() 86 maf_app.config.arena_dir = str(arena_dir) 87 maf_app.config.modules = [] 88 maf_app.modules = [] # so /modules iterates empty 89 maf_app.get_arena.return_value = arena 90 maf_app.list_arenas.return_value = ["demo"] 91 monkeypatch.setattr(dashboard_api, "_maf_app", maf_app) 92 return maf_app 93 94 95def _node_check(script: str) -> str | None: 96 """Run ``node --check`` on a snippet. Return ``None`` on success, 97 the first 5 lines of stderr otherwise.""" 98 with tempfile.NamedTemporaryFile("w", suffix=".js", delete=False) as fh: 99 fh.write(script) 100 tmp = fh.name 101 try: 102 proc = subprocess.run( 103 ["node", "--check", tmp], 104 capture_output=True, text=True, timeout=10, 105 ) 106 if proc.returncode == 0: 107 return None 108 return "\n".join(proc.stderr.splitlines()[:5]) 109 finally: 110 Path(tmp).unlink(missing_ok=True) 111 112 113@pytest.mark.skipif(not _node_available(), reason="node not installed — JS syntax check skipped") 114@pytest.mark.parametrize("path", _HTML_ROUTES) 115def test_html_page_inline_js_parses(fake_arena, path: str) -> None: 116 """Every inline <script> block on this page must be valid JavaScript. 117 118 Catches the kind of bug where a hand-rolled ternary inside template- 119 string concatenation breaks the parser — and with it every onclick 120 handler on the page. 121 """ 122 with TestClient(dashboard_api.app) as client: 123 r = client.get(path, follow_redirects=True) 124 assert r.status_code == 200, f"{path} returned {r.status_code}" 125 126 scripts = _SCRIPT_RE.findall(r.text) 127 if not scripts: 128 # Some pages (e.g. /docs) may have no inline script — that's fine. 129 pytest.skip(f"{path} has no inline <script> blocks") 130 131 failures: list[str] = [] 132 for i, script in enumerate(scripts): 133 if not script.strip(): 134 continue 135 err = _node_check(script) 136 if err is not None: 137 failures.append(f" <script> #{i + 1}:\n{err}") 138 139 assert not failures, ( 140 f"JS parse errors on {path}:\n" + "\n\n".join(failures) 141 )