Files
brain-of-reese/tests/e2e/test_admin_theme_tab.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

836 lines
36 KiB
Python

"""Phase 91 E2E (Playwright): the admin Theme tab — the pickers and
fields, the pre-paint theme, the admin gate, and the reset.
Source: ``TODO.md`` L4 — "Custom theming isn't really working. The
page loads red first and then the theme 'pops' into view, replacing
words and colors in an obvious way. Remove the custom css file
theming. Create a new admin tab that allows the user to change
everything the env var and custom css currently supports but with
buttons and color pickers. Theme should load immediately, not pop in
after the page load."
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_admin_theme_tab.py -v --no-cov
Test → contract mapping (one story, one phase, one isolated file):
1. ``test_theme_tab_admin_save`` — "buttons and color pickers": the
admin sees the "Theme" nav link and the form (gate hidden); the
17 color inputs show the effective defaults (the built-in hexes —
the 9 identity + the 8 semantic state, phase 93 — parsed out of
``styles.css``'s ``:root`` IN-TEST — the suite can never drift from
the stylesheet); Save
runs the §7.4 lifecycle (disabled + "Saving…" while the PUT is
held, then restored) and lands the role=status "Theme saved.";
the inputs re-populate to the saved values; the persisted row is
the one saved; and the saved non-AA palette lists its failing
pair in ``#theme-contrast`` without blocking the save
(warning-only), and the row lands with its 17 colors and NO brand
string (phase 190, A1).
1b. ``test_tab_renders_the_17_colors_and_no_string_inputs`` — the
phase-190 (A1) strip in the browser: the tab's DOM holds exactly the
17 ``type=color`` inputs and NONE of the old string inputs
(``#theme-app-name`` / ``#theme-placeholder`` / ``#theme-footer`` —
count 0), the admin Settings panel being the single writable owner of
the brand strings; a Save posts a body with the 17 colors and no
string key.
2. ``test_saved_theme_is_pre_paint_for_everyone`` — "the theme
should load immediately, not pop in": after a save, the RAW
served HTML of ``/`` carries exactly one ``<style
id="bor-theme">`` with all 17 vars = the saved hexes, placed
IMMEDIATELY before ``</head>`` — for the admin AND a fresh
anonymous context — and the computed ``:root`` custom properties
equal the saved hexes at load (the inline tag precedes every
stylesheet application). Phase 190 (A1) turned the other half of the
old B4 split into a contract: a palette save writes NO brand string,
so the anonymous page still renders the env/template name,
placeholder and footer line after the save (the boot fetch has
nothing new to apply — a theme save can no longer rename the app).
3. ``test_anonymous_and_token_user_are_walled`` — the admin gate:
anonymous meets ``#theme-gate`` (sign-in link
``?next=/theme.html``) with ``#theme-content`` hidden and the
nav link hidden, and ``PUT /api/ui-settings`` 403s; a token user
(the phase-79 gate login) 403s the PUT too and never sees the
nav link (B5: admin-only, like Tuning/Tokens).
4. ``test_reset_restores_the_builtin_byte_identical`` — "reset":
Reset to defaults runs the §7.4 lifecycle ("Resetting…"), lands
the role=status "Reset to the built-in theme.", re-populates the
17 built-in colors, serves NO theme tag, and the served bytes equal
a row-less deployment byte for byte (the no-op injection
contract).
5. ``test_contrast_warning_does_not_block`` — the WCAG warnings:
``--ink`` set within 0.1 ratio of ``--bg`` lists the failing
pair(s) with the ratio in ``#theme-contrast`` (role=alert) as
soon as the picker moves; Save still succeeds (warning-only);
Reset restores the AA built-ins and hides the warning (the
suite's final state is clean).
DB isolation: the shared e2e Postgres keeps ``ui_settings`` (the
single row the caching middleware reads for EVERY served page — a
leftover themed row would repaint other suites' pages) 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).
"""
from __future__ import annotations
import json
import os
import re
import subprocess
import sys
import time
from collections.abc import Iterator
import httpx
import pytest
from playwright.sync_api import Browser, BrowserContext, Page, Route, expect
from sqlalchemy import text
from app.config import Settings
from app.core.theming import COLOR_FIELDS, STRING_FIELDS
from app.db import SessionLocal
from app.models import UiSettings
from e2e.auth_helpers import login, login_with_token
from e2e.conftest import (
ADMIN_PASSWORD,
APP_PORT,
REPO,
SESSION_SECRET,
USE_REAL_LLM,
_wait_http,
)
APP_URL = f"http://127.0.0.1:{APP_PORT}"
# The distinct E2E palette (task 06, extended in phase 92 task 05:
# grid_line — the 9th identity var, distinct from its built-in
# #4a2626 and from line #232a4a; extended in phase 93 with the 8
# semantic state colors, each distinct from its built-in): a full
# non-built-in set over ALL 17 vars — every value differs from its
# built-in, so the tag is non-empty and every saved color is stored
# as-is (no built-in→NULL collapse). The semantic picks pass the
# three new ink-on-bg AA pairs (10.5:1 / 8.2:1 / 9.9:1), so the
# saved palette still fails exactly ONE of the eight pairs —
# --bg on --brand (3.0:1) — the exact-text contrast assertion.
PALETTE: dict[str, str] = {
"bg": "#0b1020",
"surface": "#111730",
"ink": "#e6e9f5",
"ink_soft": "#a8b0d0",
"line": "#232a4a",
"grid_line": "#2b3550",
"brand": "#4f46e5",
"brand_soft": "#1e2447",
"brand_ink": "#c7d2fe",
"ok_bg": "#101c24",
"ok_ink": "#86d9c0",
"err_bg": "#20101c",
"err_ink": "#e896b4",
"err_line": "#d1608c",
"accent_bg": "#1e1a10",
"accent_ink": "#d9c37a",
"accent_line": "#b8963e",
}
# The three brand strings are GONE from this surface (phase 190, A1 —
# the admin Settings panel is their single writable owner), so this
# suite carries no saved-string fixture any more: a palette save writes
# colors only, and every string assertion reads the env/template default.
#: The failing-pair leg (test 5): --ink set within 0.1 ratio of --bg
#: (the deterministic near-identical pick — 1.0:1 on both dark pairs).
FAILING_INK = "#101010"
#: The E2E-stable color-input ids, in COLOR_FIELDS order (the form's
#: own markup — the static E2E-stable-selectors house convention).
COLOR_INPUT_IDS: dict[str, str] = {
field: f"#theme-{field.replace('_', '-')}" for field in COLOR_FIELDS
}
# ---------------------------------------------------------------------------
# In-test constants (single sources of truth — never duplicated)
# ---------------------------------------------------------------------------
def _builtin_colors() -> dict[str, str]:
"""The 17 built-in palette hexes (the 9 identity + the 8
semantic state, phase 93) parsed OUT of
``frontend/assets/styles.css``'s ``:root`` in-test — the single
source of truth, so the suite can't drift from the stylesheet it
asserts on."""
css = (REPO / "frontend" / "assets" / "styles.css").read_text(encoding="utf-8")
root = re.search(r":root\s*\{([^}]*)\}", css, re.DOTALL)
assert root is not None, "styles.css must open with its :root block"
colors: dict[str, str] = {}
for name in COLOR_FIELDS:
match = re.search(
rf"--{name.replace('_', '-')}\s*:\s*(#[0-9a-fA-F]{{6}})", root.group(1)
)
assert match is not None, f"--{name} missing from styles.css :root"
colors[name] = match.group(1).lower()
return colors
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 _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 COLOR_FIELDS)
return f'<style id="bor-theme">:root{{{declarations}}}</style>'
# ---------------------------------------------------------------------------
# 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 reads it for EVERY page — a
leftover themed row would repaint other suites' pages) 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 _seed_theme_via_api(app_url: str, cookies: dict[str, str]) -> None:
"""Admin ``PUT /api/ui-settings`` with the full 17-color palette
(the API seed — the UI save itself is test 1's job).
Phase 190 (A1): no string key in the body — the Settings panel owns
the brand strings and this API ignores them. The seed asserts the
colors round-trip AND that the effective strings are still the
env/template defaults, i.e. a theme save cannot rename the app."""
defaults = _template_defaults()
r = httpx.put(f"{app_url}/api/ui-settings", json=PALETTE, cookies=cookies, timeout=10)
assert r.status_code == 200, r.text
body = r.json()
for field in COLOR_FIELDS:
assert body[field] == PALETTE[field], f"effective {field} != {PALETTE[field]}"
for field, value in defaults.items():
assert body[field] == value, f"{field} must stay the effective default (A1)"
def _hold_theme_puts(page: Page, hold_s: float = 0.6) -> None:
"""Intercept ``PUT /api/ui-settings`` and hold it for
``hold_s`` seconds (the archive-upload suite's §7.4 pattern):
while it is held, the page's fetch is guaranteed pending, so the
in-flight state (disabled buttons, the "Saving…" / "Resetting…"
labels) is observable deterministically — a localhost PUT
settles in milliseconds, so without the hold the window is a
race. GETs (the load + the save's refetch) pass straight
through."""
def handle(route: Route) -> None:
if route.request.method == "PUT":
time.sleep(hold_s)
route.continue_()
page.route("**/api/ui-settings", handle)
def _release_theme_puts(page: Page) -> None:
page.unroute("**/api/ui-settings")
#: The three string inputs the Theme tab used to render (phase 91) —
#: REMOVED in phase 190 (A1: the admin Settings panel is the single
#: writable owner of the brand strings). Every pin below asserts they
#: count 0 in the tab DOM.
REMOVED_STRING_IDS = ("#theme-app-name", "#theme-placeholder", "#theme-footer")
def _fill_theme_form(page: Page, palette: dict[str, str]) -> None:
"""Fill the 17 color pickers (``palette`` — all 17 vars, phase 93).
The tab has no string inputs since phase 190 (A1)."""
for field, value in palette.items():
page.fill(COLOR_INPUT_IDS[field], value)
def _expect_form_values(page: Page, colors: dict[str, str]) -> None:
"""Assert the 17 color inputs show the given effective values."""
for field in COLOR_FIELDS:
expect(page.locator(COLOR_INPUT_IDS[field])).to_have_value(colors[field])
def _assert_raw_tag(raw: str, colors: dict[str, str]) -> None:
"""The RAW served HTML carries exactly one inline theme tag, with
all 17 vars = the given hexes, placed IMMEDIATELY before
``</head>`` (``inject_theme``'s exact placement: the tag ends
exactly where ``</head>`` begins and carries the injector's
single leading newline)."""
tag = _expected_tag(colors)
assert raw.count(tag) == 1, f"expected exactly one theme tag:\n{tag}"
start = raw.index(tag)
head = raw.index("</head>")
assert start + len(tag) == head, "the tag must end exactly where </head> begins"
assert raw[start - 1] == "\n", "the tag must carry the injector's leading newline"
def _wait_theme_computed(page: Page, colors: dict[str, str], timeout: int = 15_000) -> None:
"""The first-paint proof: all 17 computed ``:root`` custom
properties equal the given hexes. The inline tag precedes every
stylesheet application, so a themed deployment resolves them
from the first style pass — no red flash, no pop-in (custom
properties return the specified token, so the string compare is
stable — the ``.trim()`` rides out any token whitespace)."""
expected = {f"--{k.replace('_', '-')}": v for k, v in colors.items()}
page.wait_for_function(
"""(expected) => {
const cs = getComputedStyle(document.documentElement);
return Object.entries(expected).every(
([k, v]) => cs.getPropertyValue(k).trim() === v
);
}""",
arg=expected,
timeout=timeout,
)
# ---------------------------------------------------------------------------
# 1. The tab (admin): the form, the effective defaults, the §7.4 save
# ---------------------------------------------------------------------------
def test_theme_tab_admin_save(page: Page, app_url: str, db_ready: None) -> None:
builtin = _builtin_colors()
page.set_default_timeout(30_000)
login(page, app_url, next="/theme.html")
# The admin header contract on this page: the ship-hidden "Theme"
# nav link is revealed (header.js, role === "admin") and marks
# the current page (the router's single-writer nav stamp).
expect(page.locator("#nav-theme")).to_be_visible(timeout=15_000)
expect(page.locator("#nav-theme")).to_have_attribute("aria-current", "page")
expect(page.locator("#sign-out-btn")).to_be_visible()
# The gate is hidden for the admin and the form is revealed
# (theme.js's whoami branch — the #git-sources-content pattern).
expect(page.locator("#theme-gate")).to_be_hidden()
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
# The 17 color inputs show the EFFECTIVE defaults: the built-in
# hexes (9 identity + 8 semantic) parsed straight out of
# styles.css's :root (the resolver's missing-row branch).
_expect_form_values(page, builtin)
# Set a distinct palette, then Save through the real form — the PUT
# held so the §7.4 in-flight state is observable deterministically.
_fill_theme_form(page, PALETTE)
_hold_theme_puts(page)
try:
page.click("#theme-save")
# In-flight: BOTH buttons disabled (one action at a time),
# the primary relabeled "Saving…" (never stale).
expect(page.locator("#theme-save")).to_be_disabled()
expect(page.locator("#theme-save")).to_have_text("Saving…")
expect(page.locator("#theme-reset")).to_be_disabled()
# Settled: the role=status confirmation + the restored
# lifecycle (re-enabled, original label).
expect(page.locator("#theme-result")).to_have_text(
"Theme saved.", timeout=30_000
)
expect(page.locator("#theme-result")).to_have_attribute("role", "status")
expect(page.locator("#theme-save")).to_have_text("Save theme")
expect(page.locator("#theme-save")).to_be_enabled()
expect(page.locator("#theme-reset")).to_be_enabled()
finally:
_release_theme_puts(page)
# The inputs re-populate to the SAVED (effective) colors (the
# save's refetch is the canonical state).
_expect_form_values(page, PALETTE)
# The row landed in Postgres (the id-1 single row) with its 17
# colors — every palette color differs from its built-in, so nothing
# collapsed to NULL — and with NO brand string: the tab no longer
# writes them, which is the write half of the TODO.md L3 fix
# (phase 190, A1).
with SessionLocal() as db:
row = db.get(UiSettings, 1)
assert row is not None, "the PUT must upsert the id-1 row"
for field in STRING_FIELDS:
assert getattr(row, field) is None, f"{field} must never be stored"
for field in COLOR_FIELDS:
assert getattr(row, field) == PALETTE[field]
# The saved palette fails ONE of the eight pairs — --bg on
# --brand (the button-ink pair: 3.0:1 < 4.5:1) — and the
# warning lists it (the three semantic ink-on-bg pairs pass on
# the saved picks — see the PALETTE comment). Save was NEVER
# blocked (the warning-only contract: the owner's homelab
# palette; the built-in stays AA).
contrast = page.locator("#theme-contrast")
expect(contrast).to_have_attribute("role", "alert")
expect(contrast).to_be_visible()
expect(contrast).to_have_text("--bg on --brand: 3.0:1 — needs 4.5:1")
# ---------------------------------------------------------------------------
# 1b. The phase-190 (A1) strip in the browser: 17 color inputs, no
# string inputs, and a Save body that carries no string key
# ---------------------------------------------------------------------------
def test_tab_renders_the_17_colors_and_no_string_inputs(
page: Page, app_url: str, db_ready: None
) -> None:
"""The owner-visible shape of the strip (phase 190, A1): the Theme
tab is a COLORS surface — its DOM holds exactly the 17
``type=color`` pickers and NONE of the three string inputs it used
to render, and the body Save posts carries the 17 colors and no
string key. That is the read half of "changing the App name in
settings broke": the tab can neither show a stale name nor bake one
into its row (the write half is the ignored-string PUT, pinned by
``tests/integration/test_ui_settings_api.py``)."""
builtin = _builtin_colors()
page.set_default_timeout(30_000)
login(page, app_url, next="/theme.html")
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
# The three string inputs are ABSENT — not hidden, not empty: gone
# from the DOM, so no selector can fill one and no populate() can
# write a stale effective value into it.
for selector in REMOVED_STRING_IDS:
expect(page.locator(selector)).to_have_count(0)
expect(page.get_by_label("App name")).to_have_count(0)
# The 17 color pickers are all there and still type=color.
color_inputs = page.locator('#view-theme input[type="color"]')
expect(color_inputs).to_have_count(len(COLOR_FIELDS))
_expect_form_values(page, builtin)
# Save posts the 17 colors only — the captured PUT body names no
# string field (the client half of the bake being dead).
captured: dict[str, object] = {}
def handle(route: Route) -> None:
if route.request.method == "PUT":
captured["body"] = route.request.post_data_json
route.continue_()
page.route("**/api/ui-settings", handle)
try:
_fill_theme_form(page, PALETTE)
page.click("#theme-save")
expect(page.locator("#theme-result")).to_have_text(
"Theme saved.", timeout=30_000
)
finally:
page.unroute("**/api/ui-settings")
body = captured.get("body")
assert isinstance(body, dict), "the Save PUT was not captured (route hook?)"
assert set(body) == set(COLOR_FIELDS), (
f"the PUT body must be the 17 colors only, got {sorted(body)}"
)
for field in STRING_FIELDS:
assert field not in body
assert body.get("bg") == PALETTE["bg"]
# ---------------------------------------------------------------------------
# 2. Pre-paint, for everyone: the inline :root in the RAW served HTML
# + the computed palette at load (the no-pop-in proof), and the
# brand strings UNTOUCHED by a palette save (phase 190 A1)
# ---------------------------------------------------------------------------
def test_saved_theme_is_pre_paint_for_everyone(
page: Page, browser: Browser, app_url: str, db_ready: None
) -> None:
page.set_default_timeout(30_000)
login(page, app_url, next="/")
# The admin saves the theme (the API seed — test 1 owns the UI
# save path).
_seed_theme_via_api(app_url, _cookies(page))
# The RAW served HTML (httpx — no JS at all, the server's own
# bytes): exactly one inline theme tag, all 17 vars = the saved
# hexes, immediately before </head> (the pre-paint mechanism the
# middleware unit tests pin — this is its observable
# consequence).
r = httpx.get(app_url + "/", timeout=10)
assert r.status_code == 200
_assert_raw_tag(r.text, PALETTE)
# The phase-91 CSP extension: the inline tag is permitted in a
# real browser only via the strict sha256 source expression
# (style-src 'self' 'sha256-…' appended to the A1 string — no
# 'unsafe-inline').
csp = r.headers.get("content-security-policy", "")
assert "style-src 'self' 'sha256-" in csp, csp
# The admin's browser: the same tag in the served document, and
# the computed custom properties equal the saved hexes at load
# (the inline tag precedes every stylesheet application — the
# first paint IS the themed paint).
page.goto(app_url + "/")
_assert_raw_tag(page.content(), PALETTE)
_wait_theme_computed(page, PALETTE)
# A FRESH anonymous context (no auth anywhere): the same inline
# tag + computed values — the theme is for EVERYONE, not just
# the admin who set it.
anon_ctx: BrowserContext | None = None
try:
anon_ctx = browser.new_context()
anon = anon_ctx.new_page()
anon.set_default_timeout(30_000)
anon.goto(app_url + "/")
_assert_raw_tag(anon.content(), PALETTE)
_wait_theme_computed(anon, PALETTE)
# Phase 190 (A1): the palette save wrote NO brand string, so
# the anonymous page renders the env/template strings through
# the SAME brand.js boot fetch (name + window global, the
# composer placeholder, the footer line) — saving a theme can no
# longer rename the deployment, which is the owner-visible half
# of the TODO.md L3 fix. (The strings' own surface — the admin
# Settings panel — is pinned by test_admin_settings_panel.py and
# the phase-190 suite.)
defaults = _template_defaults()
expect(anon.locator(".brand-text")).to_have_text(
defaults["app_name"], timeout=15_000
)
assert anon.evaluate("() => window.BOR_BRAND") == defaults["app_name"]
expect(anon.locator("#message-input")).to_have_attribute(
"placeholder", defaults["input_placeholder"]
)
expect(anon.locator(".footer-text").first).to_have_text(
defaults["footer_text"]
)
finally:
if anon_ctx is not None:
anon_ctx.close()
# ---------------------------------------------------------------------------
# 3. The gate + the 403s: anonymous sees the gate (never the form),
# the API 403s anonymous AND token users, the nav link is admin-only
# ---------------------------------------------------------------------------
def test_anonymous_and_token_user_are_walled(
page: Page, browser: Browser, app_url: str, db_ready: None
) -> None:
page.set_default_timeout(30_000)
# --- anonymous: the gate, the hidden form, the hidden nav link ---
page.goto(app_url + "/theme.html")
# Phase 79: an anonymous visitor meets the in-app token gate on
# the shell — #main is inert behind it…
expect(page.locator("#auth-gate")).to_be_visible(timeout=30_000)
assert page.evaluate("() => document.getElementById('main').inert") is True
# …and the Theme view's OWN gate (the exact #sources-gate
# pattern) is the view's visible surface: the sign-in link
# returns to the Theme view (?next=/theme.html)…
expect(page.locator("#theme-gate")).to_be_visible(timeout=15_000)
expect(page.locator("#theme-gate a.sources-gate-link")).to_have_attribute(
"href", "/login.html?next=/theme.html"
)
# …while the form stays locked away (theme.js's non-admin
# branch) and the admin-only nav link is hidden.
expect(page.locator("#theme-content")).to_be_hidden()
expect(page.locator("#nav-theme")).to_be_hidden()
expect(page.locator("#sign-in-link")).to_be_visible()
# The API agrees from the context's own (empty) cookies: GET AND
# PUT are 403 "admin only" (the whole router sits behind
# require_admin — anonymous first).
anon_put = page.evaluate(
"""async () => (await fetch('/api/ui-settings', {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({brand: '#4f46e5'}),
})).status"""
)
assert anon_put == 403, f"anonymous PUT /api/ui-settings → {anon_put}"
anon_get = page.evaluate(
"() => fetch('/api/ui-settings').then((r) => r.status)"
)
assert anon_get == 403, f"anonymous GET /api/ui-settings → {anon_get}"
# --- a token user: the SAME wall (B5: admin-only, like
# Tuning/Tokens) ---
login(page, app_url, next="/")
r = httpx.post(
f"{app_url}/api/tokens",
json={"label": "e2e-theme-wall"},
cookies=_cookies(page),
timeout=10,
)
assert r.status_code == 201, r.text
token = r.json()["token"]
user_ctx: BrowserContext | None = None
try:
user_ctx = browser.new_context()
user = user_ctx.new_page()
user.set_default_timeout(30_000)
login_with_token(user, app_url, token)
# The nav link is hidden on their shell (role "user" — the
# header reveals the admin links only for role === "admin")…
expect(user.locator("#nav-theme")).to_be_hidden()
# …and the API 403s their own session (authenticated, just
# not an admin — 403, never 401).
put_status = user.evaluate(
"""async () => (await fetch('/api/ui-settings', {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({brand: '#4f46e5'}),
})).status"""
)
assert put_status == 403, f"token-user PUT /api/ui-settings → {put_status}"
finally:
if user_ctx is not None:
user_ctx.close()
# ---------------------------------------------------------------------------
# 4. Reset: the §7.4 lifecycle, the 17 built-in colors, NO theme tag, and
# byte-identical served HTML (the no-op injection contract)
# ---------------------------------------------------------------------------
def test_reset_restores_the_builtin_byte_identical(
page: Page, app_url: str, db_ready: None
) -> None:
builtin = _builtin_colors()
page.set_default_timeout(30_000)
login(page, app_url, next="/theme.html")
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
# The form settles on the effective defaults (the row-less
# state — the autouse clean truncated the row).
_expect_form_values(page, builtin)
# Save a distinct theme through the UI (the reset must undo a
# REAL save)…
_fill_theme_form(page, PALETTE)
_hold_theme_puts(page)
try:
page.click("#theme-save")
expect(page.locator("#theme-result")).to_have_text(
"Theme saved.", timeout=30_000
)
finally:
_release_theme_puts(page)
# …the theme is live server-side (the pre-reset baseline):
assert "bor-theme" in httpx.get(app_url + "/", timeout=10).text
# Reset to defaults: the §7.4 lifecycle again, with the all-null
# PUT (the API's documented "defaults" operation).
_hold_theme_puts(page)
try:
page.click("#theme-reset")
expect(page.locator("#theme-reset")).to_be_disabled()
expect(page.locator("#theme-reset")).to_have_text("Resetting…")
expect(page.locator("#theme-save")).to_be_disabled()
expect(page.locator("#theme-result")).to_have_text(
"Reset to the built-in theme.", timeout=30_000
)
expect(page.locator("#theme-reset")).to_have_text("Reset to defaults")
expect(page.locator("#theme-reset")).to_be_enabled()
finally:
_release_theme_puts(page)
# The form re-populates to the 17 built-in colors (the built-in
# merge, re-rendered from the refetch)…
_expect_form_values(page, builtin)
# …and the WCAG warning is gone (the built-in palette passes all
# eight pairs).
expect(page.locator("#theme-contrast")).to_be_hidden()
# The served HTML is back to the built-in: NO theme tag anywhere
# (the all-NULL row is the no-op)…
r = httpx.get(app_url + "/", timeout=10)
assert "bor-theme" not in r.text
# …and the computed --brand is the stylesheet's built-in again.
page.goto(app_url + "/")
_wait_theme_computed(page, builtin)
# The byte-identical contract, proven end to end: the served
# bytes of the reset (all-NULL row) deployment equal the served
# bytes of a ROW-LESS deployment (the middleware's no-op path —
# no tag, plain A1 CSP, identical ?v= rewrite).
with_row = httpx.get(app_url + "/", timeout=10).content
with SessionLocal() as db:
db.execute(text("TRUNCATE ui_settings"))
db.commit()
without_row = httpx.get(app_url + "/", timeout=10).content
assert with_row == without_row, (
"a defaults-saved row must serve byte-identical HTML"
)
# ---------------------------------------------------------------------------
# 5. The WCAG contrast warning: listed with the ratio on the picker's
# input event, never blocks the save, hidden again after the reset
# ---------------------------------------------------------------------------
def test_contrast_warning_does_not_block(page: Page, app_url: str, db_ready: None) -> None:
builtin = _builtin_colors()
page.set_default_timeout(30_000)
login(page, app_url, next="/theme.html")
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
# The form settles on the built-in defaults — the warning is
# hidden (the built-in palette passes all eight pairs).
expect(page.locator("#theme-ink")).to_have_value(builtin["ink"])
expect(page.locator("#theme-contrast")).to_be_hidden()
# Set ONLY --ink to a color within 0.1 ratio of --bg: the
# picker's input event previews it live AND re-runs the eight
# pairs — --ink on --bg (and --ink on --surface, the ink is now
# the darker side of that pair too) fail, and each failing pair
# is listed with its ratio in the role=alert line.
page.fill("#theme-ink", FAILING_INK)
contrast = page.locator("#theme-contrast")
expect(contrast).to_have_attribute("role", "alert")
expect(contrast).to_be_visible(timeout=15_000)
expect(contrast).to_contain_text("--ink on --bg: 1.0:1 — needs 4.5:1")
expect(contrast).to_contain_text("--ink on --surface: 1.0:1 — needs 4.5:1")
# WARNING-ONLY: Save is never disabled by the warning (the
# owner's homelab palette — the built-in stays AA, so the
# default deployment is warning-free).
assert page.locator("#theme-save").is_enabled()
_hold_theme_puts(page)
try:
page.click("#theme-save")
expect(page.locator("#theme-result")).to_have_text(
"Theme saved.", timeout=30_000
)
# The saved palette still fails the pairs — the warning
# tracks the SAVED state (the save's refetch re-checks it).
expect(contrast).to_be_visible()
finally:
_release_theme_puts(page)
# Restore: Reset clears the failing pick (the suite's final
# state is clean) and the warning hides with the AA built-ins.
page.click("#theme-reset")
expect(page.locator("#theme-result")).to_have_text(
"Reset to the built-in theme.", timeout=30_000
)
expect(contrast).to_be_hidden()
expect(page.locator("#theme-ink")).to_have_value(builtin["ink"])