"""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`` — A4). Phase 91 (task 03) retired the story's color-theming half — the CSS-file theme env var and its brand.js ```` 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 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, 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 two changes: port ``APP_PORT + 2`` (the brand suite owns ``APP_PORT + 1`` — do not collide) and the two env overrides. Every assertion is settled-state: Playwright's ``expect`` retries ride out the brand.js ``/api/config`` 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_and_footer`` 3. ``test_footer_text_applies_on_other_pages`` 4. ``test_default_server_is_byte_identical`` """ from __future__ import annotations import os import subprocess import sys from collections.abc import Iterator import httpx import pytest from playwright.sync_api import Page, expect from e2e.conftest import ( ADMIN_PASSWORD, APP_PORT, REPO, SESSION_SECRET, USE_REAL_LLM, _wait_http, ) CUSTOM_PORT = APP_PORT + 2 # the brand suite owns APP_PORT + 1 — no collision CUSTOM_URL = f"http://127.0.0.1:{CUSTOM_PORT}" # The two overrides (phase 62, task 05) — the surviving story legs: CUSTOM_PLACEHOLDER = "Ask the archive…" CUSTOM_FOOTER = "Custom footer line" # The phase-39/61 no-op contract on the shared default server: DEFAULT_NAME = "Brain of Reese" DEFAULT_PLACEHOLDER = "Ask me anything…" DEFAULT_FOOTER = "Powered by self-hosted models" 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 both customization string overrides. The shared conftest ``app_server`` keeps the defaults (every other suite's placeholder/footer/palette assertions depend on it) — so 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 two changes: port ``APP_PORT + 2`` (the brand suite owns ``APP_PORT + 1``) and the two env overrides below. Started after ``mock_llm`` is available (its fixture dependency). """ 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" ) # The mock's token-overlap embeddings have their own score # distribution — the same mock-calibrated threshold as the shared # instance, so this suite's pages behave like every other story's. env["BOR_RELEVANCE_THRESHOLD"] = "0.30" 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 # Phase 62 (owner-locked 2026-09-01, TODO L3) — the surviving legs: env["BOR_INPUT_PLACEHOLDER"] = CUSTOM_PLACEHOLDER env["BOR_FOOTER_TEXT"] = CUSTOM_FOOTER proc = subprocess.Popen( [sys.executable, "-m", "uvicorn", "app.main:app", "--host", "127.0.0.1", "--port", str(CUSTOM_PORT), "--log-level", "warning"], cwd=REPO, env=env, ) try: _wait_http(f"{CUSTOM_URL}/api/health") yield CUSTOM_URL finally: proc.terminate() try: proc.wait(timeout=10) except subprocess.TimeoutExpired: proc.kill() 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: ``#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) .getPropertyValue("--brand") .trim() === expected""", arg=expected, timeout=timeout, ) # --------------------------------------------------------------------------- # 1. The endpoint the brand layer reads — the two overrides, the # five-key set (the retired theming's theme key is gone) # --------------------------------------------------------------------------- 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 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", } assert body["app_name"] == DEFAULT_NAME assert body["input_placeholder"] == CUSTOM_PLACEHOLDER assert body["footer_text"] == CUSTOM_FOOTER # --------------------------------------------------------------------------- # 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_and_footer( page: Page, custom_server: str ) -> None: page.goto(custom_server + "/") # 5. The composer placeholder — the retry rides out the brand.js # /api/config fetch that applies it. expect(page.locator("#message-input")).to_have_attribute( "placeholder", CUSTOM_PLACEHOLDER, timeout=15_000 ) # 6. The footer line on the chat page. expect(page.locator(".footer-text").first).to_have_text( CUSTOM_FOOTER, timeout=15_000 ) # --------------------------------------------------------------------------- # 3. A second page — the footer applies multi-page; the placeholder # application no-ops without a composer # --------------------------------------------------------------------------- def test_footer_text_applies_on_other_pages(page: Page, custom_server: str) -> None: page.goto(custom_server + "/login.html") # This page has NO composer — the placeholder application no-ops # there via the null guard (no error, no element touched). expect(page.locator("#message-input")).to_have_count(0) # The footer line applies on every page (the phase-61 hook). expect(page.locator(".footer-text").first).to_have_text( CUSTOM_FOOTER, timeout=15_000 ) # --------------------------------------------------------------------------- # 4. The no-op regression — the shared default server is byte-identical # to the phase-39/61 contract # --------------------------------------------------------------------------- def test_default_server_is_byte_identical(page: Page, app_server: str) -> None: page.goto(app_server + "/") # 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) # 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)