159 lines
6.2 KiB
Python
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"
|
|
)
|