Files
LEDMatrix/test/test_web_smoke.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

221 lines
8.5 KiB
Python

"""
Web-UI smoke tests: every page, partial, and critical static asset must render.
These boot the pages blueprint with the same dual registration app.py uses
(un-prefixed primary + /v3 legacy alias) and assert each surface returns 200
with its load-bearing markers present. They exist to catch, in CI, the class
of regression that only shows up when a real request renders a real template:
a broken partial, a missing tab wiring, a renamed element id that JS depends
on, or a static asset that stopped being served.
"""
import sys
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from flask import Flask
PROJECT_ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
SMOKE_CONFIG = {
"web_display_autostart": True,
"timezone": "America/Chicago",
"location": {"city": "Dallas", "state": "Texas", "country": "US"},
"plugin_system": {
"auto_discover": True,
"auto_load_enabled": True,
"development_mode": False,
"plugins_directory": "plugin-repos",
},
"schedule": {},
"dim_schedule": {"dim_brightness": 30},
"sync": {"role": "standalone", "port": 5765, "follower_position": "left"},
"clock": {"enabled": True},
"ledmatrix-weather": {"enabled": True},
"display": {
"hardware": {
"rows": 32, "cols": 64, "chain_length": 2, "parallel": 1,
"brightness": 95, "hardware_mapping": "adafruit-hat-pwm",
"led_rgb_sequence": "RGB", "multiplexing": 0, "panel_type": "",
"row_address_type": 0, "scan_mode": 0, "pwm_bits": 9,
"pwm_dither_bits": 1, "pwm_lsb_nanoseconds": 130,
"limit_refresh_rate_hz": 120, "disable_hardware_pulsing": False,
"inverse_colors": False, "show_refresh_rate": False,
},
"runtime": {"gpio_slowdown": 3, "rp1_rio": 0},
"double_sided": {"enabled": False, "copies": 2, "axis": "horizontal"},
"use_short_date_format": False,
"dynamic_duration": {"max_duration_seconds": 180},
"vegas_scroll": {
"enabled": False, "scroll_speed": 50, "separator_width": 32,
"target_fps": 125, "buffer_ahead": 2,
"plugin_order": [], "excluded_plugins": [],
},
"display_durations": {"stale_saved_mode": 45},
"plugin_rotation_order": ["ledmatrix-weather", "clock"],
},
}
PLUGIN_MODES = {
"clock": ["clock"],
"ledmatrix-weather": ["weather_current", "weather_daily"],
}
@pytest.fixture
def client():
base = PROJECT_ROOT / "web_interface"
app = Flask(
__name__,
template_folder=str(base / "templates"),
static_folder=str(base / "static"),
)
app.config["TESTING"] = True
from web_interface.blueprints import pages_v3 as pv
# pages_v3 is a module-level Blueprint singleton shared by the whole test
# process (test_web_settings_ui.py mutates the same attributes) - save
# the originals and restore them on teardown so this fixture can't leak
# its mocks into tests that run afterward.
original_config_manager = getattr(pv.pages_v3, "config_manager", None)
original_plugin_manager = getattr(pv.pages_v3, "plugin_manager", None)
mock_cm = MagicMock()
mock_cm.load_config.return_value = SMOKE_CONFIG
mock_cm.get_raw_file_content.return_value = SMOKE_CONFIG
mock_cm.get_config_path.return_value = "config/config.json"
mock_cm.get_secrets_path.return_value = "config/config_secrets.json"
pv.pages_v3.config_manager = mock_cm
mock_pm = MagicMock()
mock_pm.plugins = {}
mock_pm.get_all_plugin_info.return_value = [
{"id": "clock", "name": "Clock"},
{"id": "ledmatrix-weather", "name": "Weather"},
]
mock_pm.get_plugin_display_modes.side_effect = (
lambda pid: PLUGIN_MODES.get(pid, [])
)
pv.pages_v3.plugin_manager = mock_pm
# Same dual registration as web_interface/app.py: un-prefixed primary,
# /v3 kept as a working legacy alias.
app.register_blueprint(pv.pages_v3, url_prefix="")
app.register_blueprint(pv.pages_v3, url_prefix="/v3", name="pages_v3_legacy")
try:
yield app.test_client()
finally:
pv.pages_v3.config_manager = original_config_manager
pv.pages_v3.plugin_manager = original_plugin_manager
# (path, [markers that must appear in the body])
PAGES = [
("/", ["site-nav", "mobileNavOpen", 'rel="manifest"',
"restart-pending-banner", "activeTab = 'durations'"]),
("/partials/overview", ["getting-started-card", "displayImage"]),
("/partials/general", ["timezone"]),
("/partials/display", ["display-section-advanced-hardware",
"display-resolution-value", "vegas_scroll_label"]),
("/partials/durations", ["rotation_plugin_order", "duration__clock",
"duration__weather_current",
"duration__stale_saved_mode"]),
("/partials/schedule", ["schedule"]),
]
@pytest.mark.parametrize("path,markers", PAGES, ids=[p for p, _ in PAGES])
def test_page_renders_with_markers(client, path, markers):
resp = client.get(path)
assert resp.status_code == 200, f"{path} -> {resp.status_code}"
body = resp.get_data(as_text=True)
for marker in markers:
assert marker in body, f"{path}: missing marker {marker!r}"
@pytest.mark.parametrize("path", [p for p, _ in PAGES if p != "/"])
def test_legacy_v3_alias_serves_the_same_partials(client, path):
assert client.get("/v3" + path).status_code == 200
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",
"/static/v3/js/app-shell.js",
"/static/v3/js/app-early.js",
"/static/v3/js/htmx-config.js",
"/static/v3/js/widgets/plugin-order-list.js",
"/static/v3/js/widgets/notification.js",
"/static/v3/vendor/fontawesome/css/all.min.css",
"/static/v3/vendor/codemirror/codemirror.min.js",
]
@pytest.mark.parametrize("asset", STATIC_ASSETS)
def test_static_asset_served(client, asset):
resp = client.get(asset)
assert resp.status_code == 200, f"{asset} -> {resp.status_code}"
assert len(resp.data) > 0
def test_widget_bundle_is_served_and_requested(client):
"""base.html loads every widget through one versioned bundle request."""
page = client.get("/").get_data(as_text=True)
assert "/assets/widgets.js?v=" in page
resp = client.get("/assets/widgets.js")
assert resp.status_code == 200
assert resp.mimetype == "application/javascript"
body = resp.get_data(as_text=True)
assert body.index("/* registry.js */") < body.index("/* notification.js */")
assert "/* json-file-manager.js */" in body
def test_durations_page_groups_by_plugin(client):
"""One duration input per display mode of each enabled plugin, plus the
leftover group for saved keys no enabled plugin owns."""
body = client.get("/partials/durations").get_data(as_text=True)
assert body.count("duration__") >= 2 * len(
[m for modes in PLUGIN_MODES.values() for m in modes]
) # each mode: id= and name=
assert "Other saved entries" in body
def test_durations_page_leaves_unsaved_modes_blank(client):
"""A saved duration overrides the plugin's own, so the page must not
pre-fill one for every mode: the first save would pin them all. Unsaved
modes are blank, with the plugin's duration as the placeholder."""
import copy
from web_interface.blueprints import pages_v3 as pv
config = copy.deepcopy(SMOKE_CONFIG)
config["clock"]["display_duration"] = 20
config["display"]["display_durations"] = {"weather_current": 40}
pv.pages_v3.config_manager.load_config.return_value = config
body = client.get("/partials/durations").get_data(as_text=True)
def field(mode):
start = body.index(f'id="duration__{mode}"')
return body[start:body.index(">", start)]
assert 'value=""' in field("clock") and 'placeholder="20"' in field("clock")
assert 'value="40"' in field("weather_current")
assert 'value=""' in field("weather_daily")
assert 'placeholder="15"' in field("weather_daily")
def test_display_advanced_section_contains_tuning_fields(client):
body = client.get("/partials/display").get_data(as_text=True)
adv = body.find('id="display-section-advanced-hardware"')
adv_close = body.find("/#display-section-advanced-hardware")
assert 0 < adv < adv_close
for field in ["multiplexing", "pwm_bits", "inverse_colors"]:
pos = body.find(f'name="{field}"')
assert adv < pos < adv_close, f"{field} not inside the advanced section"