Files
brain-of-reese/tests/e2e/test_configurable_brand.py
T
ducoterra 7fce6572d0
Build and Push Containers / build-and-push-app (push) Successful in 1m45s
Build and Push Containers / build-and-push-db (push) Successful in 13s
feat: phases 77–80 — navbar view refresh, static background, API tokens, history suggestion chips
Single consolidated commit for four completed, validated phases (77, 78,
79, 80). The pipeline run left all work uncommitted because the harness
commits only with PHASE_COMMIT=1 while child executors are forbidden from
committing; the phases themselves all passed validation and moved to
.agents/phases/complete/.

Phase 77 — navbar view refresh
- router.js dispatches bor:view-refresh on re-show / active re-click /
  popstate (gated on wasMounted; first show and boot exempt)
- History / RAG / Sources / Tuning re-fetch on refresh (admin branch);
  Chat deliberately excluded (stream survival)
- History "Refresh" button (admin-only, in-flight disable + status line)
- New story suite tests/e2e/test_navbar_refresh.py (7 tests)

Phase 78 — static background
- Removed the animated glow layers; static 44px grid over the flat --bg
  canvas; default and reduced-motion renders byte-identical
- Updated background/theme E2E suites; removed bg-glow test pins

Phase 79 — API tokens
- api_tokens model + migration 0012; hash-only token service
- Admin tokens API + Tokens admin view; POST /api/token-auth;
  live-revoking require_user on chat / suggestions / document content
- Frontend token gate with localStorage cache; anonymous E2E suites
  migrated to token login
- New story suite tests/e2e/test_api_tokens.py (9 tests)

Phase 80 — history suggestion chips
- last_questions() endpoint with SEED fallback; startNewChat() refetch
- Seed-semantics docs (config.py, .env.example, README)
- Integration state matrix + E2E suite rewritten to the 4 chip states

Also included: phase-76 report artifacts and the repo restore-test-db
skill (previously untracked), scripts/* ruff fixes from phase 77.

Final gate state (phase 80 final pass, covers everything above):
- uv run pytest --cov=app → 1637 passed, 0 failed, app/ coverage 99%
- uv run ruff check . && uv run pyright → clean, 0 errors
- Per-phase story E2E suites green in isolation
2026-09-07 12:39:01 -04:00

253 lines
10 KiB
Python

"""Phase 39 E2E (Playwright): the configurable app name (``BOR_APP_NAME``).
Story: ``.agents/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.auth_helpers import login
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()
# Phase 59 (task 05): the third key is the docs-push flag — the
# "Save as doc" gating; both instances run with BOR_DOCS_REPO
# empty, so it is the inert false here. Phase 62 (task 01): the
# endpoint grew to six keys — this suite's instances carry no
# UI-customization overrides, so the three new keys are their
# defaults.
assert set(body) == {
"app_name", "version", "docs_repo_configured",
"input_placeholder", "footer_text", "theme",
}
assert body["app_name"] == TESTY_NAME
assert body["docs_repo_configured"] is False
# The shared conftest instance keeps the default (the other
# suites' title/label contract rides on it) — and its key set
# grew with the endpoint (phase 62).
r2 = httpx.get(f"{app_server}/api/config", timeout=5)
assert r2.status_code == 200
r2_body = r2.json()
assert set(r2_body) == {
"app_name", "version", "docs_repo_configured",
"input_placeholder", "footer_text", "theme",
}
assert r2_body["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:
login(page, testy_server, next="/")
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>")