refactor(agents): migrate .agent/ planning tree to .agents/
Standardize on the .agents/ directory (shared with project skills): phases/, user_stories/, reports/, screenshots/, validate.sh, and phase-sessions/ + pipeline.log all move to .agents/ (git mv preserves history; runtime artifacts move alongside). Updates every reference in AGENTS.md, README.md, .gitignore, app docstrings, and test story headers. Historical KB content in data/ and the runtime pipeline.log transcript are left untouched.
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
# Phase 54 — Never Serve Stale HTML: Fix the 304 Hole in Cache Busting
|
||||
|
||||
**Source:** owner instruction 2026-08-30 (chat) — found live while verifying phase 52: *"add that as phase 54"*. Not a TODO.md item (TODO.md is empty).
|
||||
**Story:** n/a (bug fix discovered by measurement, not a user story)
|
||||
**Context:** Phase 33 (`app/core/caching.py`, `.agents/reports/33_cache_busting__*`) ships two layers: `asset_version()` — a per-process token, `git rev-parse --short HEAD` in a git checkout (`functools.cache`, so it flips only on process start) — and `CachingMiddleware` (mounted in `app/main.py::create_app` via `configure_caching`, wrapping the `StaticFiles(directory=static_dir, html=True)` catch-all mounted at `/`). For every known page (`HTML_PAGES` + the dynamic `/shared/<token>` prefix) the middleware drains the body, rewrites each local `assets/…` `href`/`src` to carry `?v=<token>` (`rewrite_asset_refs`), and re-serves it with `Cache-Control: no-cache`. `/assets/*` gets `public, max-age=31536000, immutable` header-only; `/api/*` (incl. the SSE chat stream) passes through byte-identical. `_no_cache_headers()` copies **every** upstream header and overrides only `content-length` (dropped) and `Cache-Control` — so the static file's `etag` and `last-modified` travel with the rewritten body.
|
||||
|
||||
**The defect (measured on the running app, 2026-08-30):** the conditional-request validators describe the *static file*, but the *served bytes* are the rewritten body — a body this process built from its own token. A revalidation therefore 304s out of the rewrite and the browser keeps the HTML it already has, whose `?v=` points at the **previous** commit's CSS/JS, which is cached `immutable` for a year:
|
||||
|
||||
```
|
||||
GET / → 200, etag: "2a838bc8d1c4fc0e6cb75128913c3fb6", cache-control: no-cache
|
||||
GET / If-None-Match: "2a838bc8…" → 304 ← stale HTML kept, stale CSS pinned
|
||||
GET /history.html → 200, etag: "9b700148…"
|
||||
GET /history.html If-None-Match: "9b700148…" → 304 ← same
|
||||
```
|
||||
|
||||
The 304 is produced *downstream* (Starlette `FileResponse`/`StaticFiles` honours `If-None-Match` / `If-Modified-Since` before the middleware can touch the response), so the middleware must stop honouring those headers on page paths — stripping the outbound validators afterwards is too late: the body never arrives. The asymmetry to preserve: a 304 on `/assets/*` is **safe** (the URL itself is versioned, so a revalidated 304 re-serves the same versioned asset), a 304 on an HTML page is **never safe** (the served body depends on the process token, which the validator ignores). Observed user-visible symptom: after `aba8615` (the phase-52 fix) the bare `GET /` still served HTML referencing `styles.css?v=8207539`, i.e. the browser kept rendering the pre-fix layout until a hard reload.
|
||||
|
||||
## Objective
|
||||
HTML pages are revalidated against the bytes that are actually served, never against the static file underneath the rewrite: a conditional `GET` of any known page (`/`, every `HTML_PAGES` entry, `/shared/<token>`) returns **200 with the current `?v=<token>` body**, and those responses publish no `etag` / `last-modified`. `/assets/*` keeps its immutable-for-a-year + conditional-304 behavior and `/api/*` (incl. SSE) stays byte-identical, so a deploy can no longer leave a browser on stale CSS/JS.
|
||||
|
||||
## Dependencies
|
||||
- `33_cache_busting` (complete) — the module under fix: `asset_version()`, `rewrite_asset_refs()`, `CachingMiddleware`, `_no_cache_headers()`, `HTML_PAGES`, `ASSETS_PREFIX`, `ASSET_CACHE_CONTROL`, `HTML_CACHE_CONTROL`. Behavior contracts to preserve, not change.
|
||||
- `51_share_chat` (complete) — `/shared/<token>` joins the page contract by path prefix (a REAL route serving `shared.html`); it must get the same revalidation fix.
|
||||
- `50_chat_history` (complete) — `/history.html` is in `HTML_PAGES`; it is one of the reproduced 304s.
|
||||
- `52_pinned_composer` (complete) — the fix is what makes that fix actually reach the browser; its `#messages { flex: 1 1 auto }` rule is the "am I on current CSS?" probe the E2E suite asserts.
|
||||
- `16_admin_auth` (complete) — admin pages go through the same middleware; the signed session cookie path must not change.
|
||||
|
||||
## Tasks
|
||||
1. `01_page_paths_ignore_conditional_headers.md` — `app/core/caching.py`: drop `if-none-match` / `if-modified-since` on known-page requests before `call_next`, skip the rewrite for bodiless statuses (204/304), drop the outbound `etag` / `last-modified` on page responses.
|
||||
2. `02_unit_tests_conditional_pages.md` — extend `tests/unit/test_caching.py` with the conditional-request + validator pins on a `StaticFiles`-backed app.
|
||||
3. `03_integration_conditional_get.md` — new `tests/integration/test_caching_revalidation.py`: the real app, real `frontend/` tree, every page family, plus the `/assets/*` and `/api/*` non-regressions.
|
||||
4. `04_e2e_and_docs.md` — the dedicated Playwright suite `tests/e2e/test_asset_cache_revalidation.py` + the README cache-busting section + regressions + commit.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit (`tests/unit/test_caching.py`, extend — house style: bare `FastAPI` app + the middleware + `TestClient`): a conditional `GET /` against a `StaticFiles(html=True)` mount that already holds the etag returns **200 + the rewritten body** (this is the regression that reproduces the defect); a conditional `GET /` with `If-Modified-Since: <the mount's last-modified>` likewise 200s; page responses carry **no** `etag` and **no** `last-modified` while keeping `Cache-Control: no-cache`; a downstream bodiless 304 is passed through as a bodiless 304 (never a `Response(content=…, status_code=304)` with a body, which starlette rejects) with `no-cache` and no validators; `/api/*` and `/assets/*` still pass through byte-identical (conditional `GET /assets/…` may still 304 — that path is URL-versioned and safe); the inbound strip touches page paths only.
|
||||
- Integration (`tests/integration/test_caching_revalidation.py`, new — real `app.main:app`, real `frontend/` tree, no mocks needed): capture the etag of `GET /` then re-request it with `If-None-Match` → 200 whose body contains `styles.css?v=<asset_version()>` and whose headers have no `etag` / `last-modified`; the same pair for `/index.html`, `/sources.html`, `/tuning.html`, `/git-sources.html`, `/history.html`, `/login.html`, `/document.html`, `/shared.html`, and for a dynamic `/shared/<token>`; `GET /assets/styles.css?v=<token>` still carries `public, max-age=31536000, immutable` + `etag` + `last-modified` and still answers a conditional `GET` with 304; `POST /api/chat` keeps its SSE `text/event-stream` headers untouched.
|
||||
- Coverage: **>90%** on `app/` (the validate.sh gate) — every new branch in `caching.py` (strip, bodiless-status guard, outbound validator drop) must be covered.
|
||||
- E2E (mandatory, A16): `tests/e2e/test_asset_cache_revalidation.py`, run in isolation (DB up).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] Conditional `GET /` (and of every other known page, static and dynamic) returns 200 with the current `?v=<token>` references — no 304 on a page path, so the browser can never reuse HTML that points at a previous commit's assets.
|
||||
- [ ] Page responses publish no `etag` / `last-modified` and keep `Cache-Control: no-cache`.
|
||||
- [ ] `/assets/*` unchanged: `public, max-age=31536000, immutable`, validators intact, conditional `GET` still 304s.
|
||||
- [ ] `/api/*` unchanged byte-for-byte, including the SSE `POST /api/chat` stream and the signed-cookie admin paths.
|
||||
- [ ] A bodiless downstream 304/204 on a page path is passed through bodiless (with `no-cache`, no validators) instead of being rewritten into a bodiless status with a body.
|
||||
- [ ] `uv run pytest` green; coverage TOTAL >90%.
|
||||
- [ ] `uv run pytest tests/e2e/test_asset_cache_revalidation.py -v --no-cov` green in isolation (DB up).
|
||||
- [ ] Regression E2E suites green in isolation: `test_cache_busting.py`, `test_smoke.py`, `test_shared_header.py`, `test_share_chat.py`, `test_chat_history.py`.
|
||||
- [ ] `uv run ruff check . && uv run pyright` clean.
|
||||
- [ ] `README.md` documents the rule (why pages never 304, why assets still may) and `git rev-parse --short HEAD` + hard-reload behaviour is recorded.
|
||||
- [ ] One `--no-gpg-sign` commit; phase dir moved to `.agents/phases/complete/`.
|
||||
|
||||
## Locked decisions
|
||||
- **A2/A11 honoured** — the fix stays inside the existing Starlette middleware; no new dependency, no CDN, no new service (A12 unchanged: still no Valkey/CDN to hold asset versions).
|
||||
- **Recorded assumptions (owner asked for the phase in chat, no further interview):**
|
||||
1. **Fix the request, not the response.** The 304 is generated downstream by `FileResponse`/`StaticFiles`, before the middleware sees it, so the middleware removes `if-none-match` / `if-modified-since` from the *inbound* request for known page paths (via starlette's `MutableHeaders(scope=request.scope)`) so a full body always arrives to be rewritten. Post-hoc header rewriting cannot fix a body that was never sent.
|
||||
2. **Pages publish no validators.** `etag` / `last-modified` are dropped from page responses; combined with `Cache-Control: no-cache` this means every navigation re-fetches the ~10–18 KB page with the current token. Alternative considered and **rejected**: publish a weak `ETag` over the rewritten body so same-process revalidations still 304 — it only saves bandwidth on a LAN, and it is only correct if every layer (route, static mount, any proxy) computes the validator over the rewritten bytes, which cannot be guaranteed here. Correctness ("never stale") beats a 304.
|
||||
3. **`/assets/*` is left completely alone** — header-only caching as today; a conditional `GET` on a versioned asset URL may still 304 because the URL already encodes the version. The inbound strip is scoped to `HTML_PAGES` + `/shared/` and must not widen.
|
||||
4. **`/api/*` stays byte-identical** (A10/A15): no header changes, the SSE body is never drained, and no conditional-header stripping.
|
||||
5. **The token algorithm is not touched.** `asset_version()` keeps its per-process `functools.cache` semantics (the token changes on the next process start). Making it per-request was rejected: a per-request `git rev-parse` reopens the boot-hang cap `_GIT_TIMEOUT_S` on the hot path and would make one page load able to reference two versions.
|
||||
6. A bodiless downstream status (204/304) on a page path is **never** rewritten — starlette forbids a body on those statuses, and today the rewrite path would build exactly that. It is passed through with `no-cache` and no validators.
|
||||
- **No Regressions** — phase 33's observable contracts (`?v=` on every asset ref, `no-cache` on pages, `immutable` on assets, `/api/*` untouched) and phases 50/51/52's pages keep working; the only behavior change is that a page revalidation yields 200 instead of 304.
|
||||
- **A16/A17 honoured** — one story E2E suite, one atomic commit.
|
||||
|
||||
## Commit
|
||||
```bash
|
||||
git add -A .agents/ app/ README.md tests/ && git commit --no-gpg-sign -m "fix(web): never 304 a rewritten page — pages drop conditional validators, assets keep them"
|
||||
```
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
# Task 01 — Page paths ignore conditional headers
|
||||
|
||||
**Phase:** `54_asset_cache_bust_revalidation` · **Story:** n/a (bug fix discovered by measurement)
|
||||
|
||||
## Objective
|
||||
`CachingMiddleware` stops honouring `If-None-Match` / `If-Modified-Since` on known HTML page paths so the full (rewritable) body always arrives, passes through any downstream bodiless 304/204 safely, and never publishes validators on page responses.
|
||||
|
||||
## Work
|
||||
1. `app/core/caching.py` — inside `CachingMiddleware.dispatch`, **before** `call_next`:
|
||||
- Reuse the existing page-path predicate: extract `is_known_page = path in HTML_PAGES or path.startswith("/shared/")` into a module-level `_is_known_page(path: str) -> bool` so the inbound check and the existing outbound branch share one source of truth (no behavior drift between the two).
|
||||
- When `_is_known_page(path)` is true, strip the conditional request headers before the downstream app runs. Starlette's `Request.headers` is read-only, but the middleware owns the scope:
|
||||
```python
|
||||
from starlette.datastructures import MutableHeaders
|
||||
headers = MutableHeaders(scope=request.scope)
|
||||
for name in ("if-none-match", "if-modified-since"):
|
||||
if name in headers:
|
||||
del headers[name]
|
||||
```
|
||||
`MutableHeaders` mutates `request.scope["raw_headers"]` in place, so the downstream `FileResponse`/route never sees the validators and can only return the full 200 body. `if-none-match` / `if-modified-since` are the two headers `FileResponse`/`StaticFiles` can act on for these paths.
|
||||
- **Do not** touch the request for any other path — `/api/*`, `/assets/*`, unknown paths keep their conditional headers (asset 304s are safe; the SSE stream must not be disturbed).
|
||||
2. `app/core/caching.py` — in the page branch, **guard the bodiless statuses before `_read_body`**:
|
||||
```python
|
||||
if response.status_code in (204, 304):
|
||||
response.headers["Cache-Control"] = HTML_CACHE_CONTROL
|
||||
response.headers.pop("etag", None)
|
||||
response.headers.pop("last-modified", None)
|
||||
return response
|
||||
```
|
||||
Today the rewrite path would build `Response(content=new_body, status_code=304)` — a body on a bodiless status, which starlette rejects and which would otherwise be the stale-304 bug re-served from the middleware. After the inbound strip this branch is a belt-and-braces guard (a future route or proxy could still produce it).
|
||||
3. `app/core/caching.py` — extend `_no_cache_headers` (used by every rewritten/fallback page response) to also drop the upstream validators, so a page response can never be 304'd against later:
|
||||
```python
|
||||
for name in ("etag", "last-modified"):
|
||||
headers.pop(name, None)
|
||||
```
|
||||
Keep the existing `content-length` drop and the `Cache-Control: no-cache` override. The three fallback returns in `dispatch` (buffer failure, non-HTML page body, rewrite failure) all flow through `_no_cache_headers`, so they lose the validators too.
|
||||
4. `app/core/caching.py` — module + method docstrings: record WHY (one paragraph, mirroring the phase overview): validators describe the static file, not the rewritten body, so pages must not 304; the asymmetry — `/assets/*` 304s are safe because the URL is versioned, pages are not. Update the class docstring's two-bullet contract ("always revalidated") to state "revalidated with a full 200 body (no 304), publishing no validators".
|
||||
- Do NOT change: `asset_version()` (token algorithm + `functools.cache`), `rewrite_asset_refs`, `_ASSET_REF_RE`, `HTML_PAGES`, `ASSETS_PREFIX`, `ASSET_CACHE_CONTROL`, `HTML_CACHE_CONTROL`, `_GIT_TIMEOUT_S`, or the `/api/*` pass-through.
|
||||
|
||||
## Testing & Quality
|
||||
- The branches this task adds (`_is_known_page`, the inbound strip, the bodiless guard, the validator drops) are covered by Task 02's unit tests and Task 03's integration tests — those must exist before this phase is complete; this task's own gate is that the existing suite stays green.
|
||||
- Coverage: **>90%** on `app/` (validate.sh gate); the new branches are expected to be exercised by Task 02/03 tests.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/unit/test_caching.py -v` green (the pre-existing phase-33 pins still hold: `?v=` rewrite, `no-cache`, `/assets/*` headers, `/api/*` byte-identical).
|
||||
- [ ] `uv run ruff check . && uv run pyright` clean.
|
||||
- [ ] No change in `app/main.py` (the middleware is mounted there; the fix is fully inside `app/core/caching.py`).
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
# Task 02 — Unit: conditional page fetches + validator pins
|
||||
|
||||
**Phase:** `54_asset_cache_bust_revalidation` · **Story:** n/a
|
||||
|
||||
## Objective
|
||||
Extend `tests/unit/test_caching.py` (the house-style unit module for phase 33) so the 304 hole is reproduced as a failing test first, then pinned: known pages must 200 on conditional requests and publish no validators, while `/assets/*` and `/api/*` keep their existing conditional behavior.
|
||||
|
||||
## Work
|
||||
1. `tests/unit/test_caching.py` — new helper `_static_page_app(tmp_path)`:
|
||||
- build a mini static tree: `index.html` (containing `href="/assets/app.js"` and `src="/assets/app.js"` refs) + `assets/app.js` (`"x"`), `assets/app.css` ("y");
|
||||
- app: `app = FastAPI(); app.mount("/", StaticFiles(directory=<tmp frontend>, html=True), name="static")` + `app.get("/api/ping")` returning JSON + a `@app.get("/shared/{token}")` route that — like the phase-51 route — serves `text/html` **and honours a conditional header** (`if "if-none-match" in request.headers: return Response(status_code=304)`), so the inbound-strip behavior is observable at the route;
|
||||
- attach `CachingMiddleware` the same way `configure_caching` does;
|
||||
- `asset_version.cache_clear()` + monkeypatch the token to a deterministic value for this test only: monkeypatch `app.core.caching.get_settings` (the module-level import the middleware calls) to a settings stub whose `static_dir` is the tmp frontend — mirroring `test_default_static_dir_comes_from_settings`. Use token `"unit-token"` so assertions are exact.
|
||||
2. New tests (assertions against `TestClient(app)`):
|
||||
- `test_conditional_get_page_returns_200_with_rewrite` — `GET /` → 200, body contains `?v=unit-token`; note the response has NO `etag` (the fix drops it) — so derive the *file* validator the way a browser would have: `starlette.responses.FileResponse(<index.html>).headers["etag"]`; then `GET /` with `If-None-Match: <that etag>` → **200** (never 304), body still rewritten, headers: `cache-control == "no-cache"`, no `etag`, no `last-modified`. This test FAILS on the pre-fix code (it gets a 304) — it is the regression reproduction.
|
||||
- `test_conditional_get_page_with_if_modified_since_returns_200` — same pair using `If-Modified-Since` from `FileResponse(...).headers["last-modified"]` → 200 + rewrite.
|
||||
- `test_page_response_publishes_no_validators` — plain `GET /` → no `etag`, no `last-modified`, `cache-control == "no-cache"`.
|
||||
- `test_downstream_304_on_page_is_passed_bodiless` — a dedicated app whose `/` route returns `Response(status_code=304)` when `if-none-match` is present, else the full html; `GET /` with an `If-None-Match` header → the middleware must return a **304 with an empty body** (assert `resp.content == b""`), `cache-control == "no-cache"`, no `etag` — never a `Response(content=…, status_code=304)` (starlette raises on that; today's rewrite path would build it).
|
||||
- `test_shared_page_path_ignores_conditional_headers` — `GET /shared/abc123` with `If-None-Match: "whatever"` → 200 + rewrite (the conditional route above would have 304'd if the strip were missing).
|
||||
- `test_api_path_keeps_conditional_headers` — `/api/ping` route echoes 304 when `if-none-match` is present; `GET /api/ping` with `If-None-Match` → **304** (the strip must NOT widen to `/api/*`) and NO `cache-control` injected.
|
||||
- `test_assets_path_keeps_validators_and_304` — `GET /assets/app.css` → 200, `cache-control == ASSET_CACHE_CONTROL`, `etag` present; conditional `GET /assets/app.css` with that etag → **304** (versioned-URL 304s stay safe).
|
||||
3. Keep every existing test in the file green — phase 33's pins (token algorithm, `rewrite_asset_refs` idempotency, the buffer/rewrite failure fallbacks, `test_html_pages_include_history`, the shared-page contract) are unchanged by this phase.
|
||||
- The `MutableHeaders` import and the strip only exist in `app/core/caching.py` (task 01) — no new test helper modules.
|
||||
|
||||
## Testing & Quality
|
||||
- `uv run pytest tests/unit/test_caching.py -v` — every test above green; the first one fails on pre-fix code (run it against the pre-fix tree once to confirm it reproduces the defect before committing task 01's fix — the task files run in order, so task 01 lands first; this is a note for the executor, not an extra step).
|
||||
- Coverage: **>90%** on `app/` (validate.sh gate) — these tests must cover every branch task 01 added.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/unit/test_caching.py -v` green (all pre-existing + all new tests).
|
||||
- [ ] `uv run ruff check . && uv run pyright` clean.
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
# Task 03 — Integration: conditional GETs against the real app
|
||||
|
||||
**Phase:** `54_asset_cache_bust_revalidation` · **Story:** n/a
|
||||
|
||||
## Objective
|
||||
Prove the fix end-to-end against the REAL `app.main:app` (real `StaticFiles` mount on the real `frontend/` tree, real middleware, real `asset_version()` token): every known page — static and the dynamic share page — 200s on a conditional GET with the current `?v=<token>` body and no validators, while `/assets/*` still immutable-304s and `/api/*` stays untouched.
|
||||
|
||||
## Work
|
||||
1. `tests/integration/test_caching_revalidation.py` (new) — imports `client` (the real-app `TestClient`, `tests/conftest.py`) and `asset_version` from `app.core.caching`; a helper:
|
||||
```python
|
||||
def file_validators(page_file: Path) -> tuple[str, str]:
|
||||
"""The etag / last-modified Starlette would stamp on the underlying
|
||||
static file — exactly what a browser revalidates against."""
|
||||
from starlette.responses import FileResponse
|
||||
headers = FileResponse(page_file).headers
|
||||
return headers["etag"], headers["last-modified"]
|
||||
```
|
||||
and `FRONTEND = REPO / "frontend"` (`REPO = Path(__file__).resolve().parents[2]`, house pattern).
|
||||
2. Tests:
|
||||
- `test_every_known_page_200s_on_conditional_get` — `for path in app.core.caching.HTML_PAGES:` (all of `/`, `/index.html`, `/sources.html`, `/document.html`, `/login.html`, `/tuning.html`, `/git-sources.html`, `/history.html`, `/shared.html`): plain `GET` → 200, `cache-control == "no-cache"`, **no `etag`, no `last-modified`**, body contains `f"?v={asset_version()}"` (the current-process token the middleware used) and at least one `assets/` ref carrying it. Then `GET` the same path with `If-None-Match: <etag from file_validators(frontend/<file>)>` → **200**, same body, same headers. (On the pre-fix app this loop fails on the very first 304.)
|
||||
- `test_dynamic_shared_page_200s_on_conditional_get` — reuse the phase-51 save+share flow from `tests/integration/test_chats_api.py` (admin `client` fixture; `POST /api/chats` with `{messages, share: true}` → the row's `token`): `GET /shared/<token>` → 200, `?v=` refs, no validators; then the same GET with `If-None-Match` derived from `file_validators(FRONTEND / "shared.html")` → **200**, body still rewritten.
|
||||
- `test_assets_keep_immutable_validators_and_304` — `GET /assets/styles.css?v=<token>` → 200, `cache-control == "public, max-age=31536000, immutable"`, `etag` + `last-modified` present; `GET` the same URL with `If-None-Match: <that etag>` → **304** (the versioned-URL 304 stays — the inbound strip must not have widened to `/assets/*`).
|
||||
- `test_api_paths_get_no_cache_headers_and_untouched_stream` — `GET /api/health` → 200, no `cache-control` header injected, no `etag`; `GET /api/health` with `If-None-Match: "x"` → 200 (pass-through, the strip is page-scoped). The SSE stream itself is not re-tested here — the existing `tests/integration/test_chat_api.py` (phase 15/48) pins the byte-identical `/api/chat` pass-through and runs as a regression below.
|
||||
- `test_page_token_matches_process_token` — after `GET /`, the token embedded in `styles.css?v=…` equals `asset_version()` (guards the per-process `functools.cache` contract: one page load can never mix two versions — phase 54 assumption 5).
|
||||
3. Do not modify `tests/conftest.py`, `tests/integration/test_chats_api.py`, or `app/` — this task is tests only; if a pre-existing test breaks, the task is not done.
|
||||
- DB: these tests need the `client` fixture's app to boot, which requires the Postgres DB up (house pattern: `podman compose up -d db` before running, same prerequisite as the other integration files).
|
||||
|
||||
## Testing & Quality
|
||||
- `uv run pytest tests/integration/test_caching_revalidation.py -v` green with the DB up.
|
||||
- Regressions, in isolation: `uv run pytest tests/integration/test_chat_api.py -v` (SSE pass-through) and `uv run pytest tests/integration/test_chats_api.py -v` (the share flow the new test reuses).
|
||||
- Coverage: **>90%** on `app/` (validate.sh gate) — the real-app path now also exercises the middleware branches the unit tests stub.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/integration/test_caching_revalidation.py -v` green (DB up).
|
||||
- [ ] `test_chat_api.py` + `test_chats_api.py` green (regressions).
|
||||
- [ ] `uv run pytest` (full unit + integration) green; coverage TOTAL >90%.
|
||||
- [ ] `uv run ruff check . && uv run pyright` clean.
|
||||
@@ -0,0 +1,30 @@
|
||||
# Task 04 — E2E: the browser can never be 304'd onto stale HTML + docs + commit
|
||||
|
||||
**Phase:** `54_asset_cache_bust_revalidation` · **Story:** n/a
|
||||
|
||||
## Objective
|
||||
One isolated Playwright story suite proving, through a real browser, that the document the browser receives is always the current one (200, `?v=<current token>`, no validators), that the CSS that actually renders is the current tree's, and that assets keep their immutable+validator behavior — then the regressions, the README section, and the single commit.
|
||||
|
||||
## Work
|
||||
1. `tests/e2e/test_asset_cache_revalidation.py` (new) — isolated run, `mock_llm` + `db_ready` prerequisites per house pattern; no KB seeding needed (the suite only loads pages, it asks no questions). The E2E app is a uvicorn subprocess of the SAME checkout (`tests/e2e/conftest.py` → `app_server`), so the expected token is deterministic: `subprocess.run(["git", "-C", str(REPO), "rev-parse", "--short", "HEAD"]).stdout.strip()`.
|
||||
- **`test_document_is_200_current_token_no_validators`** — `with page.expect_response("document") as info:` then `page.goto(app_url)`; `resp = info.value`: `resp.status == 200`; `resp.headers` has **no `etag`, no `last-modified`**; `resp.headers["cache-control"] == "no-cache"`; `resp.text()` contains `f"styles.css?v={token}"` and `f"app.js?v={token}"` (the chat page's two local refs). 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.
|
||||
- **`test_second_navigation_is_still_200_not_304`** — `page.goto(app_url)`, then `with page.expect_response("document") as info: page.goto(app_url)` again (the browser's own cache now holds the first document): second `resp.status == 200` and the body still carries the current token. This is the exact reproduction shape of the reported symptom (a plain re-navigation kept the previous commit's `?v=`).
|
||||
- **`test_browser_renders_current_css_not_stale`** — after `page.goto(app_url)`: `page.evaluate("() => getComputedStyle(document.querySelector('#messages')).flexGrow")` returns `"1"` — the phase-52 rule 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 (the end-to-end consequence of the 304 hole).
|
||||
- **`test_other_pages_carry_the_contract`** — same document assertions (`200`, no validators, `no-cache`, `?v=` token) for `app_url + "/sources.html"`.
|
||||
- **`test_assets_still_immutable_with_validators`** — `with page.expect_response("**/assets/styles.css**") as info: page.goto(app_url)`; `resp.status == 200`; `resp.headers["cache-control"] == "public, max-age=31536000, immutable"`; `resp.headers` **has** `etag` (the asset keeps its validators — only pages dropped them).
|
||||
2. `README.md` — extend the deployment/caching documentation with a short **Cache busting** subsection (create it if the README has no caching section; place it near the deployment docs): the `?v=<git short SHA>` token flips on the next process start; `/assets/*` is cached `immutable` for a year under the versioned URL (a conditional `GET` may 304 — the URL already encodes the version); HTML pages are served `no-cache` and **never 304** — because the page body is rewritten per process, upstream validators would describe the file, not the bytes served (phase 54); during local development a `git` commit changes the token on server restart — if a browser still shows an old layout, hard-reload once (the fix guarantees the NEXT navigation is current, it cannot un-pin what a pre-54 deploy already 304'd).
|
||||
3. Regressions, each in isolation (`uv run pytest tests/e2e/<file> -v --no-cov`, DB up): `test_cache_busting.py` (the phase-33 story suite — the contract it pins must survive intact), `test_smoke.py`, `test_shared_header.py`, `test_share_chat.py` (the dynamic `/shared/<token>` page), `test_chat_history.py` (`/history.html`).
|
||||
4. One `--no-gpg-sign` commit staging `.agents/ app/ README.md tests/` (message per the phase overview); move `.agents/phases/todo/54_asset_cache_bust_revalidation/` to `.agents/phases/complete/`.
|
||||
- No `app/` change in this task; if one proves necessary, stop and flag it — the fix must already be complete from task 01.
|
||||
|
||||
## Testing & Quality
|
||||
- E2E (mandatory, A16): `uv run pytest tests/e2e/test_asset_cache_revalidation.py -v --no-cov` green in isolation (DB up).
|
||||
- The five regression suites above green in isolation.
|
||||
- Final gate before the move: `bash .agents/validate.sh` (unit + integration, coverage >90%, ruff, pyright).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/e2e/test_asset_cache_revalidation.py -v --no-cov` green in isolation (DB up).
|
||||
- [ ] `test_cache_busting.py`, `test_smoke.py`, `test_shared_header.py`, `test_share_chat.py`, `test_chat_history.py` green in isolation.
|
||||
- [ ] `uv run pytest` green; coverage TOTAL >90%; `uv run ruff check . && uv run pyright` clean.
|
||||
- [ ] README documents the no-304-pages / immutable-assets rule and the restart/hard-reload behavior.
|
||||
- [ ] One `--no-gpg-sign` commit; phase dir moved to `.agents/phases/complete/`.
|
||||
Reference in New Issue
Block a user