mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
build(web): generate the UI's Tailwind CSS with the pinned standalone CLI (#685)
Replaces the hand-written Tailwind subset in app.css with a real, purged Tailwind build: scripts/build_css.py runs the pinned, SHA-256-checked standalone Tailwind CLI (no Node), the generated tailwind.css and plugin-frame.css are committed, and CI fails when they are stale. The Pi never builds anything. The login page (#683) now links tailwind.css too, and the load-order test covers every template that links app.css. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
"""scripts/build_css.py: the pinned Tailwind CLI and what it builds.
|
||||
|
||||
The build itself needs the CLI download, so CI runs it in its own job
|
||||
(`build_css.py --check`); these check the parts that must hold without it.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"build_css", PROJECT_ROOT / "scripts" / "build_css.py"
|
||||
)
|
||||
build_css = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(build_css)
|
||||
|
||||
|
||||
def test_every_asset_is_pinned_to_a_sha256():
|
||||
assert re.fullmatch(r"3\.\d+\.\d+", build_css.TAILWIND_VERSION)
|
||||
assert build_css.TAILWIND_ASSETS
|
||||
for name, digest in build_css.TAILWIND_ASSETS.items():
|
||||
assert name.startswith("tailwindcss-"), name
|
||||
assert re.fullmatch(r"[0-9a-f]{64}", digest), name
|
||||
|
||||
|
||||
@pytest.mark.parametrize("system,machine,expected", [
|
||||
("Linux", "x86_64", "tailwindcss-linux-x64"),
|
||||
("Linux", "aarch64", "tailwindcss-linux-arm64"),
|
||||
("Linux", "armv7l", "tailwindcss-linux-armv7"),
|
||||
("Darwin", "arm64", "tailwindcss-macos-arm64"),
|
||||
("Darwin", "x86_64", "tailwindcss-macos-x64"),
|
||||
("Windows", "AMD64", "tailwindcss-windows-x64.exe"),
|
||||
("Windows", "ARM64", "tailwindcss-windows-arm64.exe"),
|
||||
])
|
||||
def test_asset_name_maps_each_platform(monkeypatch, system, machine, expected):
|
||||
monkeypatch.setattr(build_css.platform, "system", lambda: system)
|
||||
monkeypatch.setattr(build_css.platform, "machine", lambda: machine)
|
||||
assert build_css.asset_name() == expected
|
||||
assert expected in build_css.TAILWIND_ASSETS
|
||||
|
||||
|
||||
def test_unsupported_cpu_is_a_clear_error(monkeypatch):
|
||||
monkeypatch.setattr(build_css.platform, "system", lambda: "Linux")
|
||||
monkeypatch.setattr(build_css.platform, "machine", lambda: "armv6l")
|
||||
with pytest.raises(SystemExit, match="armv6l"):
|
||||
build_css.asset_name()
|
||||
|
||||
|
||||
def test_every_build_input_exists_and_its_output_is_committed():
|
||||
for input_css, config, output in build_css.BUILDS:
|
||||
assert (PROJECT_ROOT / input_css).is_file(), input_css
|
||||
assert (PROJECT_ROOT / config).is_file(), config
|
||||
assert (PROJECT_ROOT / output).is_file(), output
|
||||
|
||||
|
||||
def test_the_cli_is_fed_an_lf_copy_of_a_crlf_input(tmp_path, monkeypatch):
|
||||
"""Tailwind's minifier merges rules differently when the input CSS has
|
||||
CRLF line endings, so a Windows checkout built bytes CI's Linux build
|
||||
didn't, and --check failed. The CLI must always see LF."""
|
||||
monkeypatch.setattr(build_css, "PROJECT_ROOT", tmp_path)
|
||||
(tmp_path / "in.css").write_bytes(b"@tailwind base;\r\n@tailwind utilities;\r\n")
|
||||
seen = {}
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
seen["input"] = Path(cmd[cmd.index("--input") + 1]).read_bytes()
|
||||
Path(cmd[cmd.index("--output") + 1]).write_text(".a{b:c}", encoding="utf-8")
|
||||
|
||||
class Done:
|
||||
returncode = 0
|
||||
stdout = stderr = ""
|
||||
return Done()
|
||||
|
||||
monkeypatch.setattr(build_css.subprocess, "run", fake_run)
|
||||
work = tmp_path / "work"
|
||||
work.mkdir()
|
||||
out = tmp_path / "out.css"
|
||||
build_css.run_build(Path("cli"), "in.css", "cfg.js", out, work)
|
||||
|
||||
assert seen["input"] == b"@tailwind base;\n@tailwind utilities;\n"
|
||||
assert out.read_bytes() == b".a{b:c}\n"
|
||||
assert (tmp_path / "in.css").read_bytes().count(b"\r\n") == 2 # source untouched
|
||||
|
||||
|
||||
def test_a_corrupt_cached_cli_is_replaced(tmp_path, monkeypatch):
|
||||
"""A cached binary that fails its hash is deleted and fetched again,
|
||||
and the fresh download is hash-checked too."""
|
||||
monkeypatch.setenv("LEDMATRIX_TAILWIND_CACHE", str(tmp_path))
|
||||
name = build_css.asset_name()
|
||||
cached = tmp_path / f"v{build_css.TAILWIND_VERSION}" / name
|
||||
cached.parent.mkdir(parents=True)
|
||||
cached.write_bytes(b"not the cli")
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, data):
|
||||
self.data = data
|
||||
|
||||
def read(self, n=-1):
|
||||
data, self.data = self.data, b""
|
||||
return data
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(build_css.urllib.request, "urlopen",
|
||||
lambda url, timeout: FakeResponse(b"tampered"))
|
||||
with pytest.raises(SystemExit, match="SHA-256 mismatch"):
|
||||
build_css.ensure_cli()
|
||||
assert not cached.exists()
|
||||
assert not any(p.name.startswith(".download-") for p in cached.parent.iterdir())
|
||||
|
||||
|
||||
def test_the_cli_is_only_downloaded_over_https(tmp_path, monkeypatch):
|
||||
"""urlopen would also follow file:// and custom schemes; the download
|
||||
refuses anything but https before it opens the URL."""
|
||||
monkeypatch.setenv("LEDMATRIX_TAILWIND_CACHE", str(tmp_path))
|
||||
monkeypatch.setattr(build_css, "DOWNLOAD_URL", "file:///etc/{version}/{asset}")
|
||||
|
||||
def fail(*args, **kwargs):
|
||||
raise AssertionError("urlopen must not be called for a non-https URL")
|
||||
|
||||
monkeypatch.setattr(build_css.urllib.request, "urlopen", fail)
|
||||
with pytest.raises(SystemExit, match="non-https"):
|
||||
build_css.ensure_cli()
|
||||
@@ -63,3 +63,17 @@ def test_web_ui_page_uses_the_ledmatrix_prefix_fallback(client, tmp_path):
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "radar panel" in response.get_data(as_text=True)
|
||||
|
||||
|
||||
def test_web_ui_page_styles_come_from_the_pi_not_a_cdn(client, tmp_path):
|
||||
"""In AP mode there is no internet; a CDN stylesheet left fragments unstyled."""
|
||||
web_ui = tmp_path / "radar" / "web_ui"
|
||||
web_ui.mkdir(parents=True)
|
||||
(web_ui / "panel.html").write_text("<p>radar panel</p>", encoding="utf-8")
|
||||
|
||||
body = client.get("/plugin-ui/radar/web-ui/panel.html").get_data(as_text=True)
|
||||
|
||||
assert '<link rel="stylesheet" href="/static/v3/plugin-frame.css' in body
|
||||
assert "cdnjs" not in body and "https://" not in body
|
||||
assert (Path(module.__file__).resolve().parents[1]
|
||||
/ "static" / "v3" / "plugin-frame.css").is_file()
|
||||
|
||||
@@ -144,6 +144,8 @@ def test_legacy_v3_alias_serves_the_same_partials(client, path):
|
||||
|
||||
STATIC_ASSETS = [
|
||||
"/static/v3/app.css",
|
||||
"/static/v3/tailwind.css",
|
||||
"/static/v3/plugin-frame.css",
|
||||
"/static/v3/app.js",
|
||||
"/static/v3/manifest.json",
|
||||
"/static/v3/icons/icon-192.png",
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
"""
|
||||
Static-analysis audits for the web UI, as tests so CI enforces them.
|
||||
|
||||
1. Breakpoint utility audit: app.css hand-maintains a Tailwind-style utility
|
||||
subset, so a template can reference a responsive class (e.g. sm:block)
|
||||
that no CSS rule defines — it silently no-ops. This once left the header
|
||||
search box and system stats invisible at every screen width. The audit
|
||||
diffs classes used in templates against classes defined in app.css.
|
||||
1. Utility class audits: templates and JS are written in Tailwind class names,
|
||||
and a class no stylesheet defines silently does nothing. `.hidden` was
|
||||
missing for years, which broke every JS show/hide toggle, and a
|
||||
hand-written selector starting `.2xl` (an unescaped leading digit) was
|
||||
invalid CSS, so no 2xl: class ever applied. The utilities are now
|
||||
generated (scripts/build_css.py writes static/v3/tailwind.css), and these
|
||||
tests check that the committed output plus app.css cover every class the
|
||||
markup uses. That catches a class assembled at runtime that needs a
|
||||
safelist entry in web_interface/tailwind/tailwind.config.js. (A template
|
||||
edited without rebuilding is caught by `build_css.py --check` in CI.)
|
||||
|
||||
2. Asset reference audit: every url_for('static', filename=...) in the
|
||||
templates must point to a file that exists, so a renamed/moved asset
|
||||
@@ -24,6 +29,7 @@ WEB = PROJECT_ROOT / "web_interface"
|
||||
TEMPLATES = WEB / "templates"
|
||||
STATIC = WEB / "static"
|
||||
APP_CSS = STATIC / "v3" / "app.css"
|
||||
TAILWIND_CSS = STATIC / "v3" / "tailwind.css"
|
||||
|
||||
BP_PREFIXES = ("sm", "md", "lg", "xl", "2xl")
|
||||
|
||||
@@ -32,6 +38,44 @@ def _template_files():
|
||||
return sorted(TEMPLATES.rglob("*.html"))
|
||||
|
||||
|
||||
def _stylesheets():
|
||||
"""The main UI's CSS as base.html loads it: generated utilities, then app.css."""
|
||||
return "\n".join(
|
||||
p.read_text(encoding="utf-8") for p in (TAILWIND_CSS, APP_CSS)
|
||||
)
|
||||
|
||||
|
||||
def _css_unescape(name):
|
||||
r"""`\32xl\:px-16` -> `2xl:px-16` (CSS hex and character escapes)."""
|
||||
name = re.sub(
|
||||
r"\\([0-9a-fA-F]{1,6}) ?", lambda m: chr(int(m.group(1), 16)), name
|
||||
)
|
||||
return re.sub(r"\\(.)", r"\1", name)
|
||||
|
||||
|
||||
def _css_light_classes(css):
|
||||
"""Classes that get a rule outside [data-theme="dark"]."""
|
||||
css = re.sub(r"/\*.*?\*/", "", css, flags=re.S)
|
||||
defined = set()
|
||||
for block in re.finditer(r"([^{}]+)\{", css):
|
||||
for sel in block.group(1).split(","):
|
||||
sel = sel.strip()
|
||||
if sel.startswith("@") or sel.startswith('[data-theme="dark"]'):
|
||||
continue
|
||||
for m in re.finditer(r"\.((?:\\[0-9a-fA-F]{1,6} ?|\\.|[\w-])+)", sel):
|
||||
defined.add(_css_unescape(m.group(1)))
|
||||
return defined
|
||||
|
||||
|
||||
def test_css_unescape_reads_tailwind_selectors():
|
||||
assert _css_unescape(r"\32xl\:px-16") == "2xl:px-16"
|
||||
assert _css_unescape(r"md\:grid-cols-2") == "md:grid-cols-2"
|
||||
assert _css_unescape(r"w-1\/2") == "w-1/2"
|
||||
assert "2xl:grid-cols-5" in _css_light_classes(
|
||||
r"@media (min-width:1536px){.\32xl\:grid-cols-5{grid-template-columns:1fr}}"
|
||||
)
|
||||
|
||||
|
||||
def test_every_used_breakpoint_class_is_defined():
|
||||
used = set()
|
||||
class_attr = re.compile(r'class="([^"]*)"')
|
||||
@@ -41,18 +85,16 @@ def test_every_used_breakpoint_class_is_defined():
|
||||
for m in bp_class.finditer(attr):
|
||||
used.add(m.group(0))
|
||||
|
||||
css = APP_CSS.read_text(encoding="utf-8")
|
||||
defined = {
|
||||
m.group(0).lstrip(".").replace("\\:", ":")
|
||||
for m in re.finditer(
|
||||
r"\.(%s)\\:[A-Za-z0-9_-]+" % "|".join(BP_PREFIXES), css
|
||||
)
|
||||
cls for cls in _css_light_classes(_stylesheets())
|
||||
if cls.split(":", 1)[0] in BP_PREFIXES
|
||||
}
|
||||
|
||||
missing = sorted(used - defined)
|
||||
assert not missing, (
|
||||
"Responsive utility classes referenced in templates but never defined "
|
||||
f"in app.css (they silently no-op): {missing}"
|
||||
"Responsive utility classes referenced in templates but not in "
|
||||
"tailwind.css or app.css (they silently no-op). Run "
|
||||
f"`python3 scripts/build_css.py`: {missing}"
|
||||
)
|
||||
|
||||
|
||||
@@ -82,24 +124,12 @@ _UTILITY = re.compile(
|
||||
)
|
||||
|
||||
|
||||
def _css_light_classes(css):
|
||||
"""Classes that get a rule outside [data-theme="dark"]."""
|
||||
css = re.sub(r"/\*.*?\*/", "", css, flags=re.S)
|
||||
defined = set()
|
||||
for block in re.finditer(r"([^{}]+)\{", css):
|
||||
for sel in block.group(1).split(","):
|
||||
sel = sel.strip()
|
||||
if sel.startswith("@") or sel.startswith('[data-theme="dark"]'):
|
||||
continue
|
||||
for m in re.finditer(r"\.((?:\\.|[\w-])+)", sel):
|
||||
defined.add(m.group(1).replace("\\", ""))
|
||||
return defined
|
||||
|
||||
|
||||
def test_every_used_utility_class_is_defined():
|
||||
"""app.css is the whole stylesheet (no Tailwind build), so a utility class
|
||||
it doesn't define silently does nothing. `.hidden` was missing for years,
|
||||
which broke every JS show/hide toggle. Scans templates and static JS."""
|
||||
"""A utility class neither tailwind.css nor app.css defines silently does
|
||||
nothing. Scans templates and static JS the way Tailwind can't: it also
|
||||
reads classList calls and `${...}`-stripped template strings, so a class
|
||||
only ever built at runtime shows up here as missing until it's safelisted.
|
||||
"""
|
||||
attr = re.compile(r"""(?:class|className)\s*[=:]\s*(["'`])(.*?)\1""", re.S)
|
||||
class_list = re.compile(r"classList\.(?:add|remove|toggle)\(([^)]*)\)")
|
||||
template_expr = re.compile(r"\{\{.*?\}\}|\{%.*?%\}|\$\{[^}]*\}", re.S)
|
||||
@@ -125,14 +155,44 @@ def test_every_used_utility_class_is_defined():
|
||||
if _UTILITY.match(token):
|
||||
used.setdefault(token, path.relative_to(PROJECT_ROOT))
|
||||
|
||||
defined = _css_light_classes(APP_CSS.read_text(encoding="utf-8"))
|
||||
defined = _css_light_classes(_stylesheets())
|
||||
missing = sorted(f"{cls} ({used[cls]})" for cls in used if cls not in defined)
|
||||
assert not missing, (
|
||||
"Utility classes used in templates/JS but not defined in app.css "
|
||||
f"(they silently no-op): {missing}"
|
||||
"Utility classes used in templates/JS but not in tailwind.css or "
|
||||
"app.css (they silently no-op). Run `python3 scripts/build_css.py`; if "
|
||||
"the class is assembled at runtime, safelist it in "
|
||||
f"web_interface/tailwind/tailwind.config.js: {missing}"
|
||||
)
|
||||
|
||||
|
||||
def test_runtime_built_colour_classes_are_generated():
|
||||
"""tools.html builds `bg-${color}-50` and friends from green/red/yellow;
|
||||
the scanner can't see those, so they only exist through the safelist."""
|
||||
defined = _css_light_classes(TAILWIND_CSS.read_text(encoding="utf-8"))
|
||||
for color in ("green", "red", "yellow"):
|
||||
for cls in (f"bg-{color}-50", f"bg-{color}-100", f"border-{color}-200",
|
||||
f"text-{color}-600", f"text-{color}-700", f"text-{color}-800"):
|
||||
assert cls in defined, cls
|
||||
|
||||
|
||||
def test_tailwind_loads_before_app_css():
|
||||
"""app.css overrides utilities of equal specificity (components, dark
|
||||
theme), which only works if it comes second. Every page that links
|
||||
app.css needs tailwind.css too: app.css no longer holds the utilities, so
|
||||
a standalone page (login.html) without it renders unstyled."""
|
||||
pages = []
|
||||
for path in _template_files():
|
||||
text = path.read_text(encoding="utf-8")
|
||||
app = text.find("filename='v3/app.css'")
|
||||
if app == -1:
|
||||
continue
|
||||
pages.append(path.name)
|
||||
tw = text.find("filename='v3/tailwind.css'")
|
||||
assert tw != -1, f"{path.relative_to(PROJECT_ROOT)} links app.css but not tailwind.css"
|
||||
assert tw < app, f"{path.relative_to(PROJECT_ROOT)} links tailwind.css after app.css"
|
||||
assert "base.html" in pages
|
||||
|
||||
|
||||
def test_every_static_url_for_points_to_a_real_file():
|
||||
ref = re.compile(
|
||||
r"url_for\(\s*['\"]static['\"]\s*,\s*filename\s*=\s*['\"]([^'\"]+)['\"]"
|
||||
|
||||
@@ -28,6 +28,7 @@ def client(monkeypatch):
|
||||
@pytest.mark.parametrize("url,path", [
|
||||
("/static/v3/plugins_manager.js", "v3/plugins_manager.js"),
|
||||
("/static/v3/app.css", "v3/app.css"),
|
||||
("/static/v3/tailwind.css", "v3/tailwind.css"),
|
||||
])
|
||||
def test_static_text_asset_is_gzipped_and_round_trips(client, url, path):
|
||||
resp = client.get(url, headers={"Accept-Encoding": "gzip, deflate"})
|
||||
|
||||
Reference in New Issue
Block a user