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,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)
|
||||
Reference in New Issue
Block a user