fix(web): never 304 a rewritten page — pages drop conditional validators, assets keep them
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
"""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"
|
||||
)
|
||||
Reference in New Issue
Block a user