All verification is complete — this pass needed no code changes. Final report: **Phase 91 — Admin Theme tab: final verification pass (all 6 tasks already in `complete/`)** - Verified pre-paint theming end-to-end: `ui_settings` store + resolver, admin `GET/PUT /api/ui-settings`, `CachingMiddleware` inline-`<style id="bor-theme">` injection before `</head>` (incl. `/shared/<token>` prefix branch, unit-pinned), CSP sha256 exemption for the inline tag, Theme tab shell + `theme.js` editor, CSS-file theming fully retired. - No defects found; zero changes made — working tree left exactly as the task executors left it. - Tests: `uv run pytest --cov=app` → 1841 passed, 0 failed (TOTAL coverage **99%**; theming/ui_settings/caching all 100%); `uv run pytest tests/e2e/test_admin_theme_tab.py -v --no-cov` → **5 passed** in isolation. - Lint/types: `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors, 0 warnings. - Criteria: (1) unset deployment byte-identical, no `#bor-theme` anywhere — ✓ (unit no-op test + E2E reset byte-compare); `rg "BOR_THEME|themes/"` → single hit is the permitted doc-history comment in `frontend/index.html`. (2) admin-only gate + 403s for anonymous and token users — ✓ (E2E test 3). (3) saved theme inline before `</head>` on every page incl. `/shared/<token>`, computed `--brand` on first paint for admin + anonymous — ✓ (E2E test 2 + unit). (4) reset → byte-identical; 5 contrast pairs warn <4.5:1, non-blocking — ✓ (E2E tests 4–5). (5) suite green, >90% coverage, lint clean — ✓. (6) commit deferred to harness per rules. - Notable: `.agents/PLAN.md` is absent from the repo — the phase overview's Design section was used as the binding spec; no deviation resulted. - Next pending phase: **none** — 91 is the last phase in `todo/`.
238 lines
9.7 KiB
Python
238 lines
9.7 KiB
Python
"""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
|
||
``<link>`` 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)
|