"""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=`` 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=`` 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" )