test(dashboard): cover theme bootstrap CSS render + _serve_index injection
Server-side coverage for the critical-CSS shim (PR #36024 salvage): - user theme → style block emitted with ONLY real bundle variable names (--background-base/--midground-base from layerVars(), --theme-font-sans/--theme-base-size from typographyVars()/index.css), and an html,body rule expressed via those vars so runtime theme switches never leave a stale canvas/font - built-in / unknown / non-string active theme → no block - malformed theme YAML and load_config() exceptions → no crash, index still serves - </style> breakout attempt in a theme value stays escaped - mount_spa integration: block present in <head> for user themes, absent for built-ins
This commit is contained in:
parent
01bab394cd
commit
558fcb6146
1 changed files with 183 additions and 0 deletions
|
|
@ -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('<style id="hermes-theme-bootstrap">')
|
||||
assert css.endswith("</style>")
|
||||
# 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):
|
||||
"""`</style>` 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: '</style><script>alert(1)</script>'\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("</style>") == 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(
|
||||
"<html><head><title>t</title></head><body>SPA</body></html>",
|
||||
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 '<style id="hermes-theme-bootstrap">' in resp.text
|
||||
assert "--background-base:#0a1628;" in resp.text
|
||||
# Injected inside <head>, before the closing tag.
|
||||
head = resp.text.split("</head>")[0]
|
||||
assert "hermes-theme-bootstrap" in head
|
||||
|
||||
def test_serve_index_no_bootstrap_for_builtin_theme(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
import hermes_cli.web_server as ws
|
||||
monkeypatch.setattr(
|
||||
ws, "load_config", lambda: {"dashboard": {"theme": "default"}}
|
||||
)
|
||||
client = self._mount_spa_client(tmp_path, monkeypatch)
|
||||
resp = client.get("/chat")
|
||||
assert resp.status_code == 200
|
||||
assert "hermes-theme-bootstrap" not in resp.text
|
||||
|
||||
def test_serve_index_survives_render_failure(self, tmp_path, monkeypatch):
|
||||
"""Even if theme rendering blows up internally, index serving
|
||||
must not crash (the helper swallows and returns '')."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
import hermes_cli.web_server as ws
|
||||
|
||||
def boom():
|
||||
raise RuntimeError("boom")
|
||||
|
||||
monkeypatch.setattr(ws, "load_config", boom)
|
||||
client = self._mount_spa_client(tmp_path, monkeypatch)
|
||||
resp = client.get("/chat")
|
||||
assert resp.status_code == 200
|
||||
assert "hermes-theme-bootstrap" not in resp.text
|
||||
assert "SPA" in resp.text
|
||||
|
||||
|
||||
class TestNormaliseThemeExtensions:
|
||||
"""Tests for the extended normaliser fields (assets, customCSS,
|
||||
componentStyles, layoutVariant) — the surfaces themes use to reskin
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue