"""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 12 inputs show the effective defaults (the 3 template strings + the 9 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 ``' # --------------------------------------------------------------------------- # 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 12 inputs: the 3 text fields (``strings``, default the E2E set) + the 9 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 12 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 9 vars = the given hexes, placed IMMEDIATELY before ```` (``inject_theme``'s exact placement: the tag ends exactly where ```` 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("") assert start + len(tag) == head, "the tag must end exactly where 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 9 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 12 inputs show the EFFECTIVE defaults: the 3 template # strings + the 9 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 12 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 9 vars = the saved # hexes, immediately before (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 12 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 12 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"])