Files
brain-of-reese/tests/e2e/test_asset_cache_revalidation.py
T

159 lines
6.2 KiB
Python

"""Phase 54 E2E (Playwright): the browser can never be 304'd onto stale
HTML.
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_asset_cache_revalidation.py -v --no-cov
Phase 33's cache busting had a hole (measured 2026-08-30): the HTML page
response carried the *static file's* ``etag`` / ``last-modified``, while
the *served bytes* were the per-process rewritten body — so a conditional
revalidation 304'd out of the rewrite and the browser kept HTML whose
``?v=`` pointed at the previous commit's CSS/JS, immutable-cached for a
year. Phase 54 closed the hole: on known page paths the middleware strips
``if-none-match`` / ``if-modified-since`` inbound and drops the outbound
validators — a page always 200s with the current ``?v=<token>`` refs.
``/assets/*`` keeps its immutable + validator behavior (a 304 there is
safe — the URL encodes the version).
These assertions are against a real browser: the document it receives is
always the current one (200, current token, no validators), the CSS that
actually renders is this tree's, and the assets stay immutable. The suite
only loads pages — it asks no questions, so no KB seeding is needed. The
E2E app is a uvicorn subprocess of the SAME checkout (``conftest.py`` →
``app_server``), so the expected token is deterministic: the git short
SHA of this checkout.
"""
from __future__ import annotations
import subprocess
from pathlib import Path
from playwright.sync_api import Page, Response
REPO = Path(__file__).resolve().parents[2]
def _expected_token() -> str:
"""The version token the app process appends to its asset URLs.
The E2E app is a subprocess of this very checkout, and in a git
checkout ``asset_version()`` is ``git rev-parse --short HEAD`` —
computed exactly the way the app does it, so the expected value is
deterministic for this run.
"""
return subprocess.run(
["git", "-C", str(REPO), "rev-parse", "--short", "HEAD"],
capture_output=True,
text=True,
check=True,
).stdout.strip()
def _assert_current_document(resp: Response, token: str) -> None:
"""The page-contract assertions (phase 54): 200 with the current
``?v=<token>`` references, no validators, ``no-cache`` — so the
browser can never revalidate its way back to a previous commit's
HTML.
``resp`` is the main document's ``Response`` returned by
``page.goto()`` — Playwright Python treats a bare string in
``expect_response(...)`` as a URL glob, so the navigation return
value is the assertion target for the document itself."""
assert resp.status == 200, f"the document must 200 (never 304): {resp.status}"
headers = resp.headers
assert "etag" not in headers, "a page response must publish no etag"
assert "last-modified" not in headers, (
"a page response must publish no last-modified"
)
assert headers["cache-control"] == "no-cache"
body = resp.text()
assert f"styles.css?v={token}" in body, (
f"the served HTML must reference the current token's CSS "
f"(styles.css?v={token})"
)
def test_document_is_200_current_token_no_validators(
page: Page, app_url: str, db_ready: None
) -> None:
"""`GET /` through a real browser: 200, no `etag`/`last-modified`,
`no-cache`, and the body references the current commit's CSS and JS.
On the pre-fix app a browser that has seen the page once gets a 304
here instead — this is the suite's core assertion."""
token = _expected_token()
assert token, "the version token must be non-empty"
resp = page.goto(app_url)
assert resp is not None
_assert_current_document(resp, token)
# The chat page's two local refs, both carrying the current token.
assert f"app.js?v={token}" in resp.text()
def test_second_navigation_is_still_200_not_304(
page: Page, app_url: str, db_ready: None
) -> None:
"""The exact shape of the reported symptom: a plain re-navigation.
After the first load the browser's cache holds the first document —
the second `goto` must still receive a fresh 200 with the current
token, not a 304 that keeps the previous commit's ``?v=``."""
token = _expected_token()
assert token
page.goto(app_url) # first visit — primes the browser's document cache
resp = page.goto(app_url) # the browser's own revalidation
assert resp is not None
_assert_current_document(resp, token)
def test_browser_renders_current_css_not_stale(
page: Page, app_url: str, db_ready: None
) -> None:
"""End-to-end consequence of the 304 hole: the CSS the browser is
ACTUALLY rendering is the current tree's. The phase-52 rule
(`.messages { flex: 1 1 auto }` → computed `flex-grow: 1`) exists only
in the current `styles.css`, so a green here proves the browser is
not sitting on a stale, immutable-pinned asset from an earlier
commit."""
page.goto(app_url)
flex_grow = page.evaluate(
"() => getComputedStyle(document.querySelector('#messages')).flexGrow"
)
assert flex_grow == "1", (
f"#messages must carry the current tree's `flex: 1 1 auto` "
f"(flex-grow 1) — got {flex_grow!r}; the browser is on stale CSS"
)
def test_other_pages_carry_the_contract(
page: Page, app_url: str, db_ready: None
) -> None:
"""/sources.html (another HTML_PAGES entry): the same document
contract as `/` — 200, no validators, `no-cache`, current token."""
token = _expected_token()
assert token
resp = page.goto(app_url + "/sources.html")
assert resp is not None
_assert_current_document(resp, token)
def test_assets_still_immutable_with_validators(
page: Page, app_url: str, db_ready: None
) -> None:
"""The asymmetry is deliberate: `/assets/*` keeps its validators —
a conditional `GET` on a versioned asset URL may still 304, because
the URL already encodes the version. Only the HTML pages dropped
them."""
with page.expect_response("**/assets/styles.css**") as info:
page.goto(app_url)
resp = info.value
assert resp.status == 200
assert (
resp.headers["cache-control"] == "public, max-age=31536000, immutable"
)
assert "etag" in resp.headers, (
"the asset keeps its validators — only pages dropped them"
)