From 36eddce672e3da3fba9d6eef9ec7cc23a1879fc9 Mon Sep 17 00:00:00 2001 From: ducoterra Date: Sun, 11 Oct 2026 15:33:39 -0400 Subject: [PATCH] phase: 177_themed_favicon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Everything is verified. Final report: ## Phase 177 — Themed favicon: completion pass (all tasks pre-done, verified end-to-end) **Verified (no code fixes needed — implementation matched the design D1–D4 exactly):** - `theming.favicon_svg()` pure renderer + golden byte-identical pin; `GET/HEAD /favicon.svg` route (no-cache, `image/svg+xml`, built-in 200 fallback on DB blip, routes-first before static mount); 5 templates → bare `href="/favicon.svg"`, static file deleted (Containerfile cp removed); Theme-tab `data:` live preview with 4 restore points; JS↔server mirror pins. **Tests / lint / coverage (all green):** - `uv run pytest --cov=app --cov-report=term-missing` → 4161 passed, `app/` 99% (>90% gate) - `uv run ruff check .` → clean; `uv run pyright` → 0 errors - `uv run pytest tests/e2e/test_themed_favicon.py -v --no-cov` (isolated) → 6 passed - `test_theme_save_and_coverage.py` (92) + `test_theme_semantic_completion.py` (93), isolated → 3 + 8 passed **Completion criteria:** all met — default golden bytes/headers; themed roles + `#bor-theme` non-regression; DB-failure 200 fallback; 5 bare links + `/assets/favicon.svg` 404 + browser rendering; live-preview data: repaint + Save/Reset/re-show restore; gates green; E2E suites green in isolation. Commit: harness commits atomically (overrides AGENTS rule 8 — no git commands run). **Documentation:** README.md verified consistent in full (the phase's favicon paragraph is accurate; all commands, env vars, routes, ports, scripts cross-checked) — no changes needed. AGENTS.md verified consistent — no changes needed. **Notable:** `image-1.png` (owner's TODO screenshot, referenced by phase 176's overview) sits untracked at the repo root — pre-existing, explicitly out of scope per the phase-176 report; left untouched. **Next pending phase:** none — 177 was the last; TODO.md is cleared. --- .../177_themed_favicon/00_phase.md | 0 .../01_favicon_svg_renderer.md | 0 .../177_themed_favicon/02_favicon_route.md | 0 .../03_template_link_swap.md | 0 .../04_theme_tab_live_preview.md | 0 .../177_themed_favicon__00_phase.a1.err | 0 .../177_themed_favicon__00_phase.a1.md | 20 + .../177_themed_favicon__00_phase.a1.md.status | 1 + .../177_themed_favicon__00_phase.a1.validate | 136 ++++ ...ed_favicon__01_favicon_svg_renderer.a1.err | 0 ...med_favicon__01_favicon_svg_renderer.a1.md | 26 + ...icon__01_favicon_svg_renderer.a1.md.status | 1 + ...vicon__01_favicon_svg_renderer.a1.validate | 136 ++++ ...77_themed_favicon__02_favicon_route.a1.err | 0 ...177_themed_favicon__02_favicon_route.a1.md | 18 + ...med_favicon__02_favicon_route.a1.md.status | 1 + ...emed_favicon__02_favicon_route.a1.validate | 136 ++++ ...emed_favicon__03_template_link_swap.a1.err | 0 ...hemed_favicon__03_template_link_swap.a1.md | 13 + ...avicon__03_template_link_swap.a1.md.status | 1 + ...favicon__03_template_link_swap.a1.validate | 136 ++++ ..._favicon__04_theme_tab_live_preview.a1.err | 0 ...d_favicon__04_theme_tab_live_preview.a1.md | 18 + ...on__04_theme_tab_live_preview.a1.md.status | 1 + ...con__04_theme_tab_live_preview.a1.validate | 136 ++++ Containerfile | 1 - README.md | 10 +- app/core/theming.py | 49 ++ app/main.py | 66 +- frontend/assets/favicon.svg | 7 - frontend/assets/theme.js | 106 +++ frontend/doc-edit.html | 2 +- frontend/document.html | 2 +- frontend/index.html | 2 +- frontend/login.html | 2 +- frontend/shared.html | 2 +- tests/e2e/test_themed_favicon.py | 701 ++++++++++++++++++ tests/integration/test_favicon_route.py | 335 +++++++++ tests/unit/test_theme_frontend.py | 189 ++++- tests/unit/test_theming.py | 113 +++ 40 files changed, 2351 insertions(+), 16 deletions(-) rename .agents/phases/{todo => complete}/177_themed_favicon/00_phase.md (100%) rename .agents/phases/{todo => complete}/177_themed_favicon/01_favicon_svg_renderer.md (100%) rename .agents/phases/{todo => complete}/177_themed_favicon/02_favicon_route.md (100%) rename .agents/phases/{todo => complete}/177_themed_favicon/03_template_link_swap.md (100%) rename .agents/phases/{todo => complete}/177_themed_favicon/04_theme_tab_live_preview.md (100%) create mode 100644 .agents/reports/177_themed_favicon/177_themed_favicon__00_phase.a1.err create mode 100644 .agents/reports/177_themed_favicon/177_themed_favicon__00_phase.a1.md create mode 100644 .agents/reports/177_themed_favicon/177_themed_favicon__00_phase.a1.md.status create mode 100644 .agents/reports/177_themed_favicon/177_themed_favicon__00_phase.a1.validate create mode 100644 .agents/reports/177_themed_favicon/177_themed_favicon__01_favicon_svg_renderer.a1.err create mode 100644 .agents/reports/177_themed_favicon/177_themed_favicon__01_favicon_svg_renderer.a1.md create mode 100644 .agents/reports/177_themed_favicon/177_themed_favicon__01_favicon_svg_renderer.a1.md.status create mode 100644 .agents/reports/177_themed_favicon/177_themed_favicon__01_favicon_svg_renderer.a1.validate create mode 100644 .agents/reports/177_themed_favicon/177_themed_favicon__02_favicon_route.a1.err create mode 100644 .agents/reports/177_themed_favicon/177_themed_favicon__02_favicon_route.a1.md create mode 100644 .agents/reports/177_themed_favicon/177_themed_favicon__02_favicon_route.a1.md.status create mode 100644 .agents/reports/177_themed_favicon/177_themed_favicon__02_favicon_route.a1.validate create mode 100644 .agents/reports/177_themed_favicon/177_themed_favicon__03_template_link_swap.a1.err create mode 100644 .agents/reports/177_themed_favicon/177_themed_favicon__03_template_link_swap.a1.md create mode 100644 .agents/reports/177_themed_favicon/177_themed_favicon__03_template_link_swap.a1.md.status create mode 100644 .agents/reports/177_themed_favicon/177_themed_favicon__03_template_link_swap.a1.validate create mode 100644 .agents/reports/177_themed_favicon/177_themed_favicon__04_theme_tab_live_preview.a1.err create mode 100644 .agents/reports/177_themed_favicon/177_themed_favicon__04_theme_tab_live_preview.a1.md create mode 100644 .agents/reports/177_themed_favicon/177_themed_favicon__04_theme_tab_live_preview.a1.md.status create mode 100644 .agents/reports/177_themed_favicon/177_themed_favicon__04_theme_tab_live_preview.a1.validate delete mode 100644 frontend/assets/favicon.svg create mode 100644 tests/e2e/test_themed_favicon.py create mode 100644 tests/integration/test_favicon_route.py diff --git a/.agents/phases/todo/177_themed_favicon/00_phase.md b/.agents/phases/complete/177_themed_favicon/00_phase.md similarity index 100% rename from .agents/phases/todo/177_themed_favicon/00_phase.md rename to .agents/phases/complete/177_themed_favicon/00_phase.md diff --git a/.agents/phases/todo/177_themed_favicon/01_favicon_svg_renderer.md b/.agents/phases/complete/177_themed_favicon/01_favicon_svg_renderer.md similarity index 100% rename from .agents/phases/todo/177_themed_favicon/01_favicon_svg_renderer.md rename to .agents/phases/complete/177_themed_favicon/01_favicon_svg_renderer.md diff --git a/.agents/phases/todo/177_themed_favicon/02_favicon_route.md b/.agents/phases/complete/177_themed_favicon/02_favicon_route.md similarity index 100% rename from .agents/phases/todo/177_themed_favicon/02_favicon_route.md rename to .agents/phases/complete/177_themed_favicon/02_favicon_route.md diff --git a/.agents/phases/todo/177_themed_favicon/03_template_link_swap.md b/.agents/phases/complete/177_themed_favicon/03_template_link_swap.md similarity index 100% rename from .agents/phases/todo/177_themed_favicon/03_template_link_swap.md rename to .agents/phases/complete/177_themed_favicon/03_template_link_swap.md diff --git a/.agents/phases/todo/177_themed_favicon/04_theme_tab_live_preview.md b/.agents/phases/complete/177_themed_favicon/04_theme_tab_live_preview.md similarity index 100% rename from .agents/phases/todo/177_themed_favicon/04_theme_tab_live_preview.md rename to .agents/phases/complete/177_themed_favicon/04_theme_tab_live_preview.md diff --git a/.agents/reports/177_themed_favicon/177_themed_favicon__00_phase.a1.err b/.agents/reports/177_themed_favicon/177_themed_favicon__00_phase.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/177_themed_favicon/177_themed_favicon__00_phase.a1.md b/.agents/reports/177_themed_favicon/177_themed_favicon__00_phase.a1.md new file mode 100644 index 0000000..00e578c --- /dev/null +++ b/.agents/reports/177_themed_favicon/177_themed_favicon__00_phase.a1.md @@ -0,0 +1,20 @@ +Everything is verified. Final report: + +## Phase 177 — Themed favicon: completion pass (all tasks pre-done, verified end-to-end) + +**Verified (no code fixes needed — implementation matched the design D1–D4 exactly):** +- `theming.favicon_svg()` pure renderer + golden byte-identical pin; `GET/HEAD /favicon.svg` route (no-cache, `image/svg+xml`, built-in 200 fallback on DB blip, routes-first before static mount); 5 templates → bare `href="/favicon.svg"`, static file deleted (Containerfile cp removed); Theme-tab `data:` live preview with 4 restore points; JS↔server mirror pins. + +**Tests / lint / coverage (all green):** +- `uv run pytest --cov=app --cov-report=term-missing` → 4161 passed, `app/` 99% (>90% gate) +- `uv run ruff check .` → clean; `uv run pyright` → 0 errors +- `uv run pytest tests/e2e/test_themed_favicon.py -v --no-cov` (isolated) → 6 passed +- `test_theme_save_and_coverage.py` (92) + `test_theme_semantic_completion.py` (93), isolated → 3 + 8 passed + +**Completion criteria:** all met — default golden bytes/headers; themed roles + `#bor-theme` non-regression; DB-failure 200 fallback; 5 bare links + `/assets/favicon.svg` 404 + browser rendering; live-preview data: repaint + Save/Reset/re-show restore; gates green; E2E suites green in isolation. Commit: harness commits atomically (overrides AGENTS rule 8 — no git commands run). + +**Documentation:** README.md verified consistent in full (the phase's favicon paragraph is accurate; all commands, env vars, routes, ports, scripts cross-checked) — no changes needed. AGENTS.md verified consistent — no changes needed. + +**Notable:** `image-1.png` (owner's TODO screenshot, referenced by phase 176's overview) sits untracked at the repo root — pre-existing, explicitly out of scope per the phase-176 report; left untouched. + +**Next pending phase:** none — 177 was the last; TODO.md is cleared. diff --git a/.agents/reports/177_themed_favicon/177_themed_favicon__00_phase.a1.md.status b/.agents/reports/177_themed_favicon/177_themed_favicon__00_phase.a1.md.status new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/.agents/reports/177_themed_favicon/177_themed_favicon__00_phase.a1.md.status @@ -0,0 +1 @@ +ok diff --git a/.agents/reports/177_themed_favicon/177_themed_favicon__00_phase.a1.validate b/.agents/reports/177_themed_favicon/177_themed_favicon__00_phase.a1.validate new file mode 100644 index 0000000..5ceed9f --- /dev/null +++ b/.agents/reports/177_themed_favicon/177_themed_favicon__00_phase.a1.validate @@ -0,0 +1,136 @@ +........................................................................ [ 1%] +........................................................................ [ 3%] +........................................................................ [ 5%] +........................................................................ [ 6%] +........................................................................ [ 8%] +........................................................................ [ 10%] +........................................................................ [ 12%] +........................................................................ [ 13%] +........................................................................ [ 15%] +........................................................................ [ 17%] +........................................................................ [ 19%] +........................................................................ [ 20%] +........................................................................ [ 22%] +........................................................................ [ 24%] +........................................................................ [ 25%] +........................................................................ [ 27%] +........................................................................ [ 29%] +........................................................................ [ 31%] +........................................................................ [ 32%] +........................................................................ [ 34%] +........................................................................ [ 36%] +........................................................................ [ 38%] +........................................................................ [ 39%] +........................................................................ [ 41%] +........................................................................ [ 43%] +........................................................................ [ 44%] +........................................................................ [ 46%] +........................................................................ [ 48%] +........................................................................ [ 50%] +........................................................................ [ 51%] +........................................................................ [ 53%] +........................................................................ [ 55%] +........................................................................ [ 57%] +........................................................................ [ 58%] +........................................................................ [ 60%] +........................................................................ [ 62%] +........................................................................ [ 64%] +........................................................................ [ 65%] +........................................................................ [ 67%] +........................................................................ [ 69%] +........................................................................ [ 70%] +........................................................................ [ 72%] +........................................................................ [ 74%] +........................................................................ [ 76%] +........................................................................ [ 77%] +........................................................................ [ 79%] +........................................................................ [ 81%] +........................................................................ [ 83%] +........................................................................ [ 84%] +........................................................................ [ 86%] +........................................................................ [ 88%] +........................................................................ [ 89%] +........................................................................ [ 91%] +........................................................................ [ 93%] +........................................................................ [ 95%] +........................................................................ [ 96%] +........................................................................ [ 98%] +......................................................... [100%] +=============================== warnings summary =============================== +.venv/lib/python3.13/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.13.13-final-0 _______________ + +Name Stmts Miss Cover +-------------------------------------------------- +app/__init__.py 1 0 100% +app/api/__init__.py 0 0 100% +app/api/admin_settings.py 114 0 100% +app/api/auth.py 52 0 100% +app/api/chat.py 280 1 99% +app/api/chat_images.py 50 0 100% +app/api/chats.py 111 0 100% +app/api/config.py 13 0 100% +app/api/doc_drafts.py 99 0 100% +app/api/docs.py 234 1 99% +app/api/git_sources.py 259 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 33 0 100% +app/api/sync.py 204 0 100% +app/api/tokens.py 40 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 288 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 45 0 100% +app/core/caching.py 124 0 100% +app/core/compression.py 59 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 43 0 100% +app/core/errors.py 5 0 100% +app/core/http_cache.py 22 0 100% +app/core/logging.py 13 0 100% +app/core/rate_limit.py 44 0 100% +app/core/response_budget.py 63 0 100% +app/core/security_headers.py 20 0 100% +app/core/settings_catalog.py 18 0 100% +app/core/settings_migrate.py 32 0 100% +app/core/settings_overlay.py 111 0 100% +app/core/theming.py 40 0 100% +app/core/tokens.py 44 0 100% +app/db.py 22 0 100% +app/main.py 105 0 100% +app/models.py 141 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 454 1 99% +app/rag/archive_upload.py 134 0 100% +app/rag/chunker.py 220 4 98% +app/rag/doc_dates.py 18 0 100% +app/rag/folder_summaries.py 134 0 100% +app/rag/git_sources.py 77 0 100% +app/rag/importer.py 841 4 99% +app/rag/llm.py 253 1 99% +app/rag/md_images.py 63 0 100% +app/rag/overview.py 78 0 100% +app/rag/pdf.py 192 0 100% +app/rag/prompts.py 156 0 100% +app/rag/retriever.py 252 3 99% +app/rag/scaffolding.py 55 0 100% +app/rag/source_removal.py 41 0 100% +app/rag/sources_meta.py 16 0 100% +app/rag/suggestions.py 27 0 100% +app/rag/summarizer.py 58 0 100% +app/schemas.py 533 0 100% +-------------------------------------------------- +TOTAL 6467 17 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414). +Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest` + +validation OK diff --git a/.agents/reports/177_themed_favicon/177_themed_favicon__01_favicon_svg_renderer.a1.err b/.agents/reports/177_themed_favicon/177_themed_favicon__01_favicon_svg_renderer.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/177_themed_favicon/177_themed_favicon__01_favicon_svg_renderer.a1.md b/.agents/reports/177_themed_favicon/177_themed_favicon__01_favicon_svg_renderer.a1.md new file mode 100644 index 0000000..b8a53f3 --- /dev/null +++ b/.agents/reports/177_themed_favicon/177_themed_favicon__01_favicon_svg_renderer.a1.md @@ -0,0 +1,26 @@ +Everything is complete and verified. Final status against the task's completion criteria: + +- ✅ `tests/unit/test_theming.py` green (25 tests: 22 existing + 3 new pins) +- ✅ One-time golden-vs-file sanity check: PASS (logged — 341 bytes, byte-identical) +- ✅ Full suite green (4141 passed), `app/` coverage 99% (>90%), `theming.py` 100% +- ✅ ruff + pyright clean +- ✅ No behavior change in completed work (new function, zero existing call sites) + +## Report + +**Implemented (task 01, A1):** +- `app/core/theming.py`: `favicon_svg(colors) -> str` — pure palette→artwork renderer, same art as the static favicon (hexagon + dot + 4 spokes, `viewBox 0 0 64 64`, all geometry/widths/caps unchanged), only the three A1 color roles swapped (hexagon fill→`surface`, hexagon stroke + dot→`brand`, spokes→`brand_ink`); docstring carries the role table, byte-identical contract, and the "no state colors" note; placed next to `theme_style_tag()` +- `tests/unit/test_theming.py`: three new pins — golden byte-identical (`FAVICON_GOLDEN` = the static file's 341-byte `` element, no comment header), themed three-roles (asserted via `ElementTree` attribute parsing + none of the other 14 built-in hexes present), geometry invariance (mask-the-4-color-attrs comparison, exactly 4 masked) +- Docstring module header extended with the Phase 177 entry + +**Verification (exact commands):** +- `uv run pytest tests/unit/test_theming.py -v` → 25 passed +- One-time sanity (logged): `FAVICON_GOLDEN == file element == favicon_svg(BUILTIN_COLORS)` → True (341 bytes) +- `uv run pytest --cov=app --cov-report=term-missing` → 4141 passed, TOTAL 99% (`app/core/theming.py` 100%) +- `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors + +**Docs:** no doc impact — the task adds a pure function with no user-visible surface (no route/env/commands yet); README/AGENTS.md carry no favicon references that go stale (the static file stays until task 03). + +**Decisions:** none — followed D1 exactly; the golden constant is the durable contract per the phase (file deleted in task 03). + +**Next pending task:** `02_favicon_route.md` — `GET /favicon.svg` (+ HEAD) in `create_app()` (A2/A3). diff --git a/.agents/reports/177_themed_favicon/177_themed_favicon__01_favicon_svg_renderer.a1.md.status b/.agents/reports/177_themed_favicon/177_themed_favicon__01_favicon_svg_renderer.a1.md.status new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/.agents/reports/177_themed_favicon/177_themed_favicon__01_favicon_svg_renderer.a1.md.status @@ -0,0 +1 @@ +ok diff --git a/.agents/reports/177_themed_favicon/177_themed_favicon__01_favicon_svg_renderer.a1.validate b/.agents/reports/177_themed_favicon/177_themed_favicon__01_favicon_svg_renderer.a1.validate new file mode 100644 index 0000000..90ffdc6 --- /dev/null +++ b/.agents/reports/177_themed_favicon/177_themed_favicon__01_favicon_svg_renderer.a1.validate @@ -0,0 +1,136 @@ +........................................................................ [ 1%] +........................................................................ [ 3%] +........................................................................ [ 5%] +........................................................................ [ 6%] +........................................................................ [ 8%] +........................................................................ [ 10%] +........................................................................ [ 12%] +........................................................................ [ 13%] +........................................................................ [ 15%] +........................................................................ [ 17%] +........................................................................ [ 19%] +........................................................................ [ 20%] +........................................................................ [ 22%] +........................................................................ [ 24%] +........................................................................ [ 26%] +........................................................................ [ 27%] +........................................................................ [ 29%] +........................................................................ [ 31%] +........................................................................ [ 33%] +........................................................................ [ 34%] +........................................................................ [ 36%] +........................................................................ [ 38%] +........................................................................ [ 39%] +........................................................................ [ 41%] +........................................................................ [ 43%] +........................................................................ [ 45%] +........................................................................ [ 46%] +........................................................................ [ 48%] +........................................................................ [ 50%] +........................................................................ [ 52%] +........................................................................ [ 53%] +........................................................................ [ 55%] +........................................................................ [ 57%] +........................................................................ [ 59%] +........................................................................ [ 60%] +........................................................................ [ 62%] +........................................................................ [ 64%] +........................................................................ [ 66%] +........................................................................ [ 67%] +........................................................................ [ 69%] +........................................................................ [ 71%] +........................................................................ [ 73%] +........................................................................ [ 74%] +........................................................................ [ 76%] +........................................................................ [ 78%] +........................................................................ [ 79%] +........................................................................ [ 81%] +........................................................................ [ 83%] +........................................................................ [ 85%] +........................................................................ [ 86%] +........................................................................ [ 88%] +........................................................................ [ 90%] +........................................................................ [ 92%] +........................................................................ [ 93%] +........................................................................ [ 95%] +........................................................................ [ 97%] +........................................................................ [ 99%] +..................................... [100%] +=============================== warnings summary =============================== +.venv/lib/python3.13/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.13.13-final-0 _______________ + +Name Stmts Miss Cover +-------------------------------------------------- +app/__init__.py 1 0 100% +app/api/__init__.py 0 0 100% +app/api/admin_settings.py 114 0 100% +app/api/auth.py 52 0 100% +app/api/chat.py 280 1 99% +app/api/chat_images.py 50 0 100% +app/api/chats.py 111 0 100% +app/api/config.py 13 0 100% +app/api/doc_drafts.py 99 0 100% +app/api/docs.py 234 1 99% +app/api/git_sources.py 259 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 33 0 100% +app/api/sync.py 204 0 100% +app/api/tokens.py 40 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 288 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 45 0 100% +app/core/caching.py 124 0 100% +app/core/compression.py 59 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 43 0 100% +app/core/errors.py 5 0 100% +app/core/http_cache.py 22 0 100% +app/core/logging.py 13 0 100% +app/core/rate_limit.py 44 0 100% +app/core/response_budget.py 63 0 100% +app/core/security_headers.py 20 0 100% +app/core/settings_catalog.py 18 0 100% +app/core/settings_migrate.py 32 0 100% +app/core/settings_overlay.py 111 0 100% +app/core/theming.py 40 0 100% +app/core/tokens.py 44 0 100% +app/db.py 22 0 100% +app/main.py 91 0 100% +app/models.py 141 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 454 1 99% +app/rag/archive_upload.py 134 0 100% +app/rag/chunker.py 220 4 98% +app/rag/doc_dates.py 18 0 100% +app/rag/folder_summaries.py 134 0 100% +app/rag/git_sources.py 77 0 100% +app/rag/importer.py 841 4 99% +app/rag/llm.py 253 1 99% +app/rag/md_images.py 63 0 100% +app/rag/overview.py 78 0 100% +app/rag/pdf.py 192 0 100% +app/rag/prompts.py 156 0 100% +app/rag/retriever.py 252 3 99% +app/rag/scaffolding.py 55 0 100% +app/rag/source_removal.py 41 0 100% +app/rag/sources_meta.py 16 0 100% +app/rag/suggestions.py 27 0 100% +app/rag/summarizer.py 58 0 100% +app/schemas.py 533 0 100% +-------------------------------------------------- +TOTAL 6453 17 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414). +Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest` + +validation OK diff --git a/.agents/reports/177_themed_favicon/177_themed_favicon__02_favicon_route.a1.err b/.agents/reports/177_themed_favicon/177_themed_favicon__02_favicon_route.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/177_themed_favicon/177_themed_favicon__02_favicon_route.a1.md b/.agents/reports/177_themed_favicon/177_themed_favicon__02_favicon_route.a1.md new file mode 100644 index 0000000..6d015f9 --- /dev/null +++ b/.agents/reports/177_themed_favicon/177_themed_favicon__02_favicon_route.a1.md @@ -0,0 +1,18 @@ +Everything is green. Final verification of the task's completion criteria is done — all four states plus the routes-first pin are covered by the 7 integration tests, the full suite passes, and the docs are updated. + +## Report + +**Implemented** +- `app/main.py`: `_favicon()` sync handler + `app.api_route("/favicon.svg", methods=["GET","HEAD"], include_in_schema=False)` registered after the router includes, before the static catch-all mount (routes-first). Per request: short-lived `SessionLocal()` → `theming.effective_settings()` → `theming.favicon_svg()`; ANY exception → `logger.exception` + built-in fallback (200, never 500). Response: `image/svg+xml` + `Cache-Control: no-cache`. Commented rationale: outside `/api/*` (no byte-budget line, no auth — chrome like the login page), `CachingMiddleware` passes it untouched (not in `HTML_PAGES`, not under `/assets/` → no `?v=` rewrite), CSP covers task 04's `data:` preview. +- `tests/integration/test_favicon_route.py`: 7 tests — default state (200 + golden bytes via `FAVICON_GOLDEN` imported from `tests/unit/test_theming.py` + CSP header with the `img-src 'self' data:` carve-out), themed state (3 hexes in exact roles, parsed via ET — unit-pin idiom; none of the other 14 built-ins), no-process-cache (row update visible on the next request, old hexes gone), DB-failure (patched `effective_settings` raising → 200 + golden + logged fallback line), HEAD parity (same headers, empty body), routes-first (a `favicon.svg` with shadow bytes in the static dir cannot shadow the route), public/no-auth. + +**Gates (all green)** +- `uv run pytest tests/integration/test_favicon_route.py -v` → 7 passed +- `uv run pytest --cov=app --cov-report=term-missing` → 4148 passed, `app/` **99%** (>90% ✓), `app/main.py` 100% +- `uv run ruff check .` → clean; `uv run pyright` → 0 errors, 0 warnings + +**Docs**: README "Customizing the look" — one paragraph on `GET /favicon.svg` (effective palette per request, no restart; built-in art on DB blip). AGENTS.md: no changes needed (no new conventions/env/commands). + +**Decisions**: patched `theming.effective_settings` (module attribute the handler calls) for the DB-blip test — exercises the exact handler branch; shadow-test creates/deletes `frontend/favicon.svg` with `try/finally` cleanup. No live-infrastructure changes (repo-side only). `/assets/favicon.svg` still serves (task 03 deletes it). + +**Next pending task**: `03_template_link_swap.md` diff --git a/.agents/reports/177_themed_favicon/177_themed_favicon__02_favicon_route.a1.md.status b/.agents/reports/177_themed_favicon/177_themed_favicon__02_favicon_route.a1.md.status new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/.agents/reports/177_themed_favicon/177_themed_favicon__02_favicon_route.a1.md.status @@ -0,0 +1 @@ +ok diff --git a/.agents/reports/177_themed_favicon/177_themed_favicon__02_favicon_route.a1.validate b/.agents/reports/177_themed_favicon/177_themed_favicon__02_favicon_route.a1.validate new file mode 100644 index 0000000..e852caf --- /dev/null +++ b/.agents/reports/177_themed_favicon/177_themed_favicon__02_favicon_route.a1.validate @@ -0,0 +1,136 @@ +........................................................................ [ 1%] +........................................................................ [ 3%] +........................................................................ [ 5%] +........................................................................ [ 6%] +........................................................................ [ 8%] +........................................................................ [ 10%] +........................................................................ [ 12%] +........................................................................ [ 13%] +........................................................................ [ 15%] +........................................................................ [ 17%] +........................................................................ [ 19%] +........................................................................ [ 20%] +........................................................................ [ 22%] +........................................................................ [ 24%] +........................................................................ [ 26%] +........................................................................ [ 27%] +........................................................................ [ 29%] +........................................................................ [ 31%] +........................................................................ [ 32%] +........................................................................ [ 34%] +........................................................................ [ 36%] +........................................................................ [ 38%] +........................................................................ [ 39%] +........................................................................ [ 41%] +........................................................................ [ 43%] +........................................................................ [ 45%] +........................................................................ [ 46%] +........................................................................ [ 48%] +........................................................................ [ 50%] +........................................................................ [ 52%] +........................................................................ [ 53%] +........................................................................ [ 55%] +........................................................................ [ 57%] +........................................................................ [ 59%] +........................................................................ [ 60%] +........................................................................ [ 62%] +........................................................................ [ 64%] +........................................................................ [ 65%] +........................................................................ [ 67%] +........................................................................ [ 69%] +........................................................................ [ 71%] +........................................................................ [ 72%] +........................................................................ [ 74%] +........................................................................ [ 76%] +........................................................................ [ 78%] +........................................................................ [ 79%] +........................................................................ [ 81%] +........................................................................ [ 83%] +........................................................................ [ 85%] +........................................................................ [ 86%] +........................................................................ [ 88%] +........................................................................ [ 90%] +........................................................................ [ 91%] +........................................................................ [ 93%] +........................................................................ [ 95%] +........................................................................ [ 97%] +........................................................................ [ 98%] +............................................ [100%] +=============================== warnings summary =============================== +.venv/lib/python3.13/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.13.13-final-0 _______________ + +Name Stmts Miss Cover +-------------------------------------------------- +app/__init__.py 1 0 100% +app/api/__init__.py 0 0 100% +app/api/admin_settings.py 114 0 100% +app/api/auth.py 52 0 100% +app/api/chat.py 280 1 99% +app/api/chat_images.py 50 0 100% +app/api/chats.py 111 0 100% +app/api/config.py 13 0 100% +app/api/doc_drafts.py 99 0 100% +app/api/docs.py 234 1 99% +app/api/git_sources.py 259 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 33 0 100% +app/api/sync.py 204 0 100% +app/api/tokens.py 40 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 288 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 45 0 100% +app/core/caching.py 124 0 100% +app/core/compression.py 59 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 43 0 100% +app/core/errors.py 5 0 100% +app/core/http_cache.py 22 0 100% +app/core/logging.py 13 0 100% +app/core/rate_limit.py 44 0 100% +app/core/response_budget.py 63 0 100% +app/core/security_headers.py 20 0 100% +app/core/settings_catalog.py 18 0 100% +app/core/settings_migrate.py 32 0 100% +app/core/settings_overlay.py 111 0 100% +app/core/theming.py 40 0 100% +app/core/tokens.py 44 0 100% +app/db.py 22 0 100% +app/main.py 105 0 100% +app/models.py 141 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 454 1 99% +app/rag/archive_upload.py 134 0 100% +app/rag/chunker.py 220 4 98% +app/rag/doc_dates.py 18 0 100% +app/rag/folder_summaries.py 134 0 100% +app/rag/git_sources.py 77 0 100% +app/rag/importer.py 841 4 99% +app/rag/llm.py 253 1 99% +app/rag/md_images.py 63 0 100% +app/rag/overview.py 78 0 100% +app/rag/pdf.py 192 0 100% +app/rag/prompts.py 156 0 100% +app/rag/retriever.py 252 3 99% +app/rag/scaffolding.py 55 0 100% +app/rag/source_removal.py 41 0 100% +app/rag/sources_meta.py 16 0 100% +app/rag/suggestions.py 27 0 100% +app/rag/summarizer.py 58 0 100% +app/schemas.py 533 0 100% +-------------------------------------------------- +TOTAL 6467 17 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414). +Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest` + +validation OK diff --git a/.agents/reports/177_themed_favicon/177_themed_favicon__03_template_link_swap.a1.err b/.agents/reports/177_themed_favicon/177_themed_favicon__03_template_link_swap.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/177_themed_favicon/177_themed_favicon__03_template_link_swap.a1.md b/.agents/reports/177_themed_favicon/177_themed_favicon__03_template_link_swap.a1.md new file mode 100644 index 0000000..34fc676 --- /dev/null +++ b/.agents/reports/177_themed_favicon/177_themed_favicon__03_template_link_swap.a1.md @@ -0,0 +1,13 @@ +Task 03 complete — everything is green. + +**Report** + +- **Implemented** + - All five templates (`index.html`, `document.html`, `login.html`, `shared.html`, `doc-edit.html`) L8 link swapped to ` Edit doc · Brain of Reese - + diff --git a/frontend/document.html b/frontend/document.html index 559143c..46e74a7 100644 --- a/frontend/document.html +++ b/frontend/document.html @@ -5,7 +5,7 @@ Document · Brain of Reese - + diff --git a/frontend/index.html b/frontend/index.html index e83e98a..282be61 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -5,7 +5,7 @@ Brain of Reese - + diff --git a/frontend/login.html b/frontend/login.html index 837f407..85bf1d6 100644 --- a/frontend/login.html +++ b/frontend/login.html @@ -5,7 +5,7 @@ Sign in · Brain of Reese - + diff --git a/frontend/shared.html b/frontend/shared.html index d3d279a..198154f 100644 --- a/frontend/shared.html +++ b/frontend/shared.html @@ -5,7 +5,7 @@ Shared conversation · Brain of Reese - + diff --git a/tests/e2e/test_themed_favicon.py b/tests/e2e/test_themed_favicon.py new file mode 100644 index 0000000..6a82461 --- /dev/null +++ b/tests/e2e/test_themed_favicon.py @@ -0,0 +1,701 @@ +"""Phase 177 E2E (Playwright): the tab icon carries the effective +palette — the route, the pre-paint tag, the live preview, the +restore contract. + +Source: TODO.md L3 — "We need the favicon to change colors to +match the theme." (owner roadmap confirmation 2026-10-13, A1–A4.) + +Run in isolation (DB must be up: ``podman compose up -d db``): + + uv run pytest tests/e2e/test_themed_favicon.py -v --no-cov + +Test → contract mapping (one story, one phase, one isolated file): + +1. ``test_default_state_serves_the_golden_route`` — the default + (no ``ui_settings`` row) deployment: ``GET /favicon.svg`` is 200 + ``image/svg+xml`` + ``no-cache`` with the body byte-identical + to the golden art (``FAVICON_GOLDEN`` from + ``tests.unit.test_theming`` — ONE source); the five pages' + served HTML reference the route (``href="/favicon.svg"`` — + bare, never ``/assets/favicon.svg``); the deleted static file + is 404. +2. ``test_themed_state_serves_the_effective_palette`` — with a + saved palette (the 3 driving colors distinct): the route's SVG + carries the three hexes in their EXACT roles (attribute-parsed, + never substring) and is byte-equal to the server renderer's + output; a fresh page load STILL injects the ``#bor-theme`` tag + immediately before ```` with the full 17-var content + + the phase-91 CSP hash (the tag/CSP contract is untouched by the + favicon work), and the browser's first paint is the themed + paint (the computed ``--surface`` / ``--brand``). +3. ``test_live_preview_repaints_the_favicon`` — an admin's live + (unsaved) pick of the three driving fields sets + ``link[rel="icon"]`` to a ``data:image/svg+xml`` URI that, + decoded, carries the three picked hexes in their exact roles + (attribute-parsed). Headless Chromium never fetches favicons + (probe-verified — zero request events, not even for the + initial ``/favicon.svg``), so the suite pins the href state + + the decoded art as the repaint contract and guards the CSP + path NEGATIVELY: no CSP violation is ever reported against a + ``data:`` URI (the phase-123 ``img-src 'self' data:`` + carve-out makes a ``data:`` favicon legal — CSP3 governs + ``link[rel=icon]`` by ``img-src``; a blocked fetch would + surface as a ``requestfailed`` with a CSP error or a console + violation, which the suite asserts does not happen). A + non-driving pick (``#theme-bg``) never touches the href. +4. ``test_save_restores_the_route_url`` — Save: the href is + EXACTLY ``/favicon.svg`` and the route now serves the saved + palette's bytes (the settled palette governs). +5. ``test_reset_restores_the_route_url`` — after a Save, Reset: + the href is ``/favicon.svg`` and the route serves the built-in + golden bytes. +6. ``test_view_refresh_restores_the_route_url`` — a user-initiated + re-show (the router's ``bor:view-refresh`` dispatch — the + phase-77 hook) restores the href after a live pick. + +Zero external requests across the whole suite (AGENTS rule 6 — the +house E2E assertion idiom): a ``page.on("request")`` collector +asserts every http(s) URL is the local origin (a ``data:`` URI is +exempt by design — the phase-123 carve-out). + +DB isolation: the shared e2e Postgres keeps ``ui_settings`` (the +single row the caching middleware + the favicon route read for +EVERY request — a leftover themed row would repaint other suites' +pages and icons) and ``api_tokens`` rows across suites. An autouse +fixture truncates ``ui_settings`` and deletes the ``e2e-``-labeled +tokens before AND after every test (never a TRUNCATE on +``api_tokens`` — the shared DB may hold the owner's real tokens). + +Per-module app env (the tuning/tokens/archive-upload pattern): the +module-scoped ``app_server`` override boots the same env block as +the shared conftest server with the branding vars pinned to the +CODE defaults (an operator's local ``.env`` may carry the owner's +name/placeholder/footer, and "the effective strings start at the +template defaults" must hold regardless — the phase-61/62 +leak-guard pattern, extended to ``BOR_APP_NAME``) and +``BOR_GIT_SOURCES`` forced empty (the dev ``.env``'s git repo must +not render as env rows in this suite's app). The phase-92 file +(test_theme_save_and_coverage.py) is the copy source — the two +modules' scaffolds stay in lockstep so a future conftest refactor +touches both at once. +""" +from __future__ import annotations + +import json +import os +import subprocess +import sys +import xml.etree.ElementTree as ET +from collections.abc import Iterator +from urllib.parse import unquote + +import httpx +import pytest +from playwright.sync_api import Page, expect +from sqlalchemy import text + +from app.config import Settings +from app.core import theming +from app.db import SessionLocal +from e2e.auth_helpers import login +from e2e.conftest import ( + ADMIN_PASSWORD, + APP_PORT, + REPO, + SESSION_SECRET, + USE_REAL_LLM, + _wait_http, +) +from tests.unit.test_theming import FAVICON_GOLDEN + +APP_URL = f"http://127.0.0.1:{APP_PORT}" + +# The three driving fields (A1 — the artwork's color roles: hexagon +# fill, hexagon stroke + center dot, 4 spokes) with E2E values +# distinct from every built-in. +DRIVING: dict[str, str] = { + "surface": "#102030", + "brand": "#4f46e5", + "brand_ink": "#a5b4fc", +} +#: The E2E-stable color-input ids (the form's own markup — the +#: static E2E-stable-selectors house convention). +INPUT_IDS = { + field: f"#theme-{field.replace('_', '-')}" for field in theming.COLOR_FIELDS +} +#: The five templates whose served HTML must reference the route. +PAGES = ("/", "/document.html", "/login.html", "/shared.html", "/doc-edit.html") +#: The data: URI prefix the live preview sets (the phase-123 +#: img-src 'self' data: carve-out makes it CSP-legal). +DATA_PREFIX = "data:image/svg+xml," + + +# --------------------------------------------------------------------------- +# In-test constants (single sources of truth — never duplicated) +# --------------------------------------------------------------------------- + + +def _expected_tag(colors: dict[str, str]) -> str: + """The EXACT inline tag ``theme_style_tag`` renders for + ``colors``: one ``:root`` override, all 17 vars in COLOR_FIELDS + order, no whitespace (the byte the middleware injects).""" + declarations = "".join( + f"--{k.replace('_', '-')}:{colors[k]};" for k in theming.COLOR_FIELDS + ) + return f'' + + +def _expected_favicon_bytes(colors: dict[str, str]) -> bytes: + """The route's body for a palette: the SERVER renderer's output + (imported — ONE source, never a test-side mirror of the art).""" + return theming.favicon_svg(colors).encode("utf-8") + + +def _attr(element: ET.Element, name: str) -> str: + """An SVG attribute, asserted non-null (a missing role attribute + is a test failure, not a parseable value).""" + value = element.get(name) + assert value is not None, f"missing the {name} attribute" + return value + + +def _parse_roles(svg: str) -> dict[str, str]: + """The three color roles, ATTRIBUTE-parsed (the unit pin's + idiom — never substring): the hexagon's fill (surface), the + hexagon's stroke (brand), the center circle's fill (brand), + the spokes' stroke (brand_ink).""" + ns = "{http://www.w3.org/2000/svg}" + root = ET.fromstring(svg) + assert root.tag == f"{ns}svg", root.tag + hexagon, circle, spokes = list(root) + return { + "surface": _attr(hexagon, "fill"), + "brand": _attr(hexagon, "stroke"), + "brand_circle": _attr(circle, "fill"), + "brand_ink": _attr(spokes, "stroke"), + } + + +def _template_defaults() -> dict[str, str]: + """The 3 template strings from the CODE defaults (derived from + the class fields — never drifts from ``app/config.py``; the + module server pins the same values, so the effective strings + start exactly here).""" + return { + "app_name": Settings.model_fields["app_name"].default, + "input_placeholder": Settings.model_fields["input_placeholder"].default, + "footer_text": Settings.model_fields["footer_text"].default, + } + + +def _wait_mount_settled(page: Page) -> None: + """The theme view's mount-time load has SETTLED: its populate + is the last visible step — the app-name input carries the + effective default (the static markup ships it empty, so only a + settled ``loadSettings`` can have written it).""" + expect(page.locator("#theme-app-name")).to_have_value( + _template_defaults()["app_name"], timeout=15_000 + ) + + +def _favicon_href(page: Page) -> str: + """The tab icon's current href (the attribute's raw state).""" + return page.evaluate( + "() => document.querySelector('link[rel=icon]').getAttribute('href')" + ) + + +def _wait_data_href(page: Page) -> None: + """The href is a ``data:`` URI (the live preview has landed).""" + page.wait_for_function( + f"() => (document.querySelector('link[rel=icon]')" + f".getAttribute('href') || '').startsWith('{DATA_PREFIX}')", + timeout=15_000, + ) + + +def _wait_route_href(page: Page) -> None: + """The href is EXACTLY the route URL (the restore has landed — + the settled palette governs).""" + page.wait_for_function( + "() => (document.querySelector('link[rel=icon]')" + '.getAttribute(\'href\') || \'\') === \'/favicon.svg\'', + timeout=15_000, + ) + + +def _seed_theme_via_api(app_url: str, cookies: dict[str, str]) -> None: + """Admin ``PUT /api/ui-settings`` with the 3 driving colors + (the rest null → the built-ins — the route's bytes need only + the three colors in the artwork).""" + body: dict[str, object] = {} + for field in theming.STRING_FIELDS: + body[field] = None + for field in theming.COLOR_FIELDS: + body[field] = None + body.update(DRIVING) + r = httpx.put(f"{app_url}/api/ui-settings", json=body, cookies=cookies, timeout=10) + assert r.status_code == 200, r.text + + +# --------------------------------------------------------------------------- +# Per-module app env (the tuning/tokens/archive-upload pattern) +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def app_server(mock_llm: int) -> Iterator[str]: + """The real app under test — per-module env: the branding vars + are pinned to the CODE defaults (the effective strings start at + the template defaults regardless of an operator's local + ``.env`` — the phase-61/62 leak-guard pattern the shared + conftest server applies to its two string vars; this one pins + all three, including ``BOR_APP_NAME``, which the shared server + leaves to the process) and ``BOR_GIT_SOURCES`` is forced empty + (the dev ``.env``'s git repo must not render as env rows in + this suite's app).""" + env = dict(os.environ) + env.pop("DEBUGPY", None) + env["BOR_ENVIRONMENT"] = "e2e" + env["BOR_STATIC_DIR"] = str(REPO / "frontend") + env["BOR_LLM_BASE_URL"] = ( + "https://aipi.reeseapps.com/v1" + if USE_REAL_LLM + else f"http://127.0.0.1:{mock_llm}/v1" + ) + # Mock-calibrated threshold (conftest pattern) — no chat turn is + # ever sent in this suite, but the app boots with the same shape. + env["BOR_RELEVANCE_THRESHOLD"] = "0.30" + env["BOR_LLM_RETRY_DELAY"] = "0" + env["BOR_LLM_RETRIES"] = str(Settings.model_fields["llm_retries"].default) + env.setdefault( + "BOR_DATABASE_URL", + "postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese", + ) + # Phase 16: admin auth must be set or create_app() refuses to boot. + env["BOR_ADMIN_PASSWORD"] = ADMIN_PASSWORD + env["BOR_SESSION_SECRET"] = SESSION_SECRET + env["BOR_DOCS_REPO"] = "" + env["BOR_SUGGESTIONS"] = json.dumps( + Settings.model_fields["suggestions"].default + ) + # The branding vars: "unset" = the template defaults (the code + # defaults, derived from the class fields — the local ``.env`` + # may carry the owner's values, and this suite's assertions need + # the TEMPLATE defaults, not the owner's). + env["BOR_APP_NAME"] = Settings.model_fields["app_name"].default + env["BOR_INPUT_PLACEHOLDER"] = ( + Settings.model_fields["input_placeholder"].default + ) + env["BOR_FOOTER_TEXT"] = Settings.model_fields["footer_text"].default + env["BOR_GIT_SOURCES"] = "" + proc = subprocess.Popen( + [sys.executable, "-m", "uvicorn", "app.main:app", + "--host", "127.0.0.1", "--port", str(APP_PORT), "--log-level", "warning"], + cwd=REPO, + env=env, + ) + try: + _wait_http(f"{APP_URL}/api/health") + yield APP_URL + finally: + proc.terminate() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + + +@pytest.fixture(scope="module") +def app_url(app_server: str) -> str: + return app_server + + +# --------------------------------------------------------------------------- +# DB isolation + helpers +# --------------------------------------------------------------------------- + + +def _clean_ui_state() -> None: + """Fresh theme + token state per test: truncate the single-row + ``ui_settings`` (the middleware + the favicon route read it for + EVERY request — a leftover themed row would repaint other + suites' pages and icons) and delete this suite's issued tokens + (label-scoped on ``e2e-`` — never a TRUNCATE: the shared DB + may hold the owner's real tokens).""" + with SessionLocal() as db: + db.execute(text("TRUNCATE ui_settings")) + db.execute(text("DELETE FROM api_tokens WHERE label LIKE 'e2e-%'")) + db.commit() + + +@pytest.fixture(autouse=True) +def _clean(db_ready: None) -> Iterator[None]: + _clean_ui_state() + yield + _clean_ui_state() + + +def _cookies(page: Page) -> dict[str, str]: + """The session cookies the browser context holds (the test's API + side sees exactly what that browser sees).""" + return { + c["name"]: c["value"] + for c in page.context.cookies() + if "name" in c and "value" in c + } + + +def _zero_external( + page: Page, +) -> tuple[list[str], list[str], list[str], list[str]]: + """AGENTS rule 6 (the house E2E assertion idiom): every http(s) + request is the local origin; ``data:`` URIs are exempt by + design (the phase-123 ``img-src 'self' data:`` carve-out). + Also collects the CSP-failure signals: a failed ``data:`` + fetch (a blocked fetch carries a CSP error in its failure + message) and console CSP violations ("Refused to …" / "CSP" + against a ``data:`` URI). Returns (externals, data_requests, + failed_data, csp_violations). Headless Chromium never fetches + favicons (probe-verified), so ``data_requests`` is expected to + stay empty — the negative assertions (``failed_data`` + + ``csp_violations``) are the CSP guard: a future change that + blocks the ``data:`` URI surfaces here, and a real + (non-headless) browser run additionally sees the fetch in + ``data_requests``.""" + externals: list[str] = [] + data_requests: list[str] = [] + failed_data: list[str] = [] + csp_violations: list[str] = [] + + def _watch(request: object) -> None: + url = getattr(request, "url", "") + if url.startswith("data:"): + data_requests.append(url) + return + if url.startswith(("http://", "https://")) and "127.0.0.1" not in url: + externals.append(url) + + def _watch_failed(request: object) -> None: + url = getattr(request, "url", "") + failure = getattr(request, "failure", None) + if url.startswith("data:") and failure: + failed_data.append(str(failure)) + + def _watch_console(message: object) -> None: + msg = getattr(message, "text", "") + if ("CSP" in msg or "Refused to" in msg) and "data:" in msg: + csp_violations.append(msg) + + page.on("request", _watch) + page.on("requestfailed", _watch_failed) + page.on("console", _watch_console) + return externals, data_requests, failed_data, csp_violations + + +def _assert_zero_external( + externals: list[str], failed_data: list[str], csp_violations: list[str] +) -> None: + """The suite's tail assertion: zero external requests, no + ``data:`` URI CSP-blocked (a blocked fetch carries a CSP error + in its failure message), and no CSP violation reported + against a ``data:`` URI (the phase-123 carve-out holds).""" + assert not externals, f"external requests: {externals}" + assert not failed_data, f"data: URIs blocked (CSP?): {failed_data}" + assert not csp_violations, f"CSP violations: {csp_violations}" + + +# --------------------------------------------------------------------------- +# 1. Default state: the golden route, the five page references, the +# deleted static file +# --------------------------------------------------------------------------- + + +def test_default_state_serves_the_golden_route(app_url: str, db_ready: None) -> None: + """The default (no ``ui_settings`` row) deployment: the route + is byte-identical to the golden art (imported — ONE source), + with the svg media type + ``no-cache``; the five pages' served + HTML reference the route (bare — no ``?v=``, never + ``/assets/favicon.svg``); the deleted static file is 404.""" + r = httpx.get(f"{app_url}/favicon.svg", timeout=10) + assert r.status_code == 200 + assert r.headers["content-type"].startswith("image/svg+xml") + assert r.headers["cache-control"] == "no-cache" + assert r.content == FAVICON_GOLDEN.encode("utf-8") + + for path in PAGES: + page = httpx.get(app_url + path, timeout=10) + assert page.status_code == 200, path + assert ( + '' + in page.text + ), f"{path} must reference the themed route" + assert "/assets/favicon.svg" not in page.text, ( + f"{path} must never reference the deleted static file" + ) + assert "favicon.svg?v=" not in page.text, ( + f"{path} must keep the favicon link bare (no ?v=)" + ) + + gone = httpx.get(app_url + "/assets/favicon.svg", timeout=10) + assert gone.status_code == 404 + + +# --------------------------------------------------------------------------- +# 2. Themed state: the route carries the effective palette; the +# pre-paint tag + the first paint are untouched (phase 91) +# --------------------------------------------------------------------------- + + +def test_themed_state_serves_the_effective_palette( + page: Page, app_url: str, db_ready: None +) -> None: + """With a saved palette (the 3 driving colors distinct): the + route's SVG carries the three hexes in their EXACT roles + (attribute-parsed) and is byte-equal to the server renderer; + a fresh page load STILL injects the ``#bor-theme`` tag + (17-var content, immediately before ```` + the phase-91 + CSP hash — the tag/CSP contract is untouched by the favicon + work) and the browser's first paint is the themed paint.""" + page.set_default_timeout(30_000) + login(page, app_url) + _seed_theme_via_api(app_url, _cookies(page)) + + # The route: the three hexes in their exact roles… + r = httpx.get(f"{app_url}/favicon.svg", timeout=10) + assert r.status_code == 200 + roles = _parse_roles(r.text) + assert roles["surface"] == DRIVING["surface"] + assert roles["brand"] == DRIVING["brand"] + assert roles["brand_circle"] == DRIVING["brand"] + assert roles["brand_ink"] == DRIVING["brand_ink"] + # …and byte-equal to the server renderer's output (ONE source). + effective = {**theming.BUILTIN_COLORS, **DRIVING} + assert r.content == _expected_favicon_bytes(effective) + + # The pre-paint tag non-regression (phase 91): the RAW served + # HTML carries the 17-var tag immediately before ````… + tag = _expected_tag(effective) + r = httpx.get(app_url + "/", timeout=10) + assert r.status_code == 200 + assert r.text.count(tag) == 1, "the 17-var tag must be injected exactly once" + start = r.text.index(tag) + assert start + len(tag) == r.text.index(""), ( + "the tag must end exactly where begins" + ) + # …with the phase-91 CSP hash (the favicon work never touched + # the tag/CSP contract). + assert "style-src 'self' 'sha256-" in r.headers.get( + "content-security-policy", "" + ), "the themed page must carry the phase-91 CSP hash" + + # A fresh page load: the first paint IS the themed paint. + page.goto(app_url + "/") + page.wait_for_function( + """(d) => { + const cs = getComputedStyle(document.documentElement); + return cs.getPropertyValue('--surface').trim() === d.surface + && cs.getPropertyValue('--brand').trim() === d.brand; + }""", + arg=DRIVING, + timeout=15_000, + ) + + +# --------------------------------------------------------------------------- +# 3. Live preview: an unsaved pick repaints the favicon via the +# CSP-legal data: URI; a non-driving pick never touches it +# --------------------------------------------------------------------------- + + +def test_live_preview_repaints_the_favicon( + page: Page, app_url: str, db_ready: None +) -> None: + """An admin's live (unsaved) pick of the three driving fields + sets ``link[rel="icon"]`` to a ``data:`` URI that, decoded, + carries the three picked hexes in their exact roles; the + browser FETCHES the ``data:`` URI (the phase-123 carve-out — + a blocked fetch would be a ``requestfailed`` with a CSP error); + a non-driving pick (``#theme-bg``) never touches the href.""" + page.set_default_timeout(30_000) + externals, _data_requests, failed_data, csp_violations = _zero_external(page) + login(page, app_url, next="/theme.html") + expect(page.locator("#theme-content")).to_be_visible(timeout=15_000) + _wait_mount_settled(page) + + # A clean start: the href is the route (no row → the golden + # art served by the route). + assert _favicon_href(page) == "/favicon.svg" + + # Set the three driving fields (page.fill fires the input event + # — the house _fill_theme_form idiom). + for field in ("surface", "brand", "brand_ink"): + page.fill(INPUT_IDS[field], DRIVING[field]) + _wait_data_href(page) + + # The href is a data: URI, and — decoded — carries the three + # picked hexes in their exact roles (attribute-parsed). + href = _favicon_href(page) + assert href.startswith(DATA_PREFIX) + svg = unquote(href[len(DATA_PREFIX):]) + roles = _parse_roles(svg) + assert roles["surface"] == DRIVING["surface"] + assert roles["brand"] == DRIVING["brand"] + assert roles["brand_circle"] == DRIVING["brand"] + assert roles["brand_ink"] == DRIVING["brand_ink"] + + # A non-driving pick never touches the href. + page.fill(INPUT_IDS["bg"], "#000000") + assert _favicon_href(page) == href, "a non-driving pick must not touch the href" + + # The CSP guard (negative — headless never fetches favicons): + # no failed data: fetch, no CSP violation against the URI. + _assert_zero_external(externals, failed_data, csp_violations) + + +# --------------------------------------------------------------------------- +# 4. Restore: Save — the href is EXACTLY /favicon.svg and the route +# serves the saved palette's bytes (the settled palette governs) +# --------------------------------------------------------------------------- + + +def test_save_restores_the_route_url( + page: Page, app_url: str, db_ready: None +) -> None: + """After a live pick, Save: the §7.4 lifecycle lands and the + href is EXACTLY ``/favicon.svg``; the route now serves the + saved palette's bytes (the settled palette governs — the route + re-reads the DB per request, no process cache).""" + page.set_default_timeout(30_000) + externals, _d, failed_data, csp = _zero_external(page) + login(page, app_url, next="/theme.html") + expect(page.locator("#theme-content")).to_be_visible(timeout=15_000) + _wait_mount_settled(page) + + # A live pick first — so the save has a data: href to restore. + for field in ("surface", "brand", "brand_ink"): + page.fill(INPUT_IDS[field], DRIVING[field]) + _wait_data_href(page) + + # Save through the real form. + page.click("#theme-save") + expect(page.locator("#theme-result")).to_have_text( + "Theme saved.", timeout=30_000 + ) + expect(page.locator("#theme-result")).to_have_attribute("role", "status") + # The restore: the href is EXACTLY the route URL. + _wait_route_href(page) + assert _favicon_href(page) == "/favicon.svg" + + # The settled palette governs: the route serves the saved + # palette's bytes (the PUT saved all 20 form values — the 3 + # driving colors + the 14 built-ins (→ NULL) + the 3 template + # strings; the effective palette is the 3 + the built-ins). + effective = {**theming.BUILTIN_COLORS, **DRIVING} + r = httpx.get(f"{app_url}/favicon.svg", timeout=10) + assert r.status_code == 200 + assert r.content == _expected_favicon_bytes(effective) + + _assert_zero_external(externals, failed_data, csp) + + +# --------------------------------------------------------------------------- +# 5. Restore: Reset — the href is /favicon.svg and the route serves +# the built-in golden bytes +# --------------------------------------------------------------------------- + + +def test_reset_restores_the_route_url( + page: Page, app_url: str, db_ready: None +) -> None: + """After a Save (the route is themed), a fresh live pick, then + Reset: the §7.4 lifecycle lands and the href is + ``/favicon.svg``; the route serves the built-in golden bytes + (the reset nulled the row — the built-ins govern).""" + page.set_default_timeout(30_000) + externals, _d, failed_data, csp = _zero_external(page) + login(page, app_url, next="/theme.html") + expect(page.locator("#theme-content")).to_be_visible(timeout=15_000) + _wait_mount_settled(page) + + # Save first — the route is themed (the reset then has a real + # state to settle back from). + for field in ("surface", "brand", "brand_ink"): + page.fill(INPUT_IDS[field], DRIVING[field]) + _wait_data_href(page) + page.click("#theme-save") + expect(page.locator("#theme-result")).to_have_text( + "Theme saved.", timeout=30_000 + ) + _wait_route_href(page) + r = httpx.get(f"{app_url}/favicon.svg", timeout=10) + assert r.content == _expected_favicon_bytes( + {**theming.BUILTIN_COLORS, **DRIVING} + ) + + # A fresh live pick — the href is the data: one the reset + # restores (a distinct value, so the repaint is guaranteed). + page.fill(INPUT_IDS["surface"], "#0a1525") + _wait_data_href(page) + + # Reset to defaults: the §7.4 lifecycle (the all-null PUT — the + # API's documented "defaults" operation). + page.click("#theme-reset") + expect(page.locator("#theme-result")).to_have_text( + "Reset to the built-in theme.", timeout=30_000 + ) + expect(page.locator("#theme-result")).to_have_attribute("role", "status") + # The restore: the href is EXACTLY the route URL… + _wait_route_href(page) + assert _favicon_href(page) == "/favicon.svg" + # …and the route serves the built-in golden bytes. + r = httpx.get(f"{app_url}/favicon.svg", timeout=10) + assert r.status_code == 200 + assert r.content == FAVICON_GOLDEN.encode("utf-8") + + _assert_zero_external(externals, failed_data, csp) + + +# --------------------------------------------------------------------------- +# 6. Restore: the bor:view-refresh re-show (the phase-77 hook) +# --------------------------------------------------------------------------- + + +def test_view_refresh_restores_the_route_url( + page: Page, app_url: str, db_ready: None +) -> None: + """After a live pick, a user-initiated re-show (leave the Theme + view, come back — the router dispatches ``bor:view-refresh`` + on the already-mounted section): the handler re-runs the load, + reconciles, and the href is restored to ``/favicon.svg`` (the + settled palette governs — in this test's row-less state, the + golden bytes).""" + page.set_default_timeout(30_000) + externals, _d, failed_data, csp = _zero_external(page) + login(page, app_url, next="/theme.html") + expect(page.locator("#theme-content")).to_be_visible(timeout=15_000) + _wait_mount_settled(page) + + # A live pick — the href is the data: one the re-show restores. + for field in ("surface", "brand", "brand_ink"): + page.fill(INPUT_IDS[field], DRIVING[field]) + _wait_data_href(page) + + # A user-initiated re-show: away (the RAG view — a real view + # switch), then back — the router's phase-77 hook dispatches + # bor:view-refresh on the mounted Theme section. + page.click("#nav-sources") + expect(page.locator("#view-rag")).to_be_visible(timeout=15_000) + page.click("#nav-theme") + expect(page.locator("#view-theme")).to_be_visible(timeout=15_000) + # The restore: the href is EXACTLY the route URL. + _wait_route_href(page) + assert _favicon_href(page) == "/favicon.svg" + + # The settled palette governs: no row was saved, so the route + # still serves the golden bytes. + r = httpx.get(f"{app_url}/favicon.svg", timeout=10) + assert r.status_code == 200 + assert r.content == FAVICON_GOLDEN.encode("utf-8") + + _assert_zero_external(externals, failed_data, csp) diff --git a/tests/integration/test_favicon_route.py b/tests/integration/test_favicon_route.py new file mode 100644 index 0000000..c6674ba --- /dev/null +++ b/tests/integration/test_favicon_route.py @@ -0,0 +1,335 @@ +"""Integration: the themed favicon route — ``GET /favicon.svg`` (phase +177, task 02, D2; A2/A3 owner-confirmed) + the template pins (task 03, +D3 — the route is the SINGLE source; the static file is deleted). + +The route's contract, driven through the real app (TestClient + the +house ``db`` fixture — the ``test_ui_settings_api`` pattern): + +* **default state** (no ``ui_settings`` row): 200 ``image/svg+xml`` + + ``Cache-Control: no-cache``, the body byte-identical to the golden + art (``FAVICON_GOLDEN`` from ``tests.unit.test_theming`` — ONE + source, so the route and the renderer can never serve different + bytes), the CSP header present (the ``SecurityHeadersMiddleware`` + pass-through — no new header wiring was needed); +* **themed state**: a set row's ``surface``/``brand``/``brand_ink`` + land in their exact SVG attribute roles (asserted by PARSING the + attributes — the ``test_theming`` unit-pin idiom, not substring) and + none of the other 14 built-ins appears in the body; +* **no process cache**: a row update is visible on the very next + request — the palette is re-read per request (the runtime-change + contract; a theme change never needs a restart to reach the tab + icon); +* **DB-failure state**: ``effective_settings`` raising (a DB blip / a + pre-migration boot) → 200 with the built-in golden bytes (NEVER 500 + — the never-break contract) + the fallback log line; +* **HEAD parity**: the same headers as GET, an empty body (the + phase-76 static-file parity idiom — the pre-phase static file + answered both methods); +* **routes-first**: a file named ``favicon.svg`` in the static dir + could NOT shadow the route (the registration position holds); +* **task 03 (template pins)**: all five served page bodies link the + EXACT bare ```` (NO ``?v=`` — the href + is not under ``/assets/``, so ``CachingMiddleware``'s + ``_ASSET_REF_RE`` can never touch it; the pin keeps that contract + explicit against a future regex drift) and contain no + ``/assets/favicon.svg`` reference; ``GET /assets/favicon.svg`` + 404s (the file is deleted); a tree scan of ``frontend/`` finds zero + remaining ``/assets/favicon.svg`` references. + +The route is deliberately OUTSIDE ``/api/*`` (no per-response +byte-budget line — AGENTS rule 10 is ``/api/*``-only) and carries NO +auth dependency (the favicon is chrome — public like the login page): +every test below is the anonymous ``client``. + +Real Postgres (``podman compose up -d db``); no LLM involved. + +Requires: podman compose up -d db +""" +from __future__ import annotations + +import logging +import xml.etree.ElementTree as ET +from collections.abc import Iterator +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import text, update +from sqlalchemy.orm import Session + +from app.config import get_settings +from app.core import theming +from app.models import UiSettings +from tests.unit.test_theming import FAVICON_GOLDEN + +# Phase 177 (task 03, D3): the template pins — every page links the +# BARE route; the static file is gone. + +#: The EXACT favicon link tag every template must carry (bare — no +#: ``?v=``: the href is not under ``/assets/``). +FAVICON_LINK_TAG = ( + '' +) + +#: The five L8 pages — the shell + the four standalone pages. +PAGE_PATHS: tuple[str, ...] = ( + "/", + "/document.html", + "/login.html", + "/shared.html", + "/doc-edit.html", +) + +#: The frontend tree root (the house tree-scan idiom). +FRONTEND = Path(__file__).resolve().parents[2] / "frontend" + + +@pytest.fixture(autouse=True) +def clean_state(db: Session) -> Iterator[None]: + """The single ui_settings row is global state — wiped so every + test starts from the state it asserts (the house DELETE reset + pattern, ``test_ui_settings_api``).""" + db.execute(text("DELETE FROM ui_settings")) + db.commit() + yield + db.execute(text("DELETE FROM ui_settings")) + db.commit() + + +def _assert_roles( + svg_text: str, *, surface: str, brand: str, brand_ink: str +) -> None: + """The three hexes in their EXACT SVG attribute roles — asserted by + PARSING the attributes (the ``test_theming`` unit-pin idiom, not + substring) — the geometry invariants along the way (the hexagon + ``d`` path, the three element order), and none of the other 14 + built-in hexes anywhere in the body (a themed favicon must not + leak a built-in color).""" + ns = "{http://www.w3.org/2000/svg}" + root = ET.fromstring(svg_text) + assert root.tag == f"{ns}svg" + hexagon, circle, spokes = list(root) + assert hexagon.tag == f"{ns}path" + assert hexagon.get("d") == "M32 4 55 18v28L32 60 9 46V18Z" + assert hexagon.get("fill") == surface + assert hexagon.get("stroke") == brand + assert circle.tag == f"{ns}circle" + assert circle.get("fill") == brand + assert spokes.tag == f"{ns}path" + assert spokes.get("stroke") == brand_ink + for key, value in theming.BUILTIN_COLORS.items(): + if value not in (surface, brand, brand_ink): + assert value not in svg_text, ( + f"built-in {key} ({value}) must not appear in a themed favicon" + ) + + +def test_default_state_serves_the_golden_bytes( + client: TestClient, db: Session +) -> None: + """No ui_settings row: the BUILT-IN palette renders — 200 + ``image/svg+xml`` + ``Cache-Control: no-cache``, the body + byte-identical to the golden art (the D1 contract through the + wire), and the CSP header is present on the response (the + ``SecurityHeadersMiddleware`` pass-through — no header change was + needed; the phase-123 ``img-src 'self' data:`` carve-out is part + of the policy that covers task 04's ``data:`` preview).""" + assert ( + db.execute(text("SELECT count(*) FROM ui_settings")).scalar() == 0 + ), "the test starts from a row-missing state" + r = client.get("/favicon.svg") + assert r.status_code == 200 + assert r.headers["content-type"] == "image/svg+xml" + assert r.headers["cache-control"] == "no-cache" + assert r.text == FAVICON_GOLDEN + assert "content-security-policy" in r.headers + assert "default-src 'self'" in r.headers["content-security-policy"] + assert "img-src 'self' data:" in r.headers["content-security-policy"] + + +def test_themed_state_carries_the_three_roles( + client: TestClient, db: Session +) -> None: + """A set row (distinct ``surface``/``brand``/``brand_ink``): the + three hexes land in their exact SVG attribute roles (parsed, not + substring) and none of the other 14 built-ins appears in the + body; the header contract (no-cache + content-type) is unchanged + by the palette.""" + db.add( + UiSettings( + id=1, surface="#102030", brand="#4f46e5", brand_ink="#a5b4fc" + ) + ) + db.commit() + r = client.get("/favicon.svg") + assert r.status_code == 200 + assert r.headers["content-type"] == "image/svg+xml" + assert r.headers["cache-control"] == "no-cache" + _assert_roles( + r.text, surface="#102030", brand="#4f46e5", brand_ink="#a5b4fc" + ) + + +def test_themed_state_is_live_no_process_cache( + client: TestClient, db: Session +) -> None: + """The runtime-change contract (D2): the palette is re-read per + request — NO process cache, so a row update is visible on the very + next request (a theme change never needs a restart to reach the + tab icon), and the OLD palette is gone from the body (nothing + stale is served).""" + db.add( + UiSettings( + id=1, surface="#102030", brand="#4f46e5", brand_ink="#a5b4fc" + ) + ) + db.commit() + r1 = client.get("/favicon.svg") + assert r1.status_code == 200 + _assert_roles( + r1.text, surface="#102030", brand="#4f46e5", brand_ink="#a5b4fc" + ) + + db.execute( + update(UiSettings) + .where(UiSettings.id == 1) + .values(surface="#203040", brand="#0ea5e9", brand_ink="#bae6fd") + ) + db.commit() + r2 = client.get("/favicon.svg") + assert r2.status_code == 200 + _assert_roles( + r2.text, surface="#203040", brand="#0ea5e9", brand_ink="#bae6fd" + ) + for old in ("#102030", "#4f46e5", "#a5b4fc"): + assert old not in r2.text + + +def test_db_failure_serves_the_builtin_art_with_200( + client: TestClient, + db: Session, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """A DB blip / pre-migration boot (``effective_settings`` raising): + the handler's fallback serves the BUILT-IN golden bytes with 200 + (NEVER 500 — the never-break contract) and logs the failure via + ``logger.exception`` (the ``caching.py`` DB-blip pattern).""" + + def _boom(_session: Session) -> dict[str, str]: + raise RuntimeError("db blip (test)") + + monkeypatch.setattr(theming, "effective_settings", _boom) + with caplog.at_level(logging.ERROR, logger="app"): + r = client.get("/favicon.svg") + assert r.status_code == 200 + assert r.headers["content-type"] == "image/svg+xml" + assert r.headers["cache-control"] == "no-cache" + assert r.text == FAVICON_GOLDEN + assert any( + "favicon: theme read failed" in record.message for record in caplog.records + ), "the fallback path must log the failure (logger.exception)" + + +def test_head_parity_matches_get(client: TestClient, db: Session) -> None: + """``HEAD /favicon.svg``: 200 with the SAME headers as GET + (content-type, no-cache, the CSP pass-through) and an empty body + (the phase-76 static-file parity idiom — the pre-phase static + file answered both methods, the dynamic route keeps that).""" + r = client.head("/favicon.svg") + assert r.status_code == 200 + assert r.headers["content-type"] == "image/svg+xml" + assert r.headers["cache-control"] == "no-cache" + assert "content-security-policy" in r.headers + assert r.content == b"" + + +def test_route_wins_over_the_static_mount(client: TestClient) -> None: + """Routes-first: a file named ``favicon.svg`` in the static dir + could NOT shadow the route — the file is created with + SHADOW-ATTEMPT bytes (and deleted again in the ``finally``), and + the response is still the golden art (the registration position — + after the router includes, before the static catch-all mount — + holds).""" + shadow = Path(get_settings().static_dir).resolve() / "favicon.svg" + assert not shadow.exists(), ( + "the test must start without a static-dir favicon.svg" + ) + shadow.write_text("SHADOW ATTEMPT", encoding="utf-8") + try: + r = client.get("/favicon.svg") + assert r.status_code == 200 + assert r.text == FAVICON_GOLDEN + assert "SHADOW ATTEMPT" not in r.text + finally: + shadow.unlink() + + +def test_route_is_public_no_auth_dependency(client: TestClient) -> None: + """The favicon is chrome — public like the login page: the + anonymous (unsigned-in) client gets 200, and the route carries no + ``/api/`` prefix (deliberately outside ``/api/*`` — no + per-response byte-budget line, AGENTS rule 10's scope).""" + assert client.get("/api/whoami").json() == { + "authenticated": False, + "role": "anonymous", + } + r = client.get("/favicon.svg") + assert r.status_code == 200 + + +# ---------- Phase 177 (task 03, D3): the template pins ---------- + + +@pytest.mark.parametrize("path", PAGE_PATHS) +def test_served_page_links_the_bare_route( + client: TestClient, path: str +) -> None: + """Each of the five served page bodies carries the EXACT bare + favicon link (task 03, D3 — ``href="/favicon.svg"``) and contains + NO ``/assets/favicon.svg`` reference of any kind. The link stays + BARE — no ``?v=`` (the href is not under ``/assets/``, so + ``CachingMiddleware``'s ``_ASSET_REF_RE`` can never touch it; + the pin keeps that contract explicit against a future regex + drift).""" + r = client.get(path) + assert r.status_code == 200 + assert FAVICON_LINK_TAG in r.text, f"{path}: favicon link pin missing" + assert "/assets/favicon.svg" not in r.text + assert "/favicon.svg?v=" not in r.text, ( + f"{path}: the favicon link must stay bare (no ?v= version token)" + ) + + +def test_the_static_favicon_file_is_gone(client: TestClient) -> None: + """The single-source decision (task 03, D3): ``frontend/assets/ + favicon.svg`` no longer exists — on disk AND over the wire (the + static catch-all 404s it). Nothing is lost: the built-in bytes + stay pinned by ``FAVICON_GOLDEN`` in ``test_theming`` (task 01's + golden constant is the durable contract), and ``GET + /favicon.svg`` still serves the golden art (the route is the + single source now).""" + assert not (FRONTEND / "assets" / "favicon.svg").exists() + r = client.get("/assets/favicon.svg") + assert r.status_code == 404 + r = client.get("/favicon.svg") + assert r.status_code == 200 + assert r.text == FAVICON_GOLDEN + + +def test_tree_scan_has_zero_assets_favicon_references() -> None: + """A tree scan of ``frontend/`` (the house idiom — every file, + markup + scripts + styles, comments included): ZERO remaining + references to ``/assets/favicon.svg`` (the deletion is complete — + no template, script, or stylesheet still asks for the retired + URL).""" + files = [ + p + for p in sorted(FRONTEND.rglob("*")) + if p.is_file() + ] + assert files, "frontend/ contains no files — test is blind" + for path in files: + assert "/assets/favicon.svg" not in path.read_text(encoding="utf-8"), ( + f"/assets/favicon.svg survived in {path.relative_to(FRONTEND)}" + ) diff --git a/tests/unit/test_theme_frontend.py b/tests/unit/test_theme_frontend.py index 83068c5..6bf671d 100644 --- a/tests/unit/test_theme_frontend.py +++ b/tests/unit/test_theme_frontend.py @@ -32,12 +32,23 @@ frontend sources and asserts on their mechanisms): ``BUILTIN_COLORS``), never a third hardcoded palette copy; * the "State colors" fieldset sits AFTER the palette fieldset (the task-02 form shape), and the palette legend's "five pairs" copy is - the "eight pairs" copy (the WCAG 2.1 AA (4.5:1) wording kept). + the "eight pairs" copy (the WCAG 2.1 AA (4.5:1) wording kept); +* phase 177 (task 04, A4) — the favicon live preview: the + ``data:image/svg+xml,`` + ``encodeURIComponent`` mechanism, the + ``isHex`` guard (a non-hex input returns ``null`` — the link is + left untouched), the EXACTLY-three driving fields (the binding's + gate + the helper call), the mirror's artwork constants pinned + against ``app/core/theming.py``'s ``favicon_svg`` source (the + divergence guard), and the ``/favicon.svg`` restore wired to the + four settle points (Save success, Reset success, the + Reset-failure ``clearPreview()`` path, the + ``bor:view-refresh`` re-show — and to no other point). """ from __future__ import annotations import re from pathlib import Path +from typing import TypedDict from app.core import theming @@ -276,3 +287,179 @@ def test_state_colors_fieldset_after_the_palette() -> None: assert field_id not in palette_body else: assert field_id in palette_body + + +# ---------- theme.js: the favicon live preview (phase 177 — A4) ---------- +# The admin Theme tab's live (unsaved) picks of the THREE driving +# fields (``surface`` / ``brand`` / ``brand_ink``) repaint the tab +# icon via a CSP-legal ``data:`` URI — the CLIENT-SIDE MIRROR of +# ``app.core.theming.favicon_svg`` (the live behavior is E2E-gated +# in ``tests/e2e/test_themed_favicon.py``; here the source pins +# keep the mirror from diverging — the ``PAIRS``-mirror house +# pattern). The settled palette always wins: the four settle points +# (Save success, Reset success, the Reset-failure ``clearPreview()`` +# path, the ``bor:view-refresh`` re-show) restore the href to +# ``/favicon.svg`` (the route re-reads the DB per request — no +# process cache — so the restored URL is always fresh). + +THEMING_PY = Path(__file__).resolve().parents[2] / "app" / "core" / "theming.py" + + +def _favicon_helper(js: str) -> str: + """The ``themeFaviconDataUri`` function (from the signature to + the ``restoreFavicon`` definition — the helper's layout is + pinned by the pins below, so the slice is stable).""" + start = js.index("function themeFaviconDataUri(") + end = js.index("function restoreFavicon()", start) + return js[start:end] + + +def test_favicon_data_uri_mechanism_present() -> None: + """The live preview builds a CSP-legal ``data:`` URI: the + literal ``data:image/svg+xml,`` prefix + + ``encodeURIComponent`` — the phase-123 ``img-src 'self' + data:`` carve-out makes a ``data:`` favicon legal (CSP3 + governs ``link[rel=icon]`` by ``img-src``; the carve-out is + image-scoped — ``data:`` never becomes a script/style/fetch + source). The URI is built from constants + validated hexes — + the house "never build HTML" rule binds the DOM, and this + file's URI carries no DOM-inserted HTML.""" + helper = _favicon_helper(_js()) + assert '"data:image/svg+xml,"' in helper + assert "encodeURIComponent(" in helper + + +def test_favicon_helper_guards_non_hex_inputs() -> None: + """The ``isHex`` guard (belt-and-braces — the color inputs are + always valid hex, the inputs are the ONLY source of the + values, so there is no XSS path): a non-hex input returns + ``null``, and the link is set ONLY for a non-null URI (a + ``null`` URI leaves it untouched).""" + helper = _favicon_helper(_js()) + assert ( + "if (!isHex(surface) || !isHex(brand) || !isHex(brandInk)) " + "return null;" in helper + ) + assert "if (uri && faviconLink) faviconLink.href = uri;" in _js() + + +def test_favicon_driving_set_is_exactly_the_three_fields() -> None: + """The helper reads EXACTLY the three driving fields (`` + surface`` / ``brand`` / ``brand_ink`` — the artwork's color + roles: hexagon fill, hexagon stroke + center dot, 4 spokes) — + a fourth field, or a missing one, fails the pin (the other 14 + picks never touch the link; the 8 semantic state colors are + NOT in the artwork — a favicon has no state meaning). The + binding's gate (``FAVICON_DRIVERS``) is the same set, in the + artwork's role order.""" + js = _js() + start = js.index("const FAVICON_DRIVERS = [") + end = js.index("]", start) + drivers = re.findall(r'"([a-z_]+)"', js[start:end]) + assert drivers == ["surface", "brand", "brand_ink"] + # The live set: the helper call reads EXACTLY those three + # inputs' CURRENT values (rebuilt, not patched — a surface + # pick repaints with the current brand / brand_ink picks). + # The live call (the helper definition precedes it — search past + # the restore function's definition to land on the call site). + call = js.index("themeFaviconDataUri(", js.index("function restoreFavicon()")) + close = js.index(")", call + len("themeFaviconDataUri(")) + names = re.findall(r"inputs\.(\w+)\.value", js[call:close]) + assert names == ["surface", "brand", "brand_ink"] + + +def test_favicon_mirror_matches_the_server_artwork() -> None: + """The client-side mirror's artwork constants match the + SERVER's (``app/core/theming.py``'s ``favicon_svg`` source — + the ``PAIRS``-mirror house pattern, the divergence guard): + the ``viewBox``, BOTH path ``d``-strings (the hexagon, the + spokes), and the circle ``r`` are extracted from each side's + source and compared — a change on either side fails the pin.""" + js = _js() + py = THEMING_PY.read_text(encoding="utf-8") + py_start = py.index("def favicon_svg(") + py_end = py.index("\ndef ", py_start) + py_art = py[py_start:py_end] + + class _ArtConstants(TypedDict): + viewBox: str + r: str + d: list[str] + + def _art_constants(text: str) -> _ArtConstants: + viewbox = re.search(r'viewBox="([^"]+)"', text) + circle_r = re.search(r'r="([^"]+)"', text) + assert viewbox is not None, "the viewBox constant is missing" + assert circle_r is not None, "the circle r constant is missing" + return { + "viewBox": viewbox.group(1), + "r": circle_r.group(1), + "d": re.findall(r'd="([^"]+)"', text), + } + + js_constants = _art_constants(_favicon_helper(js)) + py_constants = _art_constants(py_art) + assert js_constants == py_constants, ( + f"the client mirror diverged from the server: {js_constants} " + f"vs {py_constants}" + ) + # Sanity: BOTH path d-strings (hexagon + spokes) are present. + assert len(js_constants["d"]) == 2 + assert js_constants["viewBox"] == "0 0 64 64" + + +def test_favicon_restore_is_the_route_url() -> None: + """The restore sends the href back to EXACTLY + ``/favicon.svg`` — the themed route (the settled palette + always wins; the route re-reads the DB per request, so the + restored URL is always fresh).""" + js = _js() + start = js.index("function restoreFavicon()") + end = js.index("}\n", start) + body = js[start:end] + assert re.findall(r'href\s*=\s*"([^"]*)"', body) == ["/favicon.svg"] + + +def test_favicon_restore_wired_to_the_four_settle_points() -> None: + """The restore is wired to ALL four settle points — and to NO + other point (the count pin: the one definition + EXACTLY four + call sites): + + * ``saveTheme()`` success — AFTER the + ``applyServedTheme`` reconcile (in the refetch-success + branch — a failed refetch keeps the ``data:`` preview on + purpose: the picks ARE the saved palette); + * ``resetTheme()`` success — AFTER the reconcile; + * the ``resetTheme()`` FAILURE path — AFTER + ``clearPreview()`` (the picks are stale); + * the ``bor:view-refresh`` re-show — AFTER its + ``applyServedTheme``. + """ + js = _js() + assert js.count("restoreFavicon()") == 5 # definition + 4 call sites + + def region(start_marker: str, end_marker: str) -> str: + s = js.index(start_marker) + e = js.index(end_marker, s + len(start_marker)) + return js[s:e] + + # Save success: after the reconcile. + save = region("async function saveTheme", "async function resetTheme") + assert save.count("restoreFavicon()") == 1 + assert save.index("applyServedTheme(settings)") < save.index("restoreFavicon()") + # Reset: the success branch (after the reconcile) AND the + # failure path (after clearPreview — the picks are stale). + reset = region( + "async function resetTheme", 'root.addEventListener("bor:view-refresh"' + ) + assert reset.count("restoreFavicon()") == 2 + first = reset.index("restoreFavicon()") + second = reset.index("restoreFavicon()", first + 1) + assert reset.index("applyServedTheme(settings)") < first + assert reset.index("clearPreview()") < second + # The re-show (the phase-77 hook) — after its reconcile. + refresh = region( + 'root.addEventListener("bor:view-refresh"', + "const settings = await loadSettings()", + ) + assert refresh.count("restoreFavicon()") == 1 diff --git a/tests/unit/test_theming.py b/tests/unit/test_theming.py index 6946222..356313f 100644 --- a/tests/unit/test_theming.py +++ b/tests/unit/test_theming.py @@ -29,12 +29,21 @@ DB-over-built-in resolver shared by ``/api/ui-settings`` and ellipsis. The :root 17-variable set itself stays pinned by ``test_builtin_colors_match_styles_css_root`` (UNCHANGED — this phase adds no palette variable, B3). +* Phase 177 (task 01): ``favicon_svg`` — the pure palette→artwork + renderer, byte-identical for the built-in palette (the golden + constant ``FAVICON_GOLDEN`` holds the pre-phase static file's + ```` element — the file is deleted in task 03, so the + constant is the durable contract); the three color roles per A1 + (hexagon fill → ``surface``, hexagon stroke + center dot → + ``brand``, spokes → ``brand_ink``); the 8 semantic state colors + are NOT in the artwork (a favicon has no state meaning). """ from __future__ import annotations import base64 import hashlib import re +import xml.etree.ElementTree as ET from pathlib import Path from typing import Any @@ -572,3 +581,107 @@ def test_cell_clip_ellipsis_pin() -> None: f".cell-clip white-space drifted: {rule.get('white-space')!r} — " f"the ellipsis needs the nowrap" ) + + +# --------------------------------------------------------------------------- +# Phase 177 (task 01): favicon_svg — the pure palette→artwork renderer, +# byte-identical for the built-in palette (A1). The GOLDEN constant is +# the durable contract: it holds the pre-phase static file's ```` +# element (the file's comment header is file packaging, not art, and is +# NOT part of the contract; the file itself is deleted in task 03). +# --------------------------------------------------------------------------- + +#: The pre-phase ``frontend/assets/favicon.svg`` ```` element — +#: hexagon + center dot + 4 spokes, ``viewBox 0 0 64 64``, lowercase +#: hex, original attribute order. ``favicon_svg(BUILTIN_COLORS)`` must +#: equal this EXACTLY (341 bytes, single line, no comment header). +FAVICON_GOLDEN = ( + '' + '' + '' + '' + "" +) + + +def _mask_color_attrs(svg_text: str) -> str: + """The document with its EXACTLY FOUR color attributes (the + ``fill="`` / ``stroke="`` values) masked — the rest (geometry, + ``d`` paths, circle ``cx/cy/r``, stroke widths, ``linejoin`` / + ``linecap``, attribute order) is then comparable byte for byte. + (``stroke-width`` / ``stroke-linejoin`` / ``stroke-linecap`` never + match — they carry ``-`` before the ``="``.)""" + return re.sub(r'(fill|stroke)="#[0-9a-f]{6}"', r'\1="COLOR"', svg_text) + + +def test_favicon_svg_builtins_is_byte_identical_to_the_static_art() -> None: + """The byte-identical contract (D1): for the built-in palette the + output is EXACTLY the static file's ```` element — lowercase + hex, same attribute order — pinned by the golden constant (the + durable contract; the static file is deleted in task 03, so the + constant, not the file, keeps the bytes alive).""" + out = theming.favicon_svg(dict(theming.BUILTIN_COLORS)) + assert out == FAVICON_GOLDEN + # Single-line element, no comment header (the file's comment is + # file packaging, not art), exact extent. + assert "\n" not in out + assert out.startswith('") + # The three built-in role values land in the golden's roles. + assert 'fill="#1a0f0f"' in out # surface + assert out.count('"#f43f5e"') == 2 # brand (hexagon stroke + circle) + assert 'stroke="#fca5a5"' in out # brand_ink + + +def test_favicon_svg_themed_carries_the_three_roles() -> None: + """A palette with distinct ``surface``/``brand``/``brand_ink`` + (plus the other 14 built-ins): the three hexes appear in their + exact attribute roles — asserted by PARSING the SVG attributes + (not by substring) — and none of the other 14 built-in hexes + appears anywhere in the output.""" + colors = dict(theming.BUILTIN_COLORS) + colors["surface"] = "#102030" + colors["brand"] = "#4f46e5" + colors["brand_ink"] = "#a5b4fc" + out = theming.favicon_svg(colors) + ns = "{http://www.w3.org/2000/svg}" + root = ET.fromstring(out) + assert root.tag == f"{ns}svg" + assert root.get("viewBox") == "0 0 64 64" + hexagon, circle, spokes = list(root) + # Hexagon: fill ← surface, stroke ← brand (geometry unchanged). + assert hexagon.tag == f"{ns}path" + assert hexagon.get("d") == "M32 4 55 18v28L32 60 9 46V18Z" + assert hexagon.get("fill") == "#102030" + assert hexagon.get("stroke") == "#4f46e5" + # Center dot: fill ← brand. + assert circle.tag == f"{ns}circle" + assert circle.get("fill") == "#4f46e5" + # Spokes: stroke ← brand_ink. + assert spokes.tag == f"{ns}path" + assert spokes.get("stroke") == "#a5b4fc" + # None of the other 14 built-in hexes appears in the output. + for key, value in theming.BUILTIN_COLORS.items(): + if key not in ("surface", "brand", "brand_ink"): + assert value not in out, ( + f"built-in {key} value {value} must not appear in a themed favicon" + ) + + +def test_favicon_svg_geometry_is_invariant() -> None: + """Geometry invariance: with a themed palette, everything EXCEPT + the four color attributes is byte-equal to the built-in output + (viewBox, both path ``d``s, circle ``cx/cy/r``, stroke widths, + ``linejoin``/``linecap``, attribute order — the mask leaves only + the four ``fill="…"`` / ``stroke="…"`` values to differ).""" + colors = dict(theming.BUILTIN_COLORS) + colors["surface"] = "#102030" + colors["brand"] = "#4f46e5" + colors["brand_ink"] = "#a5b4fc" + themed = theming.favicon_svg(colors) + builtin = theming.favicon_svg(dict(theming.BUILTIN_COLORS)) + assert _mask_color_attrs(themed) == _mask_color_attrs(builtin) + # Sanity: the mask masked exactly the four color attributes. + assert _mask_color_attrs(builtin).count('"COLOR"') == 4