phase: 91_admin_theme_tab
Build and Push Containers / build-and-push-app (push) Successful in 5m43s
Build and Push Containers / build-and-push-db (push) Successful in 12s

All verification is complete — this pass needed no code changes. Final report:

**Phase 91 — Admin Theme tab: final verification pass (all 6 tasks already in `complete/`)**

- Verified pre-paint theming end-to-end: `ui_settings` store + resolver, admin `GET/PUT /api/ui-settings`, `CachingMiddleware` inline-`<style id="bor-theme">` injection before `</head>` (incl. `/shared/<token>` prefix branch, unit-pinned), CSP sha256 exemption for the inline tag, Theme tab shell + `theme.js` editor, CSS-file theming fully retired.
- No defects found; zero changes made — working tree left exactly as the task executors left it.
- Tests: `uv run pytest --cov=app` → 1841 passed, 0 failed (TOTAL coverage **99%**; theming/ui_settings/caching all 100%); `uv run pytest tests/e2e/test_admin_theme_tab.py -v --no-cov` → **5 passed** in isolation.
- Lint/types: `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors, 0 warnings.
- Criteria: (1) unset deployment byte-identical, no `#bor-theme` anywhere — ✓ (unit no-op test + E2E reset byte-compare); `rg "BOR_THEME|themes/"` → single hit is the permitted doc-history comment in `frontend/index.html`. (2) admin-only gate + 403s for anonymous and token users — ✓ (E2E test 3). (3) saved theme inline before `</head>` on every page incl. `/shared/<token>`, computed `--brand` on first paint for admin + anonymous — ✓ (E2E test 2 + unit). (4) reset → byte-identical; 5 contrast pairs warn <4.5:1, non-blocking — ✓ (E2E tests 4–5). (5) suite green, >90% coverage, lint clean — ✓. (6) commit deferred to harness per rules.
- Notable: `.agents/PLAN.md` is absent from the repo — the phase overview's Design section was used as the binding spec; no deviation resulted.
- Next pending phase: **none** — 91 is the last phase in `todo/`.
This commit is contained in:
2026-09-09 17:22:24 -04:00
parent 3095c4c577
commit d22d260b8b
74 changed files with 4448 additions and 675 deletions
+5 -6
View File
@@ -123,14 +123,13 @@ def app_server(mock_llm: int) -> Iterator[str]:
)
# Phase 62: the same leak class for the new UI customization
# settings — an operator's local (gitignored) ``.env`` may
# legitimately carry ``BOR_INPUT_PLACEHOLDER`` / ``BOR_FOOTER_TEXT``
# / ``BOR_THEME``, and the byte-identical default contract (task
# 05's ``test_default_server_is_byte_identical``) must see the code
# defaults (derived from the class fields, never drifts from
# ``app/config.py``).
# legitimately carry ``BOR_INPUT_PLACEHOLDER`` /
# ``BOR_FOOTER_TEXT``, and the byte-identical default contract
# must see the code defaults (derived from the class fields, never
# drifts from ``app/config.py``). (Phase 91, task 03: the retired
# CSS-file theme env var no longer exists — nothing to pin.)
env["BOR_INPUT_PLACEHOLDER"] = _Settings.model_fields["input_placeholder"].default
env["BOR_FOOTER_TEXT"] = _Settings.model_fields["footer_text"].default
env["BOR_THEME"] = _Settings.model_fields["theme"].default
proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "app.main:app",
"--host", "127.0.0.1", "--port", str(APP_PORT), "--log-level", "warning"],
+733
View File
@@ -0,0 +1,733 @@
"""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
11 inputs show the effective defaults (the 3 template strings +
the 8 built-in hexes 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).
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 8 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). The 3 strings stay on the brand.js boot
fetch (the B4 split: colors pre-paint, strings via the fetch).
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
11 defaults, 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
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): a full non-built-in indigo set —
# 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).
PALETTE: dict[str, str] = {
"bg": "#0b1020",
"surface": "#111730",
"ink": "#e6e9f5",
"ink_soft": "#a8b0d0",
"line": "#232a4a",
"brand": "#4f46e5",
"brand_soft": "#1e2447",
"brand_ink": "#c7d2fe",
}
APP_NAME = "Theme E2E"
PLACEHOLDER = "Ask the themed brain…"
FOOTER = "E2E footer"
SAVED_STRINGS: dict[str, str] = {
"app_name": APP_NAME,
"input_placeholder": PLACEHOLDER,
"footer_text": FOOTER,
}
#: 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 8 built-in identity hexes 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 8 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 theme (the API
seed — the UI save itself is test 1's job)."""
body = {**PALETTE, **SAVED_STRINGS}
r = httpx.put(f"{app_url}/api/ui-settings", json=body, cookies=cookies, timeout=10)
assert r.status_code == 200, r.text
assert r.json() == body, "the PUT must echo the new effective values"
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")
def _fill_theme_form(
page: Page,
palette: dict[str, str],
strings: dict[str, str] | None = None,
) -> None:
"""Fill the 11 inputs: the 3 text fields (``strings``, default
the E2E set) + the 8 color pickers (``palette``)."""
text_values = strings if strings is not None else SAVED_STRINGS
page.fill("#theme-app-name", text_values["app_name"])
page.fill("#theme-placeholder", text_values["input_placeholder"])
page.fill("#theme-footer", text_values["footer_text"])
for field, value in palette.items():
page.fill(COLOR_INPUT_IDS[field], value)
def _expect_form_values(page: Page, strings: dict[str, str], colors: dict[str, str]) -> None:
"""Assert all 11 inputs show the given effective values."""
expect(page.locator("#theme-app-name")).to_have_value(strings["app_name"])
expect(page.locator("#theme-placeholder")).to_have_value(strings["input_placeholder"])
expect(page.locator("#theme-footer")).to_have_value(strings["footer_text"])
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 8 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 8 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:
defaults = _template_defaults()
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 11 inputs show the EFFECTIVE defaults: the 3 template
# strings + the 8 built-in hexes parsed straight out of
# styles.css's :root (the resolver's missing-row branch).
_expect_form_values(page, defaults, builtin)
# Set a distinct palette + the 3 strings, 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) values (the
# save's refetch is the canonical state).
_expect_form_values(page, SAVED_STRINGS, PALETTE)
# The row landed in Postgres (the id-1 single row, all 11 values
# — every palette color differs from its built-in, so nothing
# collapsed to NULL).
with SessionLocal() as db:
row = db.get(UiSettings, 1)
assert row is not None, "the PUT must upsert the id-1 row"
assert row.app_name == APP_NAME
assert row.input_placeholder == PLACEHOLDER
assert row.footer_text == FOOTER
for field in COLOR_FIELDS:
assert getattr(row, field) == PALETTE[field]
# The saved palette fails ONE of the five pairs — --bg on
# --brand (the button-ink pair: 3.0:1 < 4.5:1) — and the
# warning lists it. 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")
# ---------------------------------------------------------------------------
# 2. Pre-paint, for everyone: the inline :root in the RAW served HTML
# + the computed palette at load (the no-pop-in proof), the B4
# strings via the boot fetch
# ---------------------------------------------------------------------------
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 8 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)
# The B4 split: the 3 strings are NOT pre-paint — they apply
# post-fetch via the /api/config boot fetch (brand.js) on the
# anonymous page too: the name (header brand + window
# global), the placeholder, and the footer line.
expect(anon.locator(".brand-text")).to_have_text(APP_NAME, timeout=15_000)
assert anon.evaluate("() => window.BOR_BRAND") == APP_NAME
expect(anon.locator("#message-input")).to_have_attribute(
"placeholder", PLACEHOLDER
)
expect(anon.locator(".footer-text").first).to_have_text(FOOTER)
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 11 defaults, 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:
defaults = _template_defaults()
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, defaults, 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 11 defaults (the env/built-in
# merge, re-rendered from the refetch)…
_expect_form_values(page, defaults, builtin)
# …and the WCAG warning is gone (the built-in palette passes all
# five 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 five 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 five
# 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"])
-1
View File
@@ -331,7 +331,6 @@ def app_server(mock_llm: int, slow_llm: int) -> Iterator[str]:
)
env["BOR_INPUT_PLACEHOLDER"] = _Settings.model_fields["input_placeholder"].default
env["BOR_FOOTER_TEXT"] = _Settings.model_fields["footer_text"].default
env["BOR_THEME"] = _Settings.model_fields["theme"].default
proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "app.main:app",
"--host", "127.0.0.1", "--port", str(APP_PORT), "--log-level", "warning"],
+9 -6
View File
@@ -148,25 +148,28 @@ def test_api_config_serves_both_names(testy_server: str, app_server: str) -> Non
# Phase 59 (task 05): the third key is the docs-push flag — the
# "Save as doc" gating; both instances run with BOR_DOCS_REPO
# empty, so it is the inert false here. Phase 62 (task 01): the
# endpoint grew to six keys — this suite's instances carry no
# UI-customization overrides, so the three new keys are their
# defaults.
# endpoint grew with the UI-customization keys; phase 91
# (task 03) deleted the retired CSS-file theming's ``theme`` key —
# the five keys below are the entire contract (this suite's
# instances carry no UI-customization overrides, so the string
# keys are their defaults).
assert set(body) == {
"app_name", "version", "docs_repo_configured",
"input_placeholder", "footer_text", "theme",
"input_placeholder", "footer_text",
}
assert body["app_name"] == TESTY_NAME
assert body["docs_repo_configured"] is False
# The shared conftest instance keeps the default (the other
# suites' title/label contract rides on it) — and its key set
# grew with the endpoint (phase 62).
# tracks the endpoint contract (five keys after phase 91,
# task 03).
r2 = httpx.get(f"{app_server}/api/config", timeout=5)
assert r2.status_code == 200
r2_body = r2.json()
assert set(r2_body) == {
"app_name", "version", "docs_repo_configured",
"input_placeholder", "footer_text", "theme",
"input_placeholder", "footer_text",
}
assert r2_body["app_name"] == DEFAULT_NAME
+49 -143
View File
@@ -1,50 +1,53 @@
"""Phase 62 E2E (Playwright): UI customization — placeholder, footer, theme.
"""Phase 62 E2E (Playwright): UI customization — placeholder + footer.
Source: ``TODO.md`` L3 — "Allow UI customization. This is brain of reese,
but I want anyone to be able to deploy it with their name… custom
message-input placeholder, custom footer-inner text, custom color
themes…" (owner-locked 2026-09-01: ``BOR_INPUT_PLACEHOLDER``,
``BOR_FOOTER_TEXT``, ``BOR_THEME`` — A4/A5).
``BOR_FOOTER_TEXT`` — A4). Phase 91 (task 03) retired the story's
color-theming half — the CSS-file theme env var and its brand.js
``<link>`` insertion are gone; the admin Theme tab (phases 91,
tasks 04–06) is the only theming surface now, with its own dedicated
E2E suite.
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_ui_customization.py -v --no-cov
Contract under test:
* an instance booted with ALL THREE customization vars set shows the
custom look end-to-end: the ``GET /api/config`` overrides, the chat
composer placeholder (``#message-input``), the footer line on multiple
pages (``.footer-text``), and the computed ``:root --brand`` from the
inserted ``<link id="theme-override" href="/assets/themes/indigo.css">``
(the indigo example theme, ``--brand: #818cf8``);
* an instance booted with BOTH customization vars set shows the custom
look end-to-end: the ``GET /api/config`` overrides (now the five-key
set — the retired theming's ``theme`` key is gone), the chat composer
placeholder (``#message-input``), and the footer line on multiple
pages (``.footer-text``);
* with NOTHING set the shared conftest server is byte-identical to the
phase-39/61 no-op contract: the default placeholder, the default
footer, NO theme link, the built-in ``--brand: #f43f5e``;
* a malformed ``BOR_THEME`` (``../evil.css``) refuses startup loudly,
naming the value — the phase-56 fail-loud style, proven end-to-end
via a real boot attempt, not just the validator unit test.
footer, the built-in ``--brand: #f43f5e``;
* the app NAME stays the default on the custom instance (this suite
does not re-test ``BOR_APP_NAME`` — that is the phase-39 suite's
job); the response's ``app_name`` key is the EFFECTIVE value (phase
91: DB-over-env — for an env-only deployment, the env string itself).
Determinism note: this story needs a SECOND app instance — the shared
conftest server keeps the defaults (every other suite's
placeholder/footer/palette assertions depend on it), so ``custom_server``
boots the same env block the phase-39 brand suite's ``testy_server``
boots (same DB, the mock-LLM base URL, the admin auth, the static dir,
the mock-calibrated threshold) with exactly three changes: port
the mock-calibrated threshold) with exactly two changes: port
``APP_PORT + 2`` (the brand suite owns ``APP_PORT + 1`` — do not
collide) and the three env overrides. Every assertion is settled-state:
collide) and the two env overrides. Every assertion is settled-state:
Playwright's ``expect`` retries ride out the brand.js ``/api/config``
fetch (the three keys are applied asynchronously, in the SAME fetch's
settled ``.then`` — no second network call). The one absence assertion
(no ``#theme-override`` on the default server) first waits for
``window.BOR_CONFIG_PROMISE`` to settle, so it cannot race the fetch.
fetch (the two string keys are applied asynchronously, in the SAME
fetch's settled ``.then`` — no second network call).
Test → contract mapping (Playwright Mapping Rule):
1. ``test_config_serves_the_overrides``
2. ``test_chat_page_shows_custom_placeholder_footer_theme``
2. ``test_chat_page_shows_custom_placeholder_and_footer``
3. ``test_footer_text_applies_on_other_pages``
4. ``test_default_server_is_byte_identical``
5. ``test_malformed_theme_refuses_startup``
"""
from __future__ import annotations
import os
@@ -67,13 +70,10 @@ from e2e.conftest import (
CUSTOM_PORT = APP_PORT + 2 # the brand suite owns APP_PORT + 1 — no collision
CUSTOM_URL = f"http://127.0.0.1:{CUSTOM_PORT}"
MALFORMED_PORT = APP_PORT + 3 # the refused boot never starts listening
# The three overrides (task 05) — the whole story:
# The two overrides (phase 62, task 05) — the surviving story legs:
CUSTOM_PLACEHOLDER = "Ask the archive…"
CUSTOM_FOOTER = "Custom footer line"
CUSTOM_THEME = "indigo.css"
INDIGO_BRAND = "#818cf8" # indigo.css's --brand (the computed token)
# The phase-39/61 no-op contract on the shared default server:
DEFAULT_NAME = "Brain of Reese"
@@ -84,7 +84,7 @@ BUILTIN_BRAND = "#f43f5e" # styles.css's built-in --brand
@pytest.fixture(scope="session")
def custom_server(mock_llm: int) -> Iterator[str]:
"""A SECOND app instance, booted with all three customization
"""A SECOND app instance, booted with both customization string
overrides.
The shared conftest ``app_server`` keeps the defaults (every other
@@ -92,9 +92,9 @@ def custom_server(mock_llm: int) -> Iterator[str]:
this fixture copies the phase-39 brand suite's ``testy_server`` env
block verbatim (same DB, the mock-LLM base URL,
``BOR_ADMIN_PASSWORD``/``BOR_SESSION_SECRET``, ``BOR_STATIC_DIR``,
``BOR_RELEVANCE_THRESHOLD``) with exactly three changes: port
``BOR_RELEVANCE_THRESHOLD``) with exactly two changes: port
``APP_PORT + 2`` (the brand suite owns ``APP_PORT + 1``) and the
three env overrides below. Started after ``mock_llm`` is available
two env overrides below. Started after ``mock_llm`` is available
(its fixture dependency).
"""
env = dict(os.environ)
@@ -117,10 +117,9 @@ def custom_server(mock_llm: int) -> Iterator[str]:
# 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
# Phase 62 (owner-locked 2026-09-01, TODO L3) — the whole story:
# Phase 62 (owner-locked 2026-09-01, TODO L3) — the surviving legs:
env["BOR_INPUT_PLACEHOLDER"] = CUSTOM_PLACEHOLDER
env["BOR_FOOTER_TEXT"] = CUSTOM_FOOTER
env["BOR_THEME"] = CUSTOM_THEME
proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "app.main:app",
"--host", "127.0.0.1", "--port", str(CUSTOM_PORT), "--log-level", "warning"],
@@ -138,28 +137,13 @@ def custom_server(mock_llm: int) -> Iterator[str]:
proc.kill()
def wait_for_brand_settled(page: Page, timeout: int = 15_000) -> None:
"""Wait for the brand layer's boot fetch to settle.
The three customization keys are applied asynchronously, in the
settled ``/api/config`` promise's ``.then`` — absence assertions
(no ``#theme-override``) must not race that fetch. The promise
NEVER rejects (the brand.js contract), so its resolution means the
DOM pass has already run: ``applyBrand`` registered its callback on
the same promise at page load, before this wait's callback, and
promise callbacks run in registration order."""
page.wait_for_function(
"() => window.BOR_CONFIG_PROMISE.then(() => true)",
timeout=timeout,
)
def expect_brand_var(page: Page, expected: str, timeout: int = 15_000) -> None:
"""Retrying computed ``:root --brand`` equality. Custom properties
return the SPECIFIED token from ``getComputedStyle`` (no color
normalization), so the string compare is stable: ``#818cf8`` is
exactly what indigo.css declares, ``#f43f5e`` exactly what
styles.css declares (the built-in)."""
normalization), so the string compare is stable: ``#f43f5e`` is
exactly what styles.css declares (the built-in — the page the
shared default server serves, with no ui_settings row, carries no
inline theme tag and the stylesheet value stands)."""
page.wait_for_function(
"""(expected) =>
getComputedStyle(document.documentElement)
@@ -171,8 +155,8 @@ def expect_brand_var(page: Page, expected: str, timeout: int = 15_000) -> None:
# ---------------------------------------------------------------------------
# 1. The endpoint the brand layer reads — the three overrides, the
# six-key set, and the theme file served from the dev static dir
# 1. The endpoint the brand layer reads — the two overrides, the
# five-key set (the retired theming's theme key is gone)
# ---------------------------------------------------------------------------
@@ -180,33 +164,27 @@ def test_config_serves_the_overrides(custom_server: str) -> None:
r = httpx.get(f"{CUSTOM_URL}/api/config", timeout=5)
assert r.status_code == 200
body = r.json()
# The six-key set (the phase-39/59/62 endpoint contract) with the
# three customization overrides — the app NAME stays the default
# (this suite does not re-test BOR_APP_NAME; that is the phase-39
# suite's job).
# The five-key set (the phase-39/59/62 endpoint contract, phase 91
# task 03: the retired CSS-file theming's ``theme`` key is gone)
# with the two customization overrides — the app NAME stays the
# default (this suite does not re-test BOR_APP_NAME; that is the
# phase-39 suite's job).
assert set(body) == {
"app_name", "version", "docs_repo_configured",
"input_placeholder", "footer_text", "theme",
"input_placeholder", "footer_text",
}
assert body["app_name"] == DEFAULT_NAME
assert body["input_placeholder"] == CUSTOM_PLACEHOLDER
assert body["footer_text"] == CUSTOM_FOOTER
assert body["theme"] == CUSTOM_THEME
# Served in dev from the static dir (the no-CDN rule): the theme
# file the boot fetch names is reachable at its served path, and
# it is the indigo example (its --brand is the E2E's theme proof).
r2 = httpx.get(f"{CUSTOM_URL}/assets/themes/{CUSTOM_THEME}", timeout=5)
assert r2.status_code == 200
assert f"--brand: {INDIGO_BRAND}" in r2.text
# ---------------------------------------------------------------------------
# 2. The chat page — placeholder, footer, the theme link + effect
# 2. The chat page — placeholder + footer (the theme legs are retired:
# colors are injected pre-paint server-side, phase 91 task 02)
# ---------------------------------------------------------------------------
def test_chat_page_shows_custom_placeholder_footer_theme(
def test_chat_page_shows_custom_placeholder_and_footer(
page: Page, custom_server: str
) -> None:
page.goto(custom_server + "/")
@@ -219,13 +197,6 @@ def test_chat_page_shows_custom_placeholder_footer_theme(
expect(page.locator(".footer-text").first).to_have_text(
CUSTOM_FOOTER, timeout=15_000
)
# 7. The theme link in <head> — rel=stylesheet, the served path.
expect(page.locator('head link#theme-override[rel="stylesheet"]')).to_have_attribute(
"href", f"/assets/themes/{CUSTOM_THEME}", timeout=15_000
)
# And it takes effect: the computed :root --brand is the indigo
# value (the built-in #f43f5e means the theme never loaded).
expect_brand_var(page, INDIGO_BRAND)
# ---------------------------------------------------------------------------
@@ -243,10 +214,6 @@ def test_footer_text_applies_on_other_pages(page: Page, custom_server: str) -> N
expect(page.locator(".footer-text").first).to_have_text(
CUSTOM_FOOTER, timeout=15_000
)
# The theme link rides in <head> on every page too.
expect(page.locator('head link#theme-override[rel="stylesheet"]')).to_have_attribute(
"href", f"/assets/themes/{CUSTOM_THEME}", timeout=15_000
)
# ---------------------------------------------------------------------------
@@ -257,75 +224,14 @@ def test_footer_text_applies_on_other_pages(page: Page, custom_server: str) -> N
def test_default_server_is_byte_identical(page: Page, app_server: str) -> None:
page.goto(app_server + "/")
# Settle the boot fetch BEFORE the absence assertion — it must not
# race the (absent) theme-link insertion.
wait_for_brand_settled(page)
# The phase-39/61 no-op contract: the template defaults stand.
# The phase-39/61 no-op contract: the template defaults stand
# (positive assertions on the static HTML — the brand layer's
# no-op paths touch nothing when the env vars are unset).
expect(page.locator("#message-input")).to_have_attribute(
"placeholder", DEFAULT_PLACEHOLDER
)
expect(page.locator(".footer-text").first).to_have_text(DEFAULT_FOOTER)
# With BOR_THEME unset the brand layer inserts NO theme link:
assert page.locator("#theme-override").count() == 0, (
"with BOR_THEME unset the brand layer must NOT insert a theme "
"link (the byte-identical no-op contract)"
)
# The built-in dark-tech palette stands.
# The built-in dark-tech palette stands: with no ui_settings row
# the server injects no inline theme tag (phase 91 task 02's
# no-op), so the stylesheet's --brand is the computed value.
expect_brand_var(page, BUILTIN_BRAND)
# ---------------------------------------------------------------------------
# 5. The fail-loud boot check — a malformed BOR_THEME refuses startup
# ---------------------------------------------------------------------------
def test_malformed_theme_refuses_startup() -> None:
"""A malformed ``BOR_THEME`` (``../evil.css`` — a path, exactly the
shape the A5 lock names as illegal) kills startup with the value
NAMED on stderr (the phase-56 fail-loud house style), proven
end-to-end via a real uvicorn boot attempt: the process exits
non-zero within the timeout without ever starting to listen.
``app.main`` builds its settings at import time
(``settings = get_settings()``), so the validator fires during the
ASGI app import — before admin auth, before the port binds."""
env = dict(os.environ)
env.pop("DEBUGPY", None)
env["BOR_ENVIRONMENT"] = "e2e"
env["BOR_STATIC_DIR"] = str(REPO / "frontend")
env["BOR_RELEVANCE_THRESHOLD"] = "0.30"
env.setdefault(
"BOR_DATABASE_URL",
"postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese",
)
env["BOR_ADMIN_PASSWORD"] = ADMIN_PASSWORD
env["BOR_SESSION_SECRET"] = SESSION_SECRET
# The whole point: a malformed theme value.
env["BOR_THEME"] = "../evil.css"
proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "app.main:app",
"--host", "127.0.0.1", "--port", str(MALFORMED_PORT), "--log-level", "warning"],
cwd=REPO,
env=env,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
text=True,
)
try:
proc.wait(timeout=60)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait()
pytest.fail(
"the app kept running with BOR_THEME='../evil.css' — a "
"malformed theme must refuse startup, not silently 404"
)
assert proc.returncode != 0, (
"the malformed BOR_THEME must make uvicorn exit non-zero"
)
stderr = proc.stderr.read() if proc.stderr else ""
# Fail-loud names the offending value (phase-56 house style):
assert "'../evil.css'" in stderr, (
f"stderr must name the offending value, got tail: {stderr[-2000:]}"
)
assert "theme must be a bare .css filename" in stderr