Files
LEDMatrix/test/test_build_css_script.py
T
ChuckandClaude Opus 5.5 ba6eccb489 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>
2026-09-30 09:38:16 -04:00

130 lines
4.9 KiB
Python

"""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()