phase: 177_themed_favicon
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.
This commit is contained in:
@@ -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.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ok
|
||||||
@@ -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
|
||||||
@@ -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 `<svg>` 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).
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
ok
|
||||||
+136
@@ -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
|
||||||
@@ -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`
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ok
|
||||||
@@ -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
|
||||||
@@ -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 `<link rel="icon" type="image/svg+xml" href="/favicon.svg">` — nothing else touched in any template
|
||||||
|
- `frontend/assets/favicon.svg` deleted (route is now the single source; bytes stay pinned by task 01's `FAVICON_GOLDEN`)
|
||||||
|
- `tests/integration/test_favicon_route.py` extended with the task-03 pins: 5 parametrized served-page tests (exact bare link present, no `/assets/favicon.svg`, no `?v=` against regex drift), deleted-file test (on-disk gone + `GET /assets/favicon.svg` 404 + `GET /favicon.svg` still golden), `frontend/` tree scan (house `rglob` idiom) asserting zero `/assets/favicon.svg` references
|
||||||
|
- **Defect fixed:** the `Containerfile` stage-1 `cp ./assets/favicon.svg` would have broken the production image build once the file was deleted — removed the line (the hermetic `test_containerfile_assets.py` pins stay green)
|
||||||
|
- **Tests/lint/coverage:** `uv run pytest tests/integration/test_favicon_route.py -v` → 14 passed; `uv run pytest --cov=app --cov-report=term-missing` → 4155 passed, `app/` 99%; `uv run ruff check .` clean; `uv run pyright` 0 errors; targeted `test_containerfile_assets`/`test_caching_revalidation`/`test_api` → 66 passed
|
||||||
|
- **Docs:** no changes needed — README's "Customizing the look" (task 02) already describes the route and carries no static-file reference; AGENTS.md has no favicon mention
|
||||||
|
- **Decisions:** Containerfile `cp` removal is a direct consequence of the single-source deletion (no phase-design conflict — it's not in the "NOT touched" list and leaving it broke the build); live browser proof deferred to task 04's E2E per the task file; no live-infrastructure changes
|
||||||
|
- **Next pending task:** `.agents/phases/todo/177_themed_favicon/04_theme_tab_live_preview.md`
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
ok
|
||||||
+136
@@ -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%]
|
||||||
|
........................................................................ [ 45%]
|
||||||
|
........................................................................ [ 46%]
|
||||||
|
........................................................................ [ 48%]
|
||||||
|
........................................................................ [ 50%]
|
||||||
|
........................................................................ [ 51%]
|
||||||
|
........................................................................ [ 53%]
|
||||||
|
........................................................................ [ 55%]
|
||||||
|
........................................................................ [ 57%]
|
||||||
|
........................................................................ [ 58%]
|
||||||
|
........................................................................ [ 60%]
|
||||||
|
........................................................................ [ 62%]
|
||||||
|
........................................................................ [ 64%]
|
||||||
|
........................................................................ [ 65%]
|
||||||
|
........................................................................ [ 67%]
|
||||||
|
........................................................................ [ 69%]
|
||||||
|
........................................................................ [ 71%]
|
||||||
|
........................................................................ [ 72%]
|
||||||
|
........................................................................ [ 74%]
|
||||||
|
........................................................................ [ 76%]
|
||||||
|
........................................................................ [ 77%]
|
||||||
|
........................................................................ [ 79%]
|
||||||
|
........................................................................ [ 81%]
|
||||||
|
........................................................................ [ 83%]
|
||||||
|
........................................................................ [ 84%]
|
||||||
|
........................................................................ [ 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
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
**Report — Phase 177, Task 04 (theme-tab live preview + dedicated E2E + commit)**
|
||||||
|
|
||||||
|
**Implemented**
|
||||||
|
- `theme.js`: pure `themeFaviconDataUri()` (client-side mirror of `theming.favicon_svg`, `isHex` guard → `null` leaves the link untouched), `FAVICON_DRIVERS` gate in the per-color `input` binding (rebuilt from the three inputs' current values; the other 14 picks never touch the link), `restoreFavicon()` → `"/favicon.svg"` wired to all four settle points (Save success, Reset success, Reset-failure `clearPreview()` path, `bor:view-refresh` re-show); CSP (phase-123 carve-out) + mechanism documented in the file-header docstring
|
||||||
|
- `tests/unit/test_theme_frontend.py`: 7 new pins (mechanism, guard, exactly-three driving set, mirror artwork constants vs server source, exact `/favicon.svg` restore, four-point wiring + count pin)
|
||||||
|
- `tests/e2e/test_themed_favicon.py`: new isolated Playwright suite — all six contract blocks + zero-external/CSP guard, following the phase-92 fixture idiom
|
||||||
|
|
||||||
|
**Results**
|
||||||
|
- `uv run pytest tests/unit/test_theme_frontend.py -v` — 13 passed
|
||||||
|
- `uv run pytest tests/e2e/test_themed_favicon.py -v --no-cov` (isolated) — 6/6 passed
|
||||||
|
- `test_theme_save_and_coverage.py` — 3/3; `test_theme_semantic_completion.py` — 8/8 (isolated, no regression)
|
||||||
|
- `uv run pytest --cov=app` — 4161 passed, `app/` 99% (>90%); `uv run ruff check . && uv run pyright` — clean
|
||||||
|
|
||||||
|
**Docs** — README.md: one sentence added to "Customizing the look" (live picks repaint the favicon via `data:` URI; Save/Reset/re-show restore the route URL). AGENTS.md: no impact.
|
||||||
|
|
||||||
|
**Decisions/deviations** — headless Chromium never fetches favicons (probe-verified: zero request events, not even the initial `/favicon.svg`), so the CSP-legality proof is the negative guard (no failed `data:` request, no CSP console violation) plus the href state + decoded-art assertions. Commit deferred to the harness (one atomic per phase, per protocol).
|
||||||
|
|
||||||
|
**Next pending task** — none; task 04 is the phase's final task (phase 177 complete).
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
ok
|
||||||
+136
@@ -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
|
||||||
@@ -24,7 +24,6 @@ RUN mkdir -p /out/assets \
|
|||||||
&& esbuild ./assets/brand.js --minify --outfile=/out/assets/brand.js \
|
&& esbuild ./assets/brand.js --minify --outfile=/out/assets/brand.js \
|
||||||
&& esbuild ./assets/markdown.js --minify --outfile=/out/assets/markdown.js \
|
&& esbuild ./assets/markdown.js --minify --outfile=/out/assets/markdown.js \
|
||||||
&& esbuild ./assets/styles.css --minify --outfile=/out/assets/styles.css \
|
&& esbuild ./assets/styles.css --minify --outfile=/out/assets/styles.css \
|
||||||
&& cp ./assets/favicon.svg /out/assets/favicon.svg \
|
|
||||||
&& cp ./index.html ./document.html ./login.html ./shared.html ./doc-edit.html /out/
|
&& cp ./index.html ./document.html ./login.html ./shared.html ./doc-edit.html /out/
|
||||||
|
|
||||||
# ---------- Stage 2: python dependencies ----------
|
# ---------- Stage 2: python dependencies ----------
|
||||||
|
|||||||
@@ -1275,7 +1275,15 @@ admin-only — phase 91): the 17 palette colors (the 9 identity colors plus
|
|||||||
the 8 semantic state colors — success/error/accent families) and the
|
the 8 semantic state colors — success/error/accent families) and the
|
||||||
three strings above are edited with text fields and color pickers,
|
three strings above are edited with text fields and color pickers,
|
||||||
persisted in the `ui_settings` table, and injected into every served page
|
persisted in the `ui_settings` table, and injected into every served page
|
||||||
as an inline `<style>` BEFORE first paint (no red flash, no pop-in). The old
|
as an inline `<style>` BEFORE first paint (no red flash, no pop-in). The
|
||||||
|
browser-tab **favicon** follows the same effective palette: `GET
|
||||||
|
/favicon.svg` renders the hexagon art from the saved colors on every
|
||||||
|
request (no process cache — a theme change is visible on the next
|
||||||
|
request, no restart) and degrades to the built-in art with 200 on a
|
||||||
|
DB blip (never 500). In the Theme tab, a live (unsaved) pick of
|
||||||
|
`surface` / `brand` / `brand_ink` repaints the favicon in real time
|
||||||
|
(a CSP-legal `data:` URI), and Save / Reset / re-show restore the
|
||||||
|
`/favicon.svg` route — the settled palette always wins. The old
|
||||||
CSS-file theming is retired — a leftover `BOR_THEME` line in a
|
CSS-file theming is retired — a leftover `BOR_THEME` line in a
|
||||||
deployment's `.env` is simply ignored. Leave everything unset and the
|
deployment's `.env` is simply ignored. Leave everything unset and the
|
||||||
app renders the built-in dark-tech palette byte-identically.
|
app renders the built-in dark-tech palette byte-identically.
|
||||||
|
|||||||
@@ -177,6 +177,55 @@ def effective_settings(
|
|||||||
return effective
|
return effective
|
||||||
|
|
||||||
|
|
||||||
|
def favicon_svg(colors: dict[str, str]) -> str:
|
||||||
|
"""Render the browser-tab favicon artwork from the effective palette.
|
||||||
|
|
||||||
|
Phase 177 (task 01, A1 — owner-confirmed): the pure half of the
|
||||||
|
themed favicon. ``GET /favicon.svg`` (task 02) serves this, and
|
||||||
|
``frontend/assets/theme.js``'s ``themeFaviconDataUri`` (task 04)
|
||||||
|
is the client-side mirror — the two must NEVER diverge (the
|
||||||
|
unit pins here keep them aligned). The artwork is the SAME art as
|
||||||
|
the pre-phase static ``frontend/assets/favicon.svg`` (a hexagon +
|
||||||
|
center dot + 4 spokes, ``viewBox 0 0 64 64``); only the THREE
|
||||||
|
color roles swap, per the A1 mapping:
|
||||||
|
|
||||||
|
=========================== ===================
|
||||||
|
SVG attribute Palette role
|
||||||
|
=========================== ===================
|
||||||
|
hexagon path ``fill`` ``surface``
|
||||||
|
hexagon path ``stroke`` ``brand``
|
||||||
|
center circle ``fill`` ``brand``
|
||||||
|
spokes path ``stroke`` ``brand_ink``
|
||||||
|
=========================== ===================
|
||||||
|
|
||||||
|
**Byte-identical contract:** for the built-in palette (the three
|
||||||
|
role keys carrying their :data:`BUILTIN_COLORS` values) the output
|
||||||
|
is EXACTLY the static file's ``<svg>`` element — lowercase hex,
|
||||||
|
same attribute order (pinned by the golden constant in
|
||||||
|
``tests/unit/test_theming.py``; the static file's comment header
|
||||||
|
is file packaging, not art, and is NOT part of the contract).
|
||||||
|
The input is the full 20-value effective dict (mirroring
|
||||||
|
:func:`theme_style_tag`'s self-contained signature); the function
|
||||||
|
reads its three keys. Pure: dict in → str out (no DB, no app, no
|
||||||
|
defensive branches — the :func:`effective_settings` /
|
||||||
|
:data:`BUILTIN_COLORS` contract guarantees the values are
|
||||||
|
canonical lowercase 6-hex).
|
||||||
|
|
||||||
|
**No state colors:** the 8 semantic state colors are NOT in the
|
||||||
|
artwork — a favicon has no state meaning (the state families
|
||||||
|
encode *states* on the page; the tab icon is identity only).
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">'
|
||||||
|
f'<path d="M32 4 55 18v28L32 60 9 46V18Z" fill="{colors["surface"]}"'
|
||||||
|
f' stroke="{colors["brand"]}" stroke-width="4" stroke-linejoin="round"/>'
|
||||||
|
f'<circle cx="32" cy="32" r="6.5" fill="{colors["brand"]}"/>'
|
||||||
|
f'<path d="M32 25.5V16M32 48v-9.5M25.5 32H16M48 32h-9.5"'
|
||||||
|
f' stroke="{colors["brand_ink"]}" stroke-width="3" stroke-linecap="round"/>'
|
||||||
|
"</svg>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def theme_style_tag(colors: dict[str, str]) -> str:
|
def theme_style_tag(colors: dict[str, str]) -> str:
|
||||||
"""The pre-paint inline theme tag (task 02's injection input).
|
"""The pre-paint inline theme tag (task 02's injection input).
|
||||||
|
|
||||||
|
|||||||
+65
-1
@@ -18,7 +18,7 @@ from pathlib import Path
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from starlette.middleware.sessions import SessionMiddleware
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
from starlette.responses import FileResponse
|
from starlette.responses import FileResponse, Response
|
||||||
|
|
||||||
from app.api.admin_settings import router as admin_settings_router
|
from app.api.admin_settings import router as admin_settings_router
|
||||||
from app.api.auth import router as auth_router
|
from app.api.auth import router as auth_router
|
||||||
@@ -45,6 +45,7 @@ from app.api.sync import router as sync_router
|
|||||||
from app.api.tokens import router as tokens_router
|
from app.api.tokens import router as tokens_router
|
||||||
from app.api.ui_settings import router as ui_settings_router
|
from app.api.ui_settings import router as ui_settings_router
|
||||||
from app.config import get_settings, invalidate_settings
|
from app.config import get_settings, invalidate_settings
|
||||||
|
from app.core import theming
|
||||||
from app.core.auth import ensure_admin_configured
|
from app.core.auth import ensure_admin_configured
|
||||||
from app.core.caching import configure_caching
|
from app.core.caching import configure_caching
|
||||||
from app.core.compression import SelectiveGZipMiddleware
|
from app.core.compression import SelectiveGZipMiddleware
|
||||||
@@ -55,6 +56,7 @@ from app.core.security_headers import SecurityHeadersMiddleware
|
|||||||
from app.core.settings_migrate import migrate_legacy_docs_creds
|
from app.core.settings_migrate import migrate_legacy_docs_creds
|
||||||
from app.core.settings_overlay import enabled as settings_db_enabled
|
from app.core.settings_overlay import enabled as settings_db_enabled
|
||||||
from app.core.settings_overlay import load_overrides, register_invalidator
|
from app.core.settings_overlay import load_overrides, register_invalidator
|
||||||
|
from app.db import SessionLocal
|
||||||
|
|
||||||
configure_logging()
|
configure_logging()
|
||||||
configure_debugging()
|
configure_debugging()
|
||||||
@@ -96,6 +98,55 @@ def _shell_routes(app: FastAPI, static_dir: Path, paths: tuple[str, ...]) -> Non
|
|||||||
)(_shell_view)
|
)(_shell_view)
|
||||||
|
|
||||||
|
|
||||||
|
def _favicon() -> Response:
|
||||||
|
"""Phase 177 (task 02, D2 — A2/A3 owner-confirmed): the themed
|
||||||
|
favicon over the wire.
|
||||||
|
|
||||||
|
Renders :func:`app.core.theming.favicon_svg` from the EFFECTIVE
|
||||||
|
palette on EVERY request — one short-lived ``SessionLocal()``
|
||||||
|
session, the ``caching.py`` middleware's house pattern: NO process
|
||||||
|
cache, so a runtime theme change (the admin Theme tab's Save) is
|
||||||
|
visible on the next request without a restart (a single-row SELECT
|
||||||
|
is negligible at homelab traffic). ANY exception (a DB blip, a
|
||||||
|
pre-migration boot) falls back to the BUILT-IN palette with 200 —
|
||||||
|
never 500 (the theme-tag's never-break contract, ``caching.py``'s
|
||||||
|
DB-blip fallback): the tab icon degrades to the built-in art, the
|
||||||
|
app never errors on it.
|
||||||
|
|
||||||
|
Deliberately OUTSIDE ``/api/*``: no per-response byte-budget line
|
||||||
|
(AGENTS rule 10 is ``/api/*``-only) and NO auth dependency — the
|
||||||
|
favicon is chrome, public like the login page.
|
||||||
|
``CachingMiddleware`` passes the response through untouched
|
||||||
|
(``/favicon.svg`` is neither in ``HTML_PAGES`` nor under
|
||||||
|
``/assets/``) → the route's own ``Cache-Control: no-cache`` is the
|
||||||
|
freshness contract (the palette changes at runtime; the pages are
|
||||||
|
no-cache too, so the browser revalidates the favicon together with
|
||||||
|
them — freshness is revalidation, not a ``?v=`` bust). The route is
|
||||||
|
not under ``/assets/``, so its ``<link>`` ref can never be touched
|
||||||
|
by the ``?v=`` rewrite. ``SecurityHeadersMiddleware`` still adds the
|
||||||
|
CSP — no change needed (the phase-123 ``img-src 'self' data:``
|
||||||
|
carve-out covers task 04's ``data:`` preview; CSP3 governs
|
||||||
|
``link[rel=icon]`` by ``img-src``).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
effective = theming.effective_settings(db)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
svg = theming.favicon_svg(effective)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"favicon: theme read failed — serving the built-in favicon"
|
||||||
|
)
|
||||||
|
svg = theming.favicon_svg(theming.BUILTIN_COLORS)
|
||||||
|
return Response(
|
||||||
|
svg,
|
||||||
|
media_type="image/svg+xml",
|
||||||
|
headers={"Cache-Control": "no-cache"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> FastAPI:
|
def create_app() -> FastAPI:
|
||||||
# Fail loud BEFORE serving anything (phase 16): missing
|
# Fail loud BEFORE serving anything (phase 16): missing
|
||||||
# BOR_ADMIN_PASSWORD / BOR_SESSION_SECRET raises at boot, naming the
|
# BOR_ADMIN_PASSWORD / BOR_SESSION_SECRET raises at boot, naming the
|
||||||
@@ -189,6 +240,19 @@ def create_app() -> FastAPI:
|
|||||||
app.include_router(chats_public_router, prefix="/api")
|
app.include_router(chats_public_router, prefix="/api")
|
||||||
app.include_router(chats_shared_page_router) # no prefix — /shared/<token>
|
app.include_router(chats_shared_page_router) # no prefix — /shared/<token>
|
||||||
|
|
||||||
|
# Phase 177 (task 02, D2 — A2/A3 owner-confirmed): the themed
|
||||||
|
# favicon route — registered AFTER the router includes and BEFORE
|
||||||
|
# the static catch-all mount (routes-first, the phase-51/76
|
||||||
|
# pattern): a file named favicon.svg in the static dir could never
|
||||||
|
# shadow it. HEAD joins GET for static-file parity (the
|
||||||
|
# phase-76 ``_shell_routes`` parity idiom — the pre-phase static
|
||||||
|
# file answered both methods, the dynamic route keeps that).
|
||||||
|
# See the handler docstring for the never-break DB-blip fallback
|
||||||
|
# and the /api/*-out rationale.
|
||||||
|
app.api_route(
|
||||||
|
"/favicon.svg", methods=["GET", "HEAD"], include_in_schema=False
|
||||||
|
)(_favicon)
|
||||||
|
|
||||||
# Cache busting (phase 33): the five HTML pages revalidate (no-cache)
|
# Cache busting (phase 33): the five HTML pages revalidate (no-cache)
|
||||||
# with ?v=<token> asset refs; /assets/* becomes immutable for a year.
|
# with ?v=<token> asset refs; /assets/* becomes immutable for a year.
|
||||||
# Added after the session middleware, so it wraps the whole app
|
# Added after the session middleware, so it wraps the whole app
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
<!-- Favicon — served statically (AGENTS.md rule 6) instead of a
|
|
||||||
data: URI: the phase-82 strict CSP (default-src 'self') blocks
|
|
||||||
data: images, and the audit's "no external hosts" verification
|
|
||||||
had missed this one. Byte-identical artwork, extracted from the
|
|
||||||
former <link rel="icon" href="data:image/svg+xml,…"> (2026-09-08,
|
|
||||||
phase 82 task 03 — the E2E CSP gate caught the violation). -->
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path d="M32 4 55 18v28L32 60 9 46V18Z" fill="#1a0f0f" stroke="#f43f5e" stroke-width="4" stroke-linejoin="round"/><circle cx="32" cy="32" r="6.5" fill="#f43f5e"/><path d="M32 25.5V16M32 48v-9.5M25.5 32H16M48 32h-9.5" stroke="#fca5a5" stroke-width="3" stroke-linecap="round"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 757 B |
@@ -30,6 +30,42 @@
|
|||||||
* effect: the 3 strings keep the brand.js runtime application
|
* effect: the 3 strings keep the brand.js runtime application
|
||||||
* (owner-locked B4) — they apply via the /api/config boot fetch on
|
* (owner-locked B4) — they apply via the /api/config boot fetch on
|
||||||
* the NEXT page load, and the sub-copy says so.
|
* the NEXT page load, and the sub-copy says so.
|
||||||
|
* • favicon live preview (phase 177, A4 — owner-confirmed
|
||||||
|
* 2026-10-13) — a pick of one of the THREE driving fields
|
||||||
|
* (``surface`` / ``brand`` / ``brand_ink`` — the artwork's
|
||||||
|
* color roles: hexagon fill, hexagon stroke + center dot,
|
||||||
|
* 4 spokes) ALSO repaints the tab icon: the
|
||||||
|
* ``link[rel="icon"]`` href is set to
|
||||||
|
* ``themeFaviconDataUri(...)`` — a ``data:image/svg+xml,``
|
||||||
|
* URI (``encodeURIComponent``-ed) whose SVG is the
|
||||||
|
* CLIENT-SIDE MIRROR of ``app.core.theming.favicon_svg``
|
||||||
|
* (the same artwork, the same roles —
|
||||||
|
* ``tests/unit/test_theme_frontend.py`` keeps the two from
|
||||||
|
* diverging: the artwork constants are pinned against the
|
||||||
|
* server source, the ``PAIRS``-mirror house pattern).
|
||||||
|
* Rebuilt, not patched: a ``surface`` pick repaints with the
|
||||||
|
* CURRENT ``brand`` / ``brand_ink`` picks (the helper reads
|
||||||
|
* the three inputs' values). 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; the house "never build HTML" rule binds the DOM,
|
||||||
|
* the URI is built from constants + validated hexes) returns
|
||||||
|
* ``null`` on a non-hex input and the link is left
|
||||||
|
* untouched. The other 14 picks never touch the link.
|
||||||
|
* RESTORE TO THE ROUTE — the settled palette always wins: on
|
||||||
|
* every SETTLED read (Save success, after the
|
||||||
|
* ``applyServedTheme`` reconcile; Reset success, after the
|
||||||
|
* reconcile; the Reset-failure ``clearPreview()`` path — the
|
||||||
|
* picks are stale; the ``bor:view-refresh`` re-show, after
|
||||||
|
* its ``applyServedTheme``) the href goes back to
|
||||||
|
* ``"/favicon.svg"`` — the route re-reads the DB per request
|
||||||
|
* (NO process cache), so the restored URL is always fresh,
|
||||||
|
* and the page is ``no-cache``, so the browser revalidates
|
||||||
|
* the favicon with it. CSP (phase 123, per D4): ``data:`` is
|
||||||
|
* legal for the favicon via the ``img-src 'self' data:``
|
||||||
|
* carve-out — CSP3 governs ``link[rel=icon]`` by
|
||||||
|
* ``img-src``, and the carve-out is image-scoped (``data:``
|
||||||
|
* never becomes a script/style/fetch source).
|
||||||
* • served-theme sync (phase 92, defect 1) — the phase-91 defect:
|
* • served-theme sync (phase 92, defect 1) — the phase-91 defect:
|
||||||
* Save/Reset removed the preview overrides, and the page then
|
* Save/Reset removed the preview overrides, and the page then
|
||||||
* fell back to the <style id="bor-theme"> tag baked into THIS
|
* fell back to the <style id="bor-theme"> tag baked into THIS
|
||||||
@@ -124,6 +160,10 @@ export async function mount(root) {
|
|||||||
const errorEl = root.querySelector("#theme-error");
|
const errorEl = root.querySelector("#theme-error");
|
||||||
const resultEl = root.querySelector("#theme-result");
|
const resultEl = root.querySelector("#theme-result");
|
||||||
const contrastEl = root.querySelector("#theme-contrast");
|
const contrastEl = root.querySelector("#theme-contrast");
|
||||||
|
/* The tab icon's link (a document-level element, not in the
|
||||||
|
view — the favicon live preview writes its href; phase 177,
|
||||||
|
A4). */
|
||||||
|
const faviconLink = document.querySelector('link[rel="icon"]');
|
||||||
|
|
||||||
const SAVE_LABEL = "Save theme";
|
const SAVE_LABEL = "Save theme";
|
||||||
const RESET_LABEL = "Reset to defaults";
|
const RESET_LABEL = "Reset to defaults";
|
||||||
@@ -306,6 +346,53 @@ export async function mount(root) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- favicon live preview (phase 177 — A4) ----------
|
||||||
|
* The tab icon is the CLIENT-SIDE MIRROR of the server's
|
||||||
|
* favicon: app/core/theming.py's favicon_svg is the
|
||||||
|
* authoritative renderer — the SAME artwork (a hexagon +
|
||||||
|
* center dot + 4 spokes, viewBox 0 0 64 64), the SAME three
|
||||||
|
* color roles (hexagon fill ← surface, hexagon stroke +
|
||||||
|
* circle fill ← brand, spokes stroke ← brand_ink). The two
|
||||||
|
* must never diverge: tests/unit/test_theme_frontend.py
|
||||||
|
* pins this file's artwork constants (the viewBox, both path
|
||||||
|
* d-strings, the circle r) against the server's source (the
|
||||||
|
* PAIRS-mirror house pattern). CSP (phase 123, per D4):
|
||||||
|
* data: is legal for the favicon via the img-src 'self'
|
||||||
|
* data: carve-out — CSP3 governs link[rel="icon"] by
|
||||||
|
* img-src, and the carve-out is image-scoped (data: never
|
||||||
|
* becomes a script / style / fetch source). */
|
||||||
|
const FAVICON_DRIVERS = ["surface", "brand", "brand_ink"];
|
||||||
|
|
||||||
|
/* The data: URI for the three driving values — the pure half
|
||||||
|
of the favicon live preview. 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
|
||||||
|
— the house "never build HTML" rule binds the DOM, the URI
|
||||||
|
is built from constants + validated hexes) returns null on
|
||||||
|
a non-hex input: the link is then left untouched. */
|
||||||
|
function themeFaviconDataUri(surface, brand, brandInk) {
|
||||||
|
if (!isHex(surface) || !isHex(brand) || !isHex(brandInk)) return null;
|
||||||
|
const svg =
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">'
|
||||||
|
+ '<path d="M32 4 55 18v28L32 60 9 46V18Z" fill="' + surface
|
||||||
|
+ '" stroke="' + brand + '" stroke-width="4" stroke-linejoin="round"/>'
|
||||||
|
+ '<circle cx="32" cy="32" r="6.5" fill="' + brand + '"/>'
|
||||||
|
+ '<path d="M32 25.5V16M32 48v-9.5M25.5 32H16M48 32h-9.5" stroke="'
|
||||||
|
+ brandInk + '" stroke-width="3" stroke-linecap="round"/>'
|
||||||
|
+ "</svg>";
|
||||||
|
return "data:image/svg+xml," + encodeURIComponent(svg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The settled palette always wins (A4): after every SETTLED
|
||||||
|
read (Save success, Reset success, the Reset-failure
|
||||||
|
clearPreview path, the bor:view-refresh re-show) the href
|
||||||
|
goes back to the route — which re-reads the DB per request
|
||||||
|
(NO process cache, so it is always fresh) and whose
|
||||||
|
no-cache makes the browser revalidate the favicon with it. */
|
||||||
|
function restoreFavicon() {
|
||||||
|
if (faviconLink) faviconLink.href = "/favicon.svg";
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- served-theme sync (phase 92, defect 1) ----------
|
/* ---------- served-theme sync (phase 92, defect 1) ----------
|
||||||
* The phase-91 defect: after a Save/Reset the preview overrides
|
* The phase-91 defect: after a Save/Reset the preview overrides
|
||||||
* were removed and the page fell back to the <style id="bor-theme">
|
* were removed and the page fell back to the <style id="bor-theme">
|
||||||
@@ -500,6 +587,7 @@ export async function mount(root) {
|
|||||||
const settings = await loadSettings(); // refetch + re-populate
|
const settings = await loadSettings(); // refetch + re-populate
|
||||||
if (settings) {
|
if (settings) {
|
||||||
applyServedTheme(settings); // reconcile the open document
|
applyServedTheme(settings); // reconcile the open document
|
||||||
|
restoreFavicon(); // the settled palette governs the tab icon
|
||||||
}
|
}
|
||||||
/* A failed refetch keeps the current <html> overrides on purpose:
|
/* A failed refetch keeps the current <html> overrides on purpose:
|
||||||
the PUT body came from these very inputs, so they ARE the saved
|
the PUT body came from these very inputs, so they ARE the saved
|
||||||
@@ -518,8 +606,10 @@ export async function mount(root) {
|
|||||||
const settings = await loadSettings(); // the env / built-in defaults
|
const settings = await loadSettings(); // the env / built-in defaults
|
||||||
if (settings) {
|
if (settings) {
|
||||||
applyServedTheme(settings); // tag removed + overrides dropped
|
applyServedTheme(settings); // tag removed + overrides dropped
|
||||||
|
restoreFavicon(); // the settled palette governs the tab icon
|
||||||
} else {
|
} else {
|
||||||
clearPreview(); // the reset landed — the picks are stale
|
clearPreview(); // the reset landed — the picks are stale
|
||||||
|
restoreFavicon(); // stale picks — back to the route URL
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -562,6 +652,21 @@ export async function mount(root) {
|
|||||||
input.addEventListener("input", () => {
|
input.addEventListener("input", () => {
|
||||||
previewColor(f.field, input.value);
|
previewColor(f.field, input.value);
|
||||||
updateContrast();
|
updateContrast();
|
||||||
|
/* The favicon live preview (phase 177 — A4): ONLY the
|
||||||
|
three driving fields repaint the tab icon; the other 14
|
||||||
|
picks never touch the link. Rebuilt, not patched — the
|
||||||
|
helper reads the three inputs' CURRENT values, so a
|
||||||
|
surface pick repaints with the current brand /
|
||||||
|
brand_ink picks. A null URI (a non-hex value,
|
||||||
|
defensive) leaves the link untouched. */
|
||||||
|
if (FAVICON_DRIVERS.includes(f.field)) {
|
||||||
|
const uri = themeFaviconDataUri(
|
||||||
|
inputs.surface.value,
|
||||||
|
inputs.brand.value,
|
||||||
|
inputs.brand_ink.value,
|
||||||
|
);
|
||||||
|
if (uri && faviconLink) faviconLink.href = uri;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (saveBtn) saveBtn.addEventListener("click", () => void saveTheme());
|
if (saveBtn) saveBtn.addEventListener("click", () => void saveTheme());
|
||||||
@@ -577,6 +682,7 @@ export async function mount(root) {
|
|||||||
root.addEventListener("bor:view-refresh", () => {
|
root.addEventListener("bor:view-refresh", () => {
|
||||||
void loadSettings().then((settings) => {
|
void loadSettings().then((settings) => {
|
||||||
if (settings) applyServedTheme(settings);
|
if (settings) applyServedTheme(settings);
|
||||||
|
restoreFavicon(); // the settled palette governs the tab icon
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
<meta name="description" content="Edit a saved chat answer before it is committed to the docs repository (admin-only).">
|
<meta name="description" content="Edit a saved chat answer before it is committed to the docs repository (admin-only).">
|
||||||
<title>Edit doc · Brain of Reese</title>
|
<title>Edit doc · Brain of Reese</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||||
<link rel="stylesheet" href="/assets/styles.css">
|
<link rel="stylesheet" href="/assets/styles.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
<meta name="description" content="Read a document indexed in Brain of Reese.">
|
<meta name="description" content="Read a document indexed in Brain of Reese.">
|
||||||
<title>Document · Brain of Reese</title>
|
<title>Document · Brain of Reese</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||||
<link rel="stylesheet" href="/assets/styles.css">
|
<link rel="stylesheet" href="/assets/styles.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
<meta name="description" content="Ask anything about your indexed documents — every answer cites the exact doc.">
|
<meta name="description" content="Ask anything about your indexed documents — every answer cites the exact doc.">
|
||||||
<title>Brain of Reese</title>
|
<title>Brain of Reese</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||||
<link rel="stylesheet" href="/assets/styles.css">
|
<link rel="stylesheet" href="/assets/styles.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
<meta name="description" content="Admin sign-in for Brain of Reese — unlocks the full Sources catalog and answer tuning.">
|
<meta name="description" content="Admin sign-in for Brain of Reese — unlocks the full Sources catalog and answer tuning.">
|
||||||
<title>Sign in · Brain of Reese</title>
|
<title>Sign in · Brain of Reese</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||||
<link rel="stylesheet" href="/assets/styles.css">
|
<link rel="stylesheet" href="/assets/styles.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
<meta name="description" content="A shared Brain of Reese conversation — read-only.">
|
<meta name="description" content="A shared Brain of Reese conversation — read-only.">
|
||||||
<title>Shared conversation · Brain of Reese</title>
|
<title>Shared conversation · Brain of Reese</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||||
<link rel="stylesheet" href="/assets/styles.css">
|
<link rel="stylesheet" href="/assets/styles.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -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 ``</head>`` 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'<style id="bor-theme">:root{{{declarations}}}</style>'
|
||||||
|
|
||||||
|
|
||||||
|
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 (
|
||||||
|
'<link rel="icon" type="image/svg+xml" href="/favicon.svg">'
|
||||||
|
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 ``</head>`` + 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 ``</head>``…
|
||||||
|
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("</head>"), (
|
||||||
|
"the tag must end exactly where </head> 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)
|
||||||
@@ -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 ``<link … href="/favicon.svg">`` (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 = (
|
||||||
|
'<link rel="icon" type="image/svg+xml" href="/favicon.svg">'
|
||||||
|
)
|
||||||
|
|
||||||
|
#: 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)}"
|
||||||
|
)
|
||||||
@@ -32,12 +32,23 @@ frontend sources and asserts on their mechanisms):
|
|||||||
``BUILTIN_COLORS``), never a third hardcoded palette copy;
|
``BUILTIN_COLORS``), never a third hardcoded palette copy;
|
||||||
* the "State colors" fieldset sits AFTER the palette fieldset (the
|
* the "State colors" fieldset sits AFTER the palette fieldset (the
|
||||||
task-02 form shape), and the palette legend's "five pairs" copy is
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import TypedDict
|
||||||
|
|
||||||
from app.core import theming
|
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
|
assert field_id not in palette_body
|
||||||
else:
|
else:
|
||||||
assert field_id in palette_body
|
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
|
||||||
|
|||||||
@@ -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
|
ellipsis. The :root 17-variable set itself stays pinned by
|
||||||
``test_builtin_colors_match_styles_css_root`` (UNCHANGED — this
|
``test_builtin_colors_match_styles_css_root`` (UNCHANGED — this
|
||||||
phase adds no palette variable, B3).
|
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
|
||||||
|
``<svg>`` 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
|
from __future__ import annotations
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
import re
|
import re
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
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".cell-clip white-space drifted: {rule.get('white-space')!r} — "
|
||||||
f"the ellipsis needs the nowrap"
|
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 ``<svg>``
|
||||||
|
# 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`` ``<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 = (
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">'
|
||||||
|
'<path d="M32 4 55 18v28L32 60 9 46V18Z" fill="#1a0f0f" '
|
||||||
|
'stroke="#f43f5e" stroke-width="4" stroke-linejoin="round"/>'
|
||||||
|
'<circle cx="32" cy="32" r="6.5" fill="#f43f5e"/>'
|
||||||
|
'<path d="M32 25.5V16M32 48v-9.5M25.5 32H16M48 32h-9.5" '
|
||||||
|
'stroke="#fca5a5" stroke-width="3" stroke-linecap="round"/>'
|
||||||
|
"</svg>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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 ``<svg>`` 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('<svg xmlns="http://www.w3.org/2000/svg"')
|
||||||
|
assert out.endswith("</svg>")
|
||||||
|
# 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
|
||||||
|
|||||||
Reference in New Issue
Block a user