"""Phase 62 E2E (Playwright): UI customization — placeholder, footer, theme. 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). 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 ```` (the indigo example theme, ``--brand: #818cf8``); * 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. 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 ``APP_PORT + 2`` (the brand suite owns ``APP_PORT + 1`` — do not collide) and the three 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. Test → contract mapping (Playwright Mapping Rule): 1. ``test_config_serves_the_overrides`` 2. ``test_chat_page_shows_custom_placeholder_footer_theme`` 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 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}" MALFORMED_PORT = APP_PORT + 3 # the refused boot never starts listening # The three overrides (task 05) — the whole story: 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" 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 all three customization 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 three changes: port ``APP_PORT + 2`` (the brand suite owns ``APP_PORT + 1``) and the three 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 whole story: 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"], 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 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).""" page.wait_for_function( """(expected) => getComputedStyle(document.documentElement) .getPropertyValue("--brand") .trim() === expected""", arg=expected, timeout=timeout, ) # --------------------------------------------------------------------------- # 1. The endpoint the brand layer reads — the three overrides, the # six-key set, and the theme file served from the dev static dir # --------------------------------------------------------------------------- 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). assert set(body) == { "app_name", "version", "docs_repo_configured", "input_placeholder", "footer_text", "theme", } 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 # --------------------------------------------------------------------------- def test_chat_page_shows_custom_placeholder_footer_theme( 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 ) # 7. The theme link in — 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) # --------------------------------------------------------------------------- # 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 ) # The theme link rides in 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 ) # --------------------------------------------------------------------------- # 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 + "/") # 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. 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. 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