Files
LEDMatrix/test/test_ledpi_script_fixes.py
T
ChuckandClaude Opus 5.5 da5937da3d fix: six bugs found testing main on a real Pi (#641)
* fix: six bugs found testing main on a real Pi (ledpi)

- Stopping the service now runs cleanup. systemd stops ledmatrix.service
  with SIGTERM, whose default action ended Python before run()'s finally
  block, so the update worker, Vegas and the panel were never torn down.
  main() now turns SIGTERM into KeyboardInterrupt, the Ctrl-C path.
- "Now showing" no longer turns into "unknown". display_current_state was
  only written on a mode change and the web UI reads it with max_age=120,
  so a live game or a single plugin on screen for longer read as unknown.
  It is republished every 30 s while unchanged.
- Switching Vegas on in the web UI works when it was off at startup. The
  coordinator was only created at startup; the config watcher now flags it
  and the render thread creates it.
- configure_web_sudo.sh finds reboot and poweroff in /usr/sbin. Run as the
  web user it could not, silently dropped their rules and still said it
  granted them, so the web UI's Reboot/Shutdown stopped working.
- check_system_compatibility.sh reports installed packages as installed.
  `dpkg -l | grep -q` under pipefail failed when grep exited early.
- A network failure fetching GitHub repo info logs a WARNING, not ERROR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(changelog): fixes found testing on a Pi

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: run the Linux-only script tests correctly

The sbin-lookup test set PATH=/nonexistent and then could not find bash
itself; call it by absolute path. The dpkg-query stub read $4, but the
package name is the third (last) argument. Both now pass on a Pi.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(display): cover the follower, long-render and startup cases

Review follow-ups on the ledpi fixes:
- The pending Vegas start is applied before the sync-follower branch too
  (_apply_pending_vegas_init), which skips _is_vegas_mode_active() while a
  follower is connected but needs the coordinator for the leader's image.
- _service_pending_changes(), which runs inside Vegas iterations and long
  screens, republishes a stale display_current_state as well; the main
  loop alone could be away for a 240 s Vegas iteration.
- The SIGTERM handler is installed after DisplayController() is built, so a
  stop during parallel plugin loading keeps the default immediate exit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 18:13:49 -04:00

103 lines
4.6 KiB
Python

"""Script and logging fixes found testing on a real Pi (ledpi).
- configure_web_sudo.sh runs as the web user, whose PATH lacks /usr/sbin, so
`command -v reboot` failed and the reboot/poweroff rules were dropped.
- check_system_compatibility.sh ran `dpkg -l | grep -q` under pipefail, which
reported installed packages as missing.
- A network failure fetching GitHub repo info is a WARNING, not an ERROR.
"""
import logging
import re
import shutil
import subprocess
import sys
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest.mock import patch
import pytest
import requests
ROOT = Path(__file__).resolve().parent.parent
WEB_SUDO = ROOT / "scripts" / "install" / "configure_web_sudo.sh"
COMPAT = ROOT / "scripts" / "check_system_compatibility.sh"
def _function_source(script: Path, name: str) -> str:
text = script.read_text(encoding="utf-8")
m = re.search(rf"^{name}\(\) \{{\n.*?^\}}\n", text, re.S | re.M)
assert m, f"{name}() not found in {script.name}"
return m.group(0)
needs_linux_bash = pytest.mark.skipif(
sys.platform == "win32" or not shutil.which("bash") or not Path("/bin/sh").exists(),
reason="runs the script's shell function; needs a Linux bash",
)
@needs_linux_bash
def test_find_command_looks_outside_path():
"""With a PATH that has none of the standard dirs, a command in one of
them is still found (reboot and poweroff live in /usr/sbin)."""
target = next((d for d in ("/usr/sbin", "/sbin", "/usr/bin", "/bin")
if Path(d, "sh").exists() or Path(d, "reboot").exists()), None)
name = "reboot" if Path(target, "reboot").exists() else "sh"
script = _function_source(WEB_SUDO, "find_command") + f'find_command {name}\n'
# Absolute bash: with PATH=/nonexistent, "bash" itself would not be found.
out = subprocess.run([shutil.which("bash"), "-c", script], env={"PATH": "/nonexistent"},
capture_output=True, text=True)
assert out.returncode == 0, out.stderr
assert out.stdout.strip().endswith("/" + name)
def test_web_sudo_resolves_reboot_and_poweroff_through_find_command():
text = WEB_SUDO.read_text(encoding="utf-8")
for var, cmd in (("REBOOT_PATH", "reboot"), ("POWEROFF_PATH", "poweroff")):
assert re.search(rf"^{var}=\$\(find_command {cmd}\)", text, re.M), var
assert "/usr/sbin" in _function_source(WEB_SUDO, "find_command")
def test_compat_check_does_not_pipe_dpkg_into_grep_q():
# grep -q exits on its first match; dpkg then dies of SIGPIPE and, under
# `set -o pipefail`, the check fails for an installed package.
text = COMPAT.read_text(encoding="utf-8")
code = "\n".join(l for l in text.splitlines() if not l.lstrip().startswith("#"))
assert "pipefail" in code
assert not re.search(r"dpkg -l\s*\|\s*grep -q", code)
assert "dpkg-query -W" in code
@needs_linux_bash
def test_compat_package_check_reports_an_installed_package_as_installed():
"""Run the script's package test against a stub dpkg-query."""
line = next(l for l in COMPAT.read_text(encoding="utf-8").splitlines()
if "dpkg-query -W" in l).strip()
with TemporaryDirectory() as tmp:
stub = Path(tmp, "dpkg-query")
# The package name is the last argument: dpkg-query -W -f=... <pkg>.
stub.write_text('#!/bin/sh\nfor a; do last=$a; done\n'
'[ "$last" = "git" ] && printf "install ok installed"\nexit 0\n')
stub.chmod(0o755)
script = ("set -Eeuo pipefail\n"
f"for pkg in git notthere; do {line} echo \"$pkg:yes\"; else echo \"$pkg:no\"; fi; done\n")
out = subprocess.run(["bash", "-c", script],
env={"PATH": f"{tmp}:/usr/bin:/bin"}, capture_output=True, text=True)
assert out.returncode == 0, out.stderr
assert out.stdout.split() == ["git:yes", "notthere:no"]
def test_github_network_failure_logs_a_warning_not_an_error(caplog):
from src.plugin_system.store_manager import PluginStoreManager
with TemporaryDirectory() as tmp:
sm = PluginStoreManager(plugins_dir=tmp)
with patch("src.plugin_system.store_manager.requests.get",
side_effect=requests.ConnectionError("offline")), \
caplog.at_level(logging.WARNING):
info = sm._get_github_repo_info("https://github.com/owner/repo")
assert info == dict(PluginStoreManager._EMPTY_REPO_INFO)
ours = [r for r in caplog.records if "owner/repo" in r.getMessage()]
assert ours, "nothing logged for the failed fetch"
assert all(r.levelno == logging.WARNING for r in ours), [(r.levelname, r.getMessage()) for r in ours]