"""Phase 39 E2E (Playwright): the configurable app name (``BOR_APP_NAME``). Story: ``.agent/user_stories/configurable-brand.md`` Run in isolation (DB must be up: ``podman compose up -d db``): uv run pytest tests/e2e/test_configurable_brand.py -v --no-cov Contract under test (owner ask — "a way to customize the name for 'Brain of'. Should be an env var"): * one env var (``BOR_APP_NAME``, default "Brain of Reese") drives the display name on EVERY page — the ````s, the header ``.brand-text`` brand, the empty-state greeting, the ``#messages`` aria-label, and the chat status labels — via the public ``GET /api/config`` + the classic ``assets/brand.js`` layer; * a name starting ``"Brain of "`` keeps the bold split (``Brain of <strong>rest</strong>``); the name is HTML-escaped (the story's acceptance criteria, AC-2); * with the variable UNSET the app is byte-identical to today — the shared conftest server (the default name every other suite's title/label assertions depend on) still renders "Brain of Reese". Determinism note: this story needs a SECOND app instance — the shared conftest server keeps the default name, so ``testy_server`` boots the same env block (same DB, the mock-LLM base URL, the admin auth, the static dir, the mock-calibrated threshold) with exactly two changes: ``BOR_APP_NAME="Brain of Testy"`` and port ``APP_PORT + 1``. Every assertion is settled-state: Playwright's ``expect`` retries ride out the brand.js ``/api/config`` fetch (the name is applied asynchronously after the fetch answers). The one chat turn is pre-token-labeled — the ``think out loud`` marker makes the mock stream ~2.7k chars of reasoning at 12 chars / 0.02 s (~4.6 s) before the first content frame, so the "… is thinking" label is stable for the whole window regardless of what the turn answers (grounded or deflected — no DB seeding needed). Test → story mapping (Playwright Mapping Rule): 1. ``test_api_config_serves_both_names`` 2. ``test_index_carries_the_custom_brand`` 3. ``test_other_pages_titles_carry_the_name`` 4. ``test_chat_status_label_uses_custom_name_pre_token`` 5. ``test_shared_server_keeps_the_default_name`` """ 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, ) TESTY_PORT = APP_PORT + 1 TESTY_URL = f"http://127.0.0.1:{TESTY_PORT}" TESTY_NAME = "Brain of Testy" DEFAULT_NAME = "Brain of Reese" def expect_inner_html(page: Page, selector: str, expected: str, timeout: int = 15_000) -> None: """Retrying innerHTML equality (Playwright's Python API ships no ``to_have_inner_html`` assertion). The brand layer applies the name when its ``/api/config`` fetch answers, so the check must poll — ``wait_for_function`` does, on Playwright's own retry clock.""" page.wait_for_function( """([sel, expected]) => { const el = document.querySelector(sel); return el !== null && el.innerHTML === expected; }""", arg=[selector, expected], timeout=timeout, ) @pytest.fixture(scope="session") def testy_server(mock_llm: int) -> Iterator[str]: """A SECOND app instance, booted with the overridden name. The shared conftest ``app_server`` keeps the default name (every other suite's title/label assertions depend on it) — so this fixture copies its 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: ``BOR_APP_NAME="Brain of Testy"`` and a distinct port (``APP_PORT + 1`` per the conftest convention). 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 turn behaves 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 # The phase-39 override — the whole story: env["BOR_APP_NAME"] = TESTY_NAME proc = subprocess.Popen( [sys.executable, "-m", "uvicorn", "app.main:app", "--host", "127.0.0.1", "--port", str(TESTY_PORT), "--log-level", "warning"], cwd=REPO, env=env, ) try: _wait_http(f"{TESTY_URL}/api/health") yield TESTY_URL finally: proc.terminate() try: proc.wait(timeout=10) except subprocess.TimeoutExpired: proc.kill() # --------------------------------------------------------------------------- # 1. The endpoint the brand layer reads — overridden on the second # instance, default on the shared one # --------------------------------------------------------------------------- def test_api_config_serves_both_names(testy_server: str, app_server: str) -> None: r = httpx.get(f"{TESTY_URL}/api/config", timeout=5) assert r.status_code == 200 body = r.json() assert set(body) == {"app_name", "version"} assert body["app_name"] == TESTY_NAME # The shared conftest instance keeps the default (the other # suites' title/label contract rides on it). r2 = httpx.get(f"{app_server}/api/config", timeout=5) assert r2.status_code == 200 assert r2.json()["app_name"] == DEFAULT_NAME # --------------------------------------------------------------------------- # 2. The chat page — title, header brand, greeting, stream aria-label # --------------------------------------------------------------------------- def test_index_carries_the_custom_brand(page: Page, testy_server: str) -> None: page.goto(testy_server + "/") # The brand layer applies the name when /api/config answers — the # retrying expectations ride out that fetch. expect(page).to_have_title(TESTY_NAME, timeout=15_000) # The "Brain of …" name keeps the bold split (AC-2) — and the name # arrives escaped (Testy has no special characters, but the markup # shape is the contract). expect_inner_html(page, ".brand-text", "Brain of <strong>Testy</strong>") # The empty-state greeting (the TreeWalker prose pass). expect(page.locator("#empty-state h1")).to_have_text( f"Hey! I'm {TESTY_NAME}.", timeout=15_000 ) # The message stream's accessible label (the attribute pass). expect(page.locator("#messages")).to_have_attribute( "aria-label", f"Conversation with {TESTY_NAME}", timeout=15_000 ) # --------------------------------------------------------------------------- # 3. The other pages — their <title>s carry the name too # --------------------------------------------------------------------------- def test_other_pages_titles_carry_the_name(page: Page, testy_server: str) -> None: page.goto(testy_server + "/sources.html") expect(page).to_have_title(f"Sources · {TESTY_NAME}", timeout=15_000) page.goto(testy_server + "/login.html") expect(page).to_have_title(f"Sign in · {TESTY_NAME}", timeout=15_000) # --------------------------------------------------------------------------- # 4. The chat status label — a pre-token turn on the custom instance # --------------------------------------------------------------------------- def test_chat_status_label_uses_custom_name_pre_token( page: Page, testy_server: str, db_ready: None ) -> None: page.goto(testy_server + "/") expect(page.locator("#send-btn")).to_be_enabled(timeout=15_000) # The ``think out loud`` marker (mock_llm.py) makes the answer # stream ~2.7k chars of reasoning FIRST — the pre-token window. # Works on the default (possibly empty) KB: a deflected answer is # fine, the label assertion is pre-token, no seeding required. page.fill("#message-input", "think out loud — what is the capital of France?") page.click("#send-btn") # The pre-token status label carries the configured name (the # ~4.6 s reasoning stream keeps the state "thinking" — the label # is stable for the whole window). expect(page.locator("#send-status")).to_have_text( f"{TESTY_NAME} is thinking", timeout=15_000 ) # The turn always finishes — the button is never left stale # (PLAN §7.4), whatever the turn answered. expect(page.locator("#send-label")).to_have_text("Send", timeout=60_000) # --------------------------------------------------------------------------- # 5. The no-op regression — the shared default-name instance is # byte-identical to today # --------------------------------------------------------------------------- def test_shared_server_keeps_the_default_name(page: Page, app_server: str) -> None: page.goto(app_server + "/") expect(page).to_have_title(DEFAULT_NAME, timeout=15_000) # With the variable unset the brand pass is a no-op: the header # renders exactly the pre-phase-39 markup. expect_inner_html(page, ".brand-text", "Brain of <strong>Reese</strong>")