Files
brain-of-reese/tests/e2e/test_themed_favicon.py
T
ducoterra c6ffa050a6
Build and Push Containers / build-and-push-app (push) Successful in 2m9s
Build and Push Containers / build-and-push-db (push) Successful in 15s
phase: 190_app_name_settings_fix
All gates re-verified green. Report:

- **Verified (all 4 tasks already complete; this was the final pass):** `effective_settings` resolves the 3 brand strings panel-row → tab-row → env (`applied_override_keys`, presence-not-value); `PUT /api/ui-settings` ignores string body fields; Theme-tab form is colors-only (17 pickers, no `#theme-app-name`/`#theme-placeholder`/`#theme-footer`); the ONE shared `applyBrandFromConfig` re-applies title/`.brand-text`/footer/placeholder after a panel save-reset and a tab save-reset; boot path byte-identical (single `/api/config`).
- **Completion criteria:**
  - strings order panel→tab→env, both branches — ✅ `tests/unit/test_theming.py::test_effective_app_name_prefers_settings_panel_row_over_ui_settings_row`, `..._fall_back_to_ui_settings_row_without_panel_row`, `..._per_field_panel_then_tab_then_env`, `..._ignore_panel_rows_when_the_overlay_is_off`
  - PUT ignores string fields + no 3 inputs in the tab — ✅ `tests/integration/test_ui_settings_api.py::test_put_ignores_string_fields_in_body` (+2) and the E2E `test_theme_tab_dom_is_colors_only`
  - live apply without reload + incident regression — ✅ `tests/e2e/test_app_name_settings_fix.py` tests 1–4
  - byte-identical boot, no double-apply — ✅ `tests/unit/test_brand_live.py` markers + E2E "exactly one /api/config"
  - suite green, coverage >90%, ruff+pyright clean — ✅
  - no behavior change in prior phases — ✅ (palette, phase-168 panel, env fallback suites all green)
  - atomic commit — **left to the harness** (pipeline rule overrides per-phase committing; no git write run)
- **Gates:** `uv run pytest --cov=app --cov-report=term-missing` → **4630 passed, TOTAL 6941 16 99%**, exit 0 · `uv run pytest tests/e2e/test_app_name_settings_fix.py -v --no-cov` → **6 passed** in isolation · regression E2E (isolated, one process each, all exit 0): `test_smoke` `test_configurable_brand` `test_admin_theme_tab` `test_theme_save_and_coverage` `test_theme_semantic_completion` `test_themed_favicon` `test_dark_tech_theme` `test_admin_settings_panel` `test_admin_settings_layout` `test_settings_mobile_value_width` · `uv run ruff check .` → All checks passed · `uv run pyright` → 0 errors.
- **Documentation:** README — fixed "saving one repaints *every* open page" (only the open document repaints; other pages on next load) and a broken paragraph wrap in *Customizing the look*; AGENTS.md rule 16 — `test_chunks_hnsw_recall.py` now described accurately (index paths forced with `enable_seqscan`/`indexonlyscan`/`bitmapscan` off, unforced plan asserted only on a pristine relation); `tests/unit/test_theme_frontend.py` — two stale section headers ("20 form fields"/"20 inputs" → 17 color). No AGENTS.md settings-overlay/resolution-order clause exists, so per the phase none was added. Full env-var cross-check (`BOR_*` in `app/config.py` vs README) → 0 gaps; 0 missing file references.
- **Notable:** the working tree also carries this phase's flake hardening of the phase-188 `tests/integration/test_chunks_hnsw_recall.py` (verified legit and green). Live infra untouched; shared DB left clean (0 `app_settings`, 0 `ui_settings`). **Flagged, not changed:** `settings_overlay.applied_override_keys` lets a `ProgrammingError` (missing `app_settings`) propagate from public `GET /api/config`, where `load_overrides` deliberately fails soft — only reachable on an un-migrated (0027) DB, outside this phase's pinned A1 semantics, so left for the owner to rule on rather than silently deviating.
- **Next pending phase:** `191_source_name_labels_tags`.
2026-10-18 02:59:19 -04:00

689 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 _login_to_theme(page: Page, app_url: str) -> None:
"""Log in straight into the Theme view AND wait for its mount-time
``GET /api/ui-settings`` to ANSWER — the settle signal.
Phase 190 (task 02) moved it with the tab's string fieldset: the
settle used to be read off the (now removed) ``#theme-app-name``
input, the only input the static markup shipped empty. The network
is the source of truth now."""
with page.expect_response(
lambda r: "/api/ui-settings" in r.url, timeout=30_000
) as info:
login(page, app_url, next="/theme.html")
assert info.value.status == 200, "the mount's GET /api/ui-settings failed"
expect(page.locator("#theme-content")).to_be_visible(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_to_theme(page, app_url)
# 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_to_theme(page, app_url)
# 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 the 17 form colors — the 3
# driving colors + the 14 built-ins (→ NULL); the brand strings are
# the Settings panel's since phase 190 (A1) and this body carried
# none, so 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_to_theme(page, app_url)
# 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_to_theme(page, app_url)
# 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)