backend/tests/integration/test_static_html_cache_headers.py asserted
that "/", "/spoolbuddy/", and "/printers" return text/html with
Cache-Control: no-cache, must-revalidate. The Dockerfile.test
backend-test target intentionally doesn't bake in the built frontend
(saves ~30s of build time per test run), so static/index.html doesn't
exist inside the container. The route handlers correctly fall through
to their "frontend not built" JSON branches, and the test fails with
"non-HTML content-type: application/json" — latent since the test was
added in e9200449 (Apr 26).
Add a fake_static_index fixture that creates a tmp dir with a one-line
<!doctype html> stub and monkeypatches app_settings.static_dir to it.
Both call sites are patched (config.settings and main.app_settings) in
case a future refactor splits the singleton. The test continues to hit
the real serve_frontend / serve_spa route handlers and validates the
real cache-header contract — just doesn't depend on a built bundle
being present.
Verified passing both with and without static/index.html present.
Reproduced live during the #1133 rollout: the SpoolBuddy display kept
serving the pre-fix picker for hours after every cache-clear,
chromium-restart, and pkill attempt because a chain of stale state
across HTTP cache + Service Worker + persistent profile prevented
fresh code from reaching the running tab.
Three independent changes — any one of them sufficient on a clean
profile, but all three needed to escape an already-corrupted one:
(1) backend/app/main.py — index.html now served with
Cache-Control: no-cache, must-revalidate on both / and the SPA
catch-all. Vite emits content-hashed JS/CSS bundle filenames so the
assets themselves are safe to cache forever, but the HTML wrapping
them is the only file that knows which hash is current. Without
explicit cache directives Chromium falls back to heuristic caching
(typically 10% of time since Last-Modified) and on long-running
kiosks happily serves stale HTML across browser restarts. That stale
HTML references an old bundle hash which is also still in disk
cache, so the kiosk runs pre-deploy JS forever without ever knowing
why.
(2) frontend/public/sw.js — CACHE_NAME bumped from bambuddy-v25 to
bambuddy-v26 so any client that fetches the new sw.js drops its old
CacheStorage. The SW does network-first for HTML/JS/CSS but
intercepts and falls back to cache, and cache-control on HTTP
responses doesn't reach into the SW's own cache layer.
(3) spoolbuddy/install/install.sh — generated kiosk launcher now uses
--user-data-dir=/tmp/spoolbuddy-kiosk-userdata with a pre-launch
rm -rf, so every kiosk restart starts from a clean slate (no HTTP
cache, no SW registration, no IndexedDB). Trade-off is a slightly
slower first paint and zero offline support; neither matters for a
single-purpose kiosk facing a backend on the same LAN, and the
guarantee that next-deploy-just-works is worth far more.
4 new tests in test_static_html_cache_headers.py: index.html on /
and SPA catch-all paths emit Cache-Control: no-cache,
must-revalidate; API routes are unaffected (no leak of HTML cache
directive onto endpoints we want React Query to cache aggressively).
For existing kiosks already trapped by an old persistent profile,
operator runs once: rm -rf ~/.config/chromium && systemctl restart
getty@tty1.service. The new launcher then picks up automatically.