Files
brain-of-reese/tests/e2e/test_themed_favicon.py
T
ducoterra 36eddce672
Build and Push Containers / build-and-push-app (push) Successful in 1m49s
Build and Push Containers / build-and-push-db (push) Successful in 12s
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.
2026-10-11 15:33:39 -04:00

702 lines
29 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 _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)