feat: custom themes initial
This commit is contained in:
@@ -222,6 +222,133 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase):
|
||||
)
|
||||
self.assertEqual(response.text, "console.log('minified');")
|
||||
|
||||
async def test_theme_css_asset_route_serves_file_from_configured_directory(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
themes_dir = Path(tmpdir)
|
||||
(themes_dir / "custom").mkdir()
|
||||
(themes_dir / "custom" / "theme.css").write_text(
|
||||
".theme-key-custom { --bg: red; }", encoding="utf-8"
|
||||
)
|
||||
request = SimpleNamespace(
|
||||
app={
|
||||
"settings": SimpleNamespace(
|
||||
WEBAPP_ENABLED=True,
|
||||
WEBAPP_THEMES_DIR=str(themes_dir),
|
||||
)
|
||||
},
|
||||
match_info={"path": "custom/theme.css"},
|
||||
)
|
||||
|
||||
response = await subscription_webapp.theme_css_asset_route(request)
|
||||
|
||||
self.assertEqual(response.content_type, "text/css")
|
||||
self.assertEqual(response.headers["Cache-Control"], "no-cache")
|
||||
self.assertIn("--bg: red", response.text)
|
||||
|
||||
async def test_theme_css_asset_route_serves_default_theme_asset_from_theme_folder(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
request = SimpleNamespace(
|
||||
app={
|
||||
"settings": SimpleNamespace(
|
||||
WEBAPP_ENABLED=True,
|
||||
WEBAPP_THEMES_DIR=tmpdir,
|
||||
)
|
||||
},
|
||||
match_info={"path": "light/style.css"},
|
||||
)
|
||||
|
||||
response = await subscription_webapp.theme_css_asset_route(request)
|
||||
|
||||
self.assertEqual(response.content_type, "text/css")
|
||||
self.assertIn(".theme-key-light", response.text)
|
||||
self.assertTrue((Path(tmpdir) / "light" / "theme.json").exists())
|
||||
self.assertTrue((Path(tmpdir) / "light" / "style.css").exists())
|
||||
|
||||
async def test_theme_css_asset_route_rejects_path_traversal(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
request = SimpleNamespace(
|
||||
app={
|
||||
"settings": SimpleNamespace(
|
||||
WEBAPP_ENABLED=True,
|
||||
WEBAPP_THEMES_DIR=tmpdir,
|
||||
)
|
||||
},
|
||||
match_info={"path": "../secret.css"},
|
||||
)
|
||||
|
||||
with self.assertRaises(webapp_assets.web.HTTPNotFound):
|
||||
await subscription_webapp.theme_css_asset_route(request)
|
||||
|
||||
async def test_theme_asset_route_serves_image_from_configured_directory(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
themes_dir = Path(tmpdir)
|
||||
(themes_dir / "custom" / "icons").mkdir(parents=True)
|
||||
(themes_dir / "custom" / "icons" / "save.png").write_bytes(b"png-bytes")
|
||||
request = SimpleNamespace(
|
||||
app={
|
||||
"settings": SimpleNamespace(
|
||||
WEBAPP_ENABLED=True,
|
||||
WEBAPP_THEMES_DIR=str(themes_dir),
|
||||
)
|
||||
},
|
||||
match_info={"path": "custom/icons/save.png"},
|
||||
)
|
||||
|
||||
response = await subscription_webapp.theme_asset_route(request)
|
||||
|
||||
self.assertEqual(response.content_type, "image/png")
|
||||
self.assertEqual(response.headers["Cache-Control"], "public, max-age=3600")
|
||||
self.assertEqual(response.body, b"png-bytes")
|
||||
|
||||
async def test_theme_asset_route_serves_default_theme_icon_from_theme_folder(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
request = SimpleNamespace(
|
||||
app={
|
||||
"settings": SimpleNamespace(
|
||||
WEBAPP_ENABLED=True,
|
||||
WEBAPP_THEMES_DIR=tmpdir,
|
||||
)
|
||||
},
|
||||
match_info={"path": "windows95/icons/save.png"},
|
||||
)
|
||||
|
||||
response = await subscription_webapp.theme_asset_route(request)
|
||||
|
||||
self.assertEqual(response.content_type, "image/png")
|
||||
self.assertGreater(len(response.body), 0)
|
||||
self.assertTrue((Path(tmpdir) / "windows95" / "theme.json").exists())
|
||||
self.assertTrue((Path(tmpdir) / "windows95" / "icons" / "save.png").exists())
|
||||
|
||||
async def test_theme_asset_route_rejects_path_traversal(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
request = SimpleNamespace(
|
||||
app={
|
||||
"settings": SimpleNamespace(
|
||||
WEBAPP_ENABLED=True,
|
||||
WEBAPP_THEMES_DIR=tmpdir,
|
||||
)
|
||||
},
|
||||
match_info={"path": "../secret.png"},
|
||||
)
|
||||
|
||||
with self.assertRaises(webapp_assets.web.HTTPNotFound):
|
||||
await subscription_webapp.theme_asset_route(request)
|
||||
|
||||
async def test_theme_asset_route_rejects_non_image_suffix(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
request = SimpleNamespace(
|
||||
app={
|
||||
"settings": SimpleNamespace(
|
||||
WEBAPP_ENABLED=True,
|
||||
WEBAPP_THEMES_DIR=tmpdir,
|
||||
)
|
||||
},
|
||||
match_info={"path": "custom/icons/readme.txt"},
|
||||
)
|
||||
|
||||
with self.assertRaises(webapp_assets.web.HTTPNotFound):
|
||||
await subscription_webapp.theme_asset_route(request)
|
||||
|
||||
def test_webapp_logo_disk_cache_roundtrip(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
logo_url = "https://cdn.example.com/logo.png"
|
||||
|
||||
@@ -59,6 +59,7 @@ class WebAppRouteContractTests(unittest.TestCase):
|
||||
("GET", "/webapp-logo"): "webapp_logo_route",
|
||||
("GET", "/webapp-emoji/{codepoints}/512.{ext}"): "webapp_animated_emoji_route",
|
||||
("GET", "/subscription_webapp.css"): "css_asset_route",
|
||||
("GET", "/webapp-theme-css/{path}"): "theme_css_asset_route",
|
||||
("GET", "/subscription_webapp.min.{asset_hash}.js"): "js_asset_route",
|
||||
("GET", "/subscription_webapp.js"): "js_asset_route",
|
||||
("POST", "/api/auth/telegram/nonce"): "telegram_oauth_nonce_route",
|
||||
@@ -136,6 +137,8 @@ class WebAppRouteContractTests(unittest.TestCase):
|
||||
("PATCH", "/api/admin/settings"): "admin_settings_patch_route",
|
||||
("GET", "/api/admin/tariffs"): "admin_tariffs_get_route",
|
||||
("PUT", "/api/admin/tariffs"): "admin_tariffs_save_route",
|
||||
("GET", "/api/admin/themes"): "admin_themes_get_route",
|
||||
("PUT", "/api/admin/themes"): "admin_themes_save_route",
|
||||
("GET", "/api/admin/panel/internal-squads"): "admin_panel_internal_squads_route",
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from config.webapp_themes_config import (
|
||||
WebappThemesConfig,
|
||||
apply_webapp_theme_env_overrides,
|
||||
builtin_webapp_themes_config,
|
||||
default_webapp_theme_descriptors,
|
||||
ensure_webapp_core_themes,
|
||||
load_webapp_theme_dir,
|
||||
public_themes_catalog_payload,
|
||||
resolved_webapp_themes_catalog,
|
||||
write_webapp_theme_dir,
|
||||
)
|
||||
|
||||
|
||||
class WebappThemesConfigTests(unittest.TestCase):
|
||||
def test_builtin_has_core_themes(self):
|
||||
cfg = builtin_webapp_themes_config("#abcdef")
|
||||
self.assertEqual(cfg.default_theme, "dark")
|
||||
keys = {theme.key for theme in cfg.themes}
|
||||
self.assertEqual(keys, {"dark", "light", "windows95", "ascii"})
|
||||
dark = cfg.theme_by_key("dark")
|
||||
self.assertIsNotNone(dark)
|
||||
self.assertTrue(dark.default)
|
||||
self.assertEqual(dark.tokens.accent, "#abcdef")
|
||||
win95 = cfg.theme_by_key("windows95")
|
||||
self.assertIsNotNone(win95)
|
||||
self.assertEqual(cfg.theme_by_key("light").css_file, "style.css")
|
||||
self.assertEqual(win95.css_file, "style.css")
|
||||
self.assertEqual(win95.tokens.style_preset, "win95")
|
||||
self.assertFalse(win95.use_primary_accent)
|
||||
self.assertTrue(win95.use_in_admin)
|
||||
self.assertEqual(win95.assets_version, 1)
|
||||
ascii_theme = cfg.theme_by_key("ascii")
|
||||
self.assertIsNotNone(ascii_theme)
|
||||
self.assertEqual(ascii_theme.css_file, "style.css")
|
||||
self.assertFalse(ascii_theme.use_primary_accent)
|
||||
self.assertTrue(ascii_theme.use_in_admin)
|
||||
self.assertEqual(ascii_theme.assets_version, 1)
|
||||
|
||||
def test_env_override_default_theme(self):
|
||||
cfg = builtin_webapp_themes_config("#00fe7a")
|
||||
out = apply_webapp_theme_env_overrides(cfg, "light")
|
||||
self.assertEqual(out.default_theme, "light")
|
||||
self.assertTrue(out.theme_by_key("light").default)
|
||||
self.assertFalse(out.theme_by_key("dark").default)
|
||||
|
||||
def test_core_themes_are_merged_when_missing(self):
|
||||
cfg = WebappThemesConfig(
|
||||
default_theme="custom",
|
||||
themes=[
|
||||
{
|
||||
"key": "custom",
|
||||
"enabled": True,
|
||||
"default": True,
|
||||
"use_primary_accent": False,
|
||||
"tokens": {"color_scheme": "dark"},
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
merged, changed = ensure_webapp_core_themes(cfg, "#00fe7a")
|
||||
|
||||
self.assertTrue(changed)
|
||||
self.assertEqual(
|
||||
{theme.key for theme in merged.themes},
|
||||
{"custom", "dark", "light", "windows95", "ascii"},
|
||||
)
|
||||
self.assertEqual(merged.default_theme, "custom")
|
||||
self.assertTrue(merged.theme_by_key("custom").default)
|
||||
self.assertEqual(merged.theme_by_key("light").css_file, "style.css")
|
||||
self.assertTrue(merged.theme_by_key("custom").use_in_admin)
|
||||
self.assertFalse(merged.theme_by_key("custom").use_primary_accent)
|
||||
|
||||
def test_default_theme_descriptors_are_read_from_source_files(self):
|
||||
descriptors = default_webapp_theme_descriptors()
|
||||
|
||||
self.assertEqual(set(descriptors), {"dark", "light", "windows95", "ascii"})
|
||||
self.assertTrue(descriptors["dark"]["default"])
|
||||
self.assertEqual(descriptors["windows95"]["css_file"], "style.css")
|
||||
self.assertEqual(descriptors["ascii"]["css_file"], "style.css")
|
||||
|
||||
def test_resolved_creates_default_files_when_missing(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
themes_dir = Path(tmp) / "themes"
|
||||
cfg = resolved_webapp_themes_catalog(
|
||||
theme_dir=themes_dir,
|
||||
primary_accent="#abc123",
|
||||
env_default_theme=None,
|
||||
)
|
||||
|
||||
self.assertTrue((themes_dir / "dark" / "theme.json").exists())
|
||||
self.assertTrue((themes_dir / "light" / "theme.json").exists())
|
||||
self.assertTrue((themes_dir / "light" / "style.css").exists())
|
||||
self.assertTrue((themes_dir / "windows95" / "theme.json").exists())
|
||||
self.assertTrue((themes_dir / "windows95" / "style.css").exists())
|
||||
self.assertTrue((themes_dir / "windows95" / "icons" / "save.png").exists())
|
||||
self.assertTrue((themes_dir / "ascii" / "theme.json").exists())
|
||||
self.assertTrue((themes_dir / "ascii" / "style.css").exists())
|
||||
self.assertEqual(cfg.default_theme, "dark")
|
||||
self.assertIsNone(cfg.theme_by_key("dark").tokens.accent)
|
||||
|
||||
def test_load_theme_dir_uses_filename_as_key_when_missing(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
themes_dir = Path(tmp) / "themes"
|
||||
themes_dir.mkdir()
|
||||
(themes_dir / "custom").mkdir()
|
||||
(themes_dir / "custom" / "theme.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"names": {"en": "Custom"},
|
||||
"enabled": True,
|
||||
"css_file": "style.css",
|
||||
"tokens": {"color_scheme": "light"},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
themes = load_webapp_theme_dir(themes_dir)
|
||||
|
||||
self.assertEqual(len(themes), 1)
|
||||
self.assertEqual(themes[0].key, "custom")
|
||||
self.assertEqual(themes[0].names["en"], "Custom")
|
||||
|
||||
def test_resolved_catalog_includes_custom_mounted_theme_descriptor(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
themes_dir = Path(tmp) / "themes"
|
||||
themes_dir.mkdir()
|
||||
(themes_dir / "neon").mkdir()
|
||||
(themes_dir / "neon" / "theme.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"names": {"en": "Neon"},
|
||||
"enabled": True,
|
||||
"default": True,
|
||||
"css_file": "style.css",
|
||||
"tokens": {"color_scheme": "dark"},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
cfg = resolved_webapp_themes_catalog(
|
||||
theme_dir=themes_dir,
|
||||
primary_accent="#00fe7a",
|
||||
env_default_theme=None,
|
||||
)
|
||||
|
||||
self.assertEqual(cfg.default_theme, "neon")
|
||||
self.assertIsNotNone(cfg.theme_by_key("neon"))
|
||||
self.assertEqual(
|
||||
{theme.key for theme in cfg.themes},
|
||||
{"dark", "light", "windows95", "ascii", "neon"},
|
||||
)
|
||||
|
||||
def test_env_default_overrides_descriptor_default(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
themes_dir = Path(tmp) / "themes"
|
||||
themes_dir.mkdir()
|
||||
(themes_dir / "neon").mkdir()
|
||||
(themes_dir / "neon" / "theme.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"names": {"en": "Neon"},
|
||||
"enabled": True,
|
||||
"default": True,
|
||||
"tokens": {"color_scheme": "dark"},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
cfg = resolved_webapp_themes_catalog(
|
||||
theme_dir=themes_dir,
|
||||
primary_accent="#00fe7a",
|
||||
env_default_theme="windows95",
|
||||
)
|
||||
|
||||
self.assertEqual(cfg.default_theme, "windows95")
|
||||
self.assertTrue(cfg.theme_by_key("windows95").default)
|
||||
self.assertFalse(cfg.theme_by_key("neon").default)
|
||||
|
||||
def test_custom_descriptor_default_wins_over_seeded_core_default(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
themes_dir = Path(tmp) / "themes"
|
||||
themes_dir.mkdir()
|
||||
(themes_dir / "dark").mkdir()
|
||||
(themes_dir / "dark" / "theme.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"key": "dark",
|
||||
"names": {"en": "Dark"},
|
||||
"enabled": True,
|
||||
"default": True,
|
||||
"tokens": {"color_scheme": "dark"},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(themes_dir / "neon").mkdir()
|
||||
(themes_dir / "neon" / "theme.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"names": {"en": "Neon"},
|
||||
"enabled": True,
|
||||
"default": True,
|
||||
"tokens": {"color_scheme": "dark"},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
cfg = resolved_webapp_themes_catalog(
|
||||
theme_dir=themes_dir,
|
||||
primary_accent="#00fe7a",
|
||||
env_default_theme=None,
|
||||
)
|
||||
|
||||
self.assertEqual(cfg.default_theme, "neon")
|
||||
self.assertTrue(cfg.theme_by_key("neon").default)
|
||||
self.assertFalse(cfg.theme_by_key("dark").default)
|
||||
|
||||
def test_theme_dir_writer_writes_descriptors_with_single_default(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
cfg = builtin_webapp_themes_config("#00fe7a")
|
||||
cfg = WebappThemesConfig(default_theme="windows95", themes=cfg.themes)
|
||||
themes_dir = Path(tmp) / "themes"
|
||||
|
||||
write_webapp_theme_dir(themes_dir, cfg)
|
||||
|
||||
dark = json.loads((themes_dir / "dark" / "theme.json").read_text(encoding="utf-8"))
|
||||
win95 = json.loads(
|
||||
(themes_dir / "windows95" / "theme.json").read_text(encoding="utf-8")
|
||||
)
|
||||
self.assertFalse(dark["default"])
|
||||
self.assertTrue(win95["default"])
|
||||
|
||||
resolved = resolved_webapp_themes_catalog(
|
||||
theme_dir=themes_dir,
|
||||
primary_accent="#00fe7a",
|
||||
env_default_theme=None,
|
||||
)
|
||||
self.assertEqual(resolved.default_theme, "windows95")
|
||||
|
||||
def test_resolved_falls_back_to_dark_when_saved_theme_is_missing(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
themes_dir = Path(tmp) / "themes"
|
||||
cfg = builtin_webapp_themes_config("#00fe7a")
|
||||
cfg = WebappThemesConfig(default_theme="windows95", themes=cfg.themes)
|
||||
write_webapp_theme_dir(themes_dir, cfg)
|
||||
windows_theme = themes_dir / "windows95" / "theme.json"
|
||||
windows_theme.unlink()
|
||||
|
||||
resolved = resolved_webapp_themes_catalog(
|
||||
theme_dir=themes_dir,
|
||||
primary_accent="#00fe7a",
|
||||
env_default_theme=None,
|
||||
)
|
||||
|
||||
self.assertEqual(resolved.default_theme, "dark")
|
||||
self.assertTrue(resolved.theme_by_key("dark").default)
|
||||
|
||||
def test_public_payload_injects_primary_accent_when_enabled(self):
|
||||
cfg = builtin_webapp_themes_config("#abc123")
|
||||
|
||||
payload = public_themes_catalog_payload(cfg, "#abc123")
|
||||
light = next(theme for theme in payload["themes"] if theme["key"] == "light")
|
||||
dark = next(theme for theme in payload["themes"] if theme["key"] == "dark")
|
||||
win95 = next(theme for theme in payload["themes"] if theme["key"] == "windows95")
|
||||
|
||||
self.assertEqual(light["css_file"], "style.css")
|
||||
self.assertEqual(light["tokens"]["accent"], "#abc123")
|
||||
self.assertEqual(dark["tokens"]["accent"], "#abc123")
|
||||
self.assertFalse(win95["use_primary_accent"])
|
||||
self.assertTrue(win95["use_in_admin"])
|
||||
self.assertNotIn("accent", win95["tokens"])
|
||||
|
||||
def test_public_payload_keeps_admin_usage_flag(self):
|
||||
cfg = WebappThemesConfig(
|
||||
default_theme="custom",
|
||||
themes=[
|
||||
{
|
||||
"key": "custom",
|
||||
"names": {"en": "Custom"},
|
||||
"enabled": True,
|
||||
"default": True,
|
||||
"use_in_admin": False,
|
||||
"tokens": {"color_scheme": "dark"},
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
payload = public_themes_catalog_payload(cfg, "#abc123")
|
||||
|
||||
self.assertFalse(payload["themes"][0]["use_in_admin"])
|
||||
|
||||
def test_resolved_refreshes_stale_builtin_windows95_assets(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
themes_dir = Path(tmp) / "themes"
|
||||
stale_theme_dir = themes_dir / "windows95"
|
||||
stale_theme_dir.mkdir(parents=True)
|
||||
(stale_theme_dir / "theme.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"key": "windows95",
|
||||
"names": {"en": "Windows 95"},
|
||||
"enabled": True,
|
||||
"default": False,
|
||||
"use_primary_accent": True,
|
||||
"css_file": "style.css",
|
||||
"assets_version": 1,
|
||||
"tokens": {"color_scheme": "light", "style_preset": "win95"},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(stale_theme_dir / "style.css").write_text("/* stale */", encoding="utf-8")
|
||||
|
||||
cfg = resolved_webapp_themes_catalog(
|
||||
theme_dir=themes_dir,
|
||||
primary_accent="#00fe7a",
|
||||
env_default_theme=None,
|
||||
)
|
||||
|
||||
descriptor = json.loads((stale_theme_dir / "theme.json").read_text(encoding="utf-8"))
|
||||
css = (stale_theme_dir / "style.css").read_text(encoding="utf-8")
|
||||
self.assertEqual(
|
||||
descriptor["assets_version"],
|
||||
cfg.theme_by_key("windows95").assets_version,
|
||||
)
|
||||
self.assertEqual(descriptor["assets_version"], 1)
|
||||
self.assertIn("lucide-house", css)
|
||||
self.assertIn("lucide-earth", css)
|
||||
self.assertIn("lucide-circle-check", css)
|
||||
self.assertIn("lucide-circle-check-big", css)
|
||||
self.assertTrue((stale_theme_dir / "icons" / "dashboard.png").exists())
|
||||
|
||||
def test_resolved_refreshes_stale_builtin_light_assets(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
themes_dir = Path(tmp) / "themes"
|
||||
stale_theme_dir = themes_dir / "light"
|
||||
stale_theme_dir.mkdir(parents=True)
|
||||
(stale_theme_dir / "theme.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"key": "light",
|
||||
"names": {"en": "Light"},
|
||||
"enabled": True,
|
||||
"default": False,
|
||||
"use_primary_accent": True,
|
||||
"css_file": "style.css",
|
||||
"assets_version": 1,
|
||||
"tokens": {"color_scheme": "light"},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(stale_theme_dir / "style.css").write_text("/* stale */", encoding="utf-8")
|
||||
|
||||
resolved_webapp_themes_catalog(
|
||||
theme_dir=themes_dir,
|
||||
primary_accent="#00fe7a",
|
||||
env_default_theme=None,
|
||||
)
|
||||
|
||||
css = (stale_theme_dir / "style.css").read_text(encoding="utf-8")
|
||||
self.assertIn(".theme-key-light.app-shell", css)
|
||||
Reference in New Issue
Block a user