perf(ui): cache busting — HTML no-cache + versioned asset URLs (?v=) with immutable 1y asset caching

Phase 33 (story: .agent/user_stories/cache-busting.md).

- app/core/caching.py: asset_version() — git short SHA (a commit is a
  deploy), stable content-hash fallback for non-git checkouts, "dev"
  for a missing static dir; computed once per process. CachingMiddleware
  — the five HTML pages revalidate (no-cache) with ?v=<token> asset refs
  rewritten in flight; /assets/* is public, max-age=31536000, immutable;
  everything else (all /api/*, the SSE chat stream in particular) passes
  through byte-identical.
- tests/e2e/test_cache_busting.py: fresh-Chromium wire assertions —
  document no-cache, versioned CSS/JS request URLs sharing one token,
  immutable asset headers, /api/health baseline headers, SSE chat to
  done (mock LLM).
- README 'Caching / deploys' section + story file.

Also fixed two prod-image defects surfaced by this phase's podman smoke
(the full app would not boot):
- Containerfile: ship the scripts/ package — app/api/sync.py (phase 32)
  imports scripts.git_sync / scripts.import_docs at module level, so the
  container crashed on boot (ModuleNotFoundError: No module named
  'scripts').
- compose.yaml: pass BOR_ADMIN_PASSWORD / BOR_SESSION_SECRET through to
  the app service (:- defaults keep 'podman compose up -d db' working;
  the app's own fail-loud gate still names missing admin auth).

Smoke: podman compose --profile prod up -d on a fresh image + a fresh
Chromium profile — /, /sources.html and /login.html all served
Cache-Control: no-cache; all 8 asset requests versioned with one shared
token (content-hash fallback inside the image — no .git there);
/assets/* immutable for a year.
This commit is contained in:
2026-08-25 22:42:10 -04:00
parent 52136fe307
commit 8fabb7efda
12 changed files with 988 additions and 0 deletions
+138
View File
@@ -0,0 +1,138 @@
"""Phase 33 E2E (Playwright): cache busting — what the browser actually
receives and requests.
Story: ``.agent/user_stories/cache-busting.md``
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_cache_busting.py -v --no-cov
The assertions are against the wire: the HTML document responses carry
``Cache-Control: no-cache``; every asset request URL the browser actually
makes carries ``?v=<token>`` (one token per process — the git short SHA of
this checkout, i.e. the deploy); the asset responses are immutable for a
year; and the API — the SSE chat stream in particular — is untouched. The
mock LLM keeps the SSE check deterministic (no live aipi).
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
import httpx
from playwright.sync_api import Page
REPO = Path(__file__).resolve().parents[2]
CHAT_QUESTION = "How is my Kubernetes cluster set up?"
def _expected_token() -> str:
"""The token the app process appends to its asset URLs.
Computed exactly the way the app does (``asset_version`` over the same
static dir): the git short SHA of this checkout in a git repo (a commit
is a deploy), so the browser's asset requests must carry it.
"""
from app.core.caching import asset_version
return asset_version(str(REPO / "frontend"))
def _version_token(url: str) -> str:
"""Extract the ``?v=`` token from a versioned asset URL (asserts one)."""
assert "?v=" in url, f"asset request is not versioned: {url}"
return url.rsplit("?v=", 1)[1]
def _stream_chat_frames(app_url: str, message: str) -> list[dict[str, Any]]:
"""Minimal SSE chat request (same pattern as ``test_chat_rag.py``):
POST /api/chat and collect the ``data:`` frames until the stream ends."""
frames: list[dict[str, Any]] = []
with httpx.stream(
"POST", f"{app_url}/api/chat", json={"message": message}, timeout=60.0
) as r:
assert r.status_code == 200
assert r.headers["content-type"].startswith("text/event-stream")
buf = ""
for part in r.iter_text():
buf += part
while "\n\n" in buf:
frame, buf = buf.split("\n\n", 1)
if frame.strip().startswith("data:"):
frames.append(
json.loads(frame.strip().removeprefix("data:").strip())
)
return frames
def test_html_pages_are_no_cache_and_versioned(page: Page, app_url: str) -> None:
"""`/`: the document revalidates (no-cache); the CSS/JS request URLs
the browser actually makes carry the process token; the asset
responses are immutable for a year; the served HTML carries no
unversioned asset references."""
token = _expected_token()
assert token, "the version token must be non-empty"
with (
page.expect_response(lambda r: "/assets/styles.css" in r.url) as css_info,
page.expect_response(lambda r: "/assets/app.js" in r.url) as js_info,
):
doc = page.goto(app_url)
# The document: always revalidated, never served from cache unchecked.
assert doc is not None
assert doc.headers["cache-control"] == "no-cache"
# CSS: versioned request URL + immutable-for-a-year response.
css = css_info.value
assert _version_token(css.url) == token
css_cc = css.headers["cache-control"]
assert "immutable" in css_cc
assert "max-age=31536000" in css_cc
# JS: the SAME token (one per process — the URL identifies the
# content, which is what makes the 1-year cache safe).
assert _version_token(js_info.value.url) == _version_token(css.url)
# The served HTML carries the versioned reference and no unversioned
# one (the "sticky" reference is gone from the page the browser sees).
html = page.content()
assert f'href="/assets/styles.css?v={token}"' in html
assert '/assets/styles.css"' not in html
def test_other_pages_share_the_token(page: Page, app_url: str) -> None:
"""/sources.html and /login.html: each document revalidates, and both
pages' stylesheet requests carry the same process token."""
token = _expected_token()
assert token
def navigate(path: str) -> str:
with page.expect_response(
lambda r: "/assets/styles.css" in r.url
) as css_info:
doc = page.goto(f"{app_url}{path}")
assert doc is not None
assert doc.headers["cache-control"] == "no-cache"
return _version_token(css_info.value.url)
sources_token = navigate("/sources.html")
login_token = navigate("/login.html")
assert sources_token == login_token == token
def test_api_responses_unaffected(page: Page, app_url: str, db_ready: None) -> None:
"""`/api/*` passes through untouched: no injected Cache-Control on the
health endpoint, and the SSE chat stream still streams to done."""
r = page.request.get(f"{app_url}/api/health")
assert r.status == 200
# Baseline (pre-middleware) behavior: FastAPI's JSON responses ship no
# Cache-Control header — the middleware must not inject one.
assert "cache-control" not in r.headers
# The SSE contract (PLAN §4) survives the middleware: deltas, then a
# final done — the stream is neither read nor rewritten by it.
frames = _stream_chat_frames(app_url, CHAT_QUESTION)
assert frames, "the SSE stream must deliver events"
assert any(f["type"] == "delta" for f in frames), "answer must be streamed"
assert frames[-1]["type"] == "done", "the stream must complete with done"