Files
brain-of-reese/tests/e2e/test_configurable_brand.py
T
ducoterra fe55be0c35
Build and Push Containers / build-and-push (push) Successful in 1m50s
feat(brand): configurable app name — BOR_APP_NAME drives /api/config + the frontend brand layer
One env var (BOR_APP_NAME, default "Brain of Reese") now drives the app's
display name everywhere (TODO.md L12 — owner ask: "a way to customize the
name for 'Brain of'. Should be an env var."). The existing app_name setting
is the source of truth (phase locked decision — no new variable, no rename);
with the variable unset the app is byte-identical to before.

Endpoint (A10 public/stateless, no secrets):
  GET /api/config → exactly {app_name, version} (app/api/config.py, the
  health.py pattern; registered before the static mount). Integration tests:
  anonymous 200, default values, a Settings override follows, key set is
  exactly two keys — no other setting may leak in later.

Frontend brand layer (A11 — runtime fetch, static templates stay static):
  assets/brand.js — a CLASSIC script, first on all six pages, so its top
  level runs at parse time: window.BOR_BRAND = "Brain of Reese"
  synchronously (the default renders immediately, no blank flash), then a
  no-store fetch of /api/config applies the name — document.title (global
  replace), every .brand-text (a name starting "Brain of " keeps the bold
  split Brain of <strong>rest</strong>, any other name renders plain; the
  operator-controlled name is HTML-escaped before innerHTML), a TreeWalker
  over text nodes (script/style rejected — page source never rewritten),
  and the aria-label/placeholder/meta-content attributes. Fetch failure
  keeps the default + console.warn (the loadHealth house style).
  app.js (status labels, typing label, elapsed-hint aria, tool labels) and
  document.js (viewer titles) read window.BOR_BRAND at CALL time via
  brand() — a label set after the fetch lands carries the configured name.
  Containerfile: esbuild minify line for brand.js (classic, like markdown.js);
  the phase-33 ?v= cache-busting picks the new asset ref up automatically.

E2E (A16 — one story, one file, isolated): test_configurable_brand.py boots
a SECOND app instance (same DB/mock-LLM/admin-auth env block, port APP_PORT+1,
BOR_APP_NAME="Brain of Testy") — the shared conftest server keeps the
default name so every other suite's title/label assertions stay untouched —
and asserts /api/config on both instances, the index title/brand/greeting/
#messages aria-label, the sources + login page titles, and one pre-token
chat turn (think out loud marker) whose #send-status reads "Brain of Testy
is thinking"; the no-op regression pins the shared server's default bytes.

Docs: .env.example App section + README configuration reference — what it
affects (titles, header brand, status labels, aria text), the default, the
bold-split rendering rule.

Gates: 695 unit+integration passed, app/ coverage 99% (>90%), story E2E
green in isolation (two consecutive runs), brand-string suites (smoke,
shared header, header consistency, chat persistence) green, ruff + pyright
clean.
2026-08-27 02:24:16 -04:00

236 lines
9.5 KiB
Python

"""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 ``<title>``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>")