diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 5cbdeffe461..691d3796a25 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -6130,6 +6130,189 @@ class TestDiscoverUserThemes: assert len(results) == 1 # only the valid one +class TestThemeBootstrapCSS: + """Tests for _render_active_theme_bootstrap_css() and its injection + into index.html via _serve_index() — the critical-CSS shim that kills + the default-teal first-paint flash for user YAML themes.""" + + @staticmethod + def _write_theme(hermes_home, name="ocean"): + themes_dir = hermes_home / "dashboard-themes" + themes_dir.mkdir(exist_ok=True) + (themes_dir / f"{name}.yaml").write_text( + f"name: {name}\n" + "label: Ocean\n" + "palette:\n" + " background:\n" + " hex: \"#0a1628\"\n" + " midground:\n" + " hex: \"#dbe4f0\"\n" + "typography:\n" + " fontSans: \"Inter, sans-serif\"\n" + " baseSize: \"17px\"\n", + encoding="utf-8", + ) + + def test_user_theme_renders_bundle_vars(self, tmp_path, monkeypatch): + """Active user theme → style block with ONLY variable names the + bundle actually consumes (layerVars/typographyVars tokens).""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + self._write_theme(tmp_path) + from hermes_cli import web_server + monkeypatch.setattr( + web_server, "load_config", lambda: {"dashboard": {"theme": "ocean"}} + ) + css = web_server._render_active_theme_bootstrap_css() + assert css.startswith('") + # Real bundle tokens (web/src/themes/context.tsx + index.css). + assert "--background-base:#0a1628;" in css + assert "--midground-base:#dbe4f0;" in css + assert "--theme-font-sans:Inter, sans-serif;" in css + assert "--theme-base-size:17px;" in css + # Names that do NOT exist in the bundle must not be emitted. + for bogus in ("--color-background", "--color-midground", + "--font-sans:", "--font-base-size"): + assert bogus not in css + # Canvas rule flows through the variables (never goes stale when + # applyTheme() rewrites them as inline styles at runtime). + assert "html,body{background-color:var(--background-base);" in css + assert "font-family:var(--theme-font-sans);" in css + assert "font-size:var(--theme-base-size);" in css + # No baked literal values in the html,body rule. + assert "#0a1628" not in css.split("html,body")[1] + + def test_builtin_theme_renders_nothing(self, tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + from hermes_cli import web_server + for builtin in ("default", "midnight", "cyberpunk"): + monkeypatch.setattr( + web_server, "load_config", + lambda b=builtin: {"dashboard": {"theme": b}}, + ) + assert web_server._render_active_theme_bootstrap_css() == "" + + def test_unknown_theme_renders_nothing(self, tmp_path, monkeypatch): + """Configured theme has no YAML on disk → empty string, no crash.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + from hermes_cli import web_server + monkeypatch.setattr( + web_server, "load_config", lambda: {"dashboard": {"theme": "ghost"}} + ) + assert web_server._render_active_theme_bootstrap_css() == "" + + def test_non_string_theme_renders_nothing(self, tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + from hermes_cli import web_server + monkeypatch.setattr( + web_server, "load_config", lambda: {"dashboard": {"theme": 42}} + ) + assert web_server._render_active_theme_bootstrap_css() == "" + + def test_malformed_theme_yaml_no_crash(self, tmp_path, monkeypatch): + """A garbage YAML for the active theme name must not crash — the + discover helper skips it, so no style block is emitted.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + themes_dir = tmp_path / "dashboard-themes" + themes_dir.mkdir() + (themes_dir / "broken.yaml").write_text( + "::: not valid yaml :::\n\tindent wrong", encoding="utf-8" + ) + from hermes_cli import web_server + monkeypatch.setattr( + web_server, "load_config", lambda: {"dashboard": {"theme": "broken"}} + ) + assert web_server._render_active_theme_bootstrap_css() == "" + + def test_load_config_exception_no_crash(self, monkeypatch): + from hermes_cli import web_server + + def boom(): + raise RuntimeError("config unreadable") + + monkeypatch.setattr(web_server, "load_config", boom) + assert web_server._render_active_theme_bootstrap_css() == "" + + def test_style_escape_defends_style_breakout(self, tmp_path, monkeypatch): + """`` in a theme value cannot break out of the block.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + themes_dir = tmp_path / "dashboard-themes" + themes_dir.mkdir() + (themes_dir / "sneaky.yaml").write_text( + "name: sneaky\n" + "typography:\n" + " fontSans: ''\n", + encoding="utf-8", + ) + from hermes_cli import web_server + monkeypatch.setattr( + web_server, "load_config", lambda: {"dashboard": {"theme": "sneaky"}} + ) + css = web_server._render_active_theme_bootstrap_css() + assert css.count("") == 1 # only the legitimate closer + assert "<\\/style>" in css # payload was escaped, not emitted raw + + @staticmethod + def _mount_spa_client(tmp_path, monkeypatch): + from fastapi import FastAPI + from starlette.testclient import TestClient + import hermes_cli.web_server as ws + + dist = tmp_path / "web_dist" + (dist / "assets").mkdir(parents=True) + (dist / "index.html").write_text( + "tSPA", + encoding="utf-8", + ) + monkeypatch.setattr(ws, "WEB_DIST", dist) + spa_app = FastAPI() + ws.mount_spa(spa_app) + return TestClient(spa_app) + + def test_serve_index_injects_bootstrap_for_user_theme(self, tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + self._write_theme(tmp_path) + import hermes_cli.web_server as ws + monkeypatch.setattr( + ws, "load_config", lambda: {"dashboard": {"theme": "ocean"}} + ) + client = self._mount_spa_client(tmp_path, monkeypatch) + resp = client.get("/chat") + assert resp.status_code == 200 + assert '