Files
brain-of-reese/tests/e2e/test_ui_customization.py
T
ducoterra a19d78d284
Build and Push Containers / build-and-push-app (push) Successful in 1m57s
Build and Push Containers / build-and-push-db (push) Failing after 13s
phase: 122_image_documents
**Phase 122 (image documents) — final verification pass: all green. No code changes were needed; defects found: none.**

**Verified (implementation already complete in working tree, reviewed end-to-end):**
- Toggle (`BOR_IMAGES`/`BOR_IMAGE_EXTENSIONS`/`BOR_IMAGE_DIR`, off by default) + `GET /api/config` `images` flag
- Ingest: bytes digest, `image_dir` persistent copy, `content = summary = vision description` (chat-model call; only text embedded), fail-soft skip + `images_failed` counter
- Serve/display: `/api/documents/{id}/image` route (404 matrix), viewer `<img>` + description, Sources 48px lazy thumbnails, chat inline source figure (alt = summary), agent `read` marker
- Prune guard: images-off syncs never prune `is_image` docs

**Test / lint / coverage (exact commands & outcomes):**
- `uv run pytest` → exit 0 (green; note: pytest 9.1.1 `-q` omits the final count line in output — exit code authoritative)
- `uv run pytest --cov=app --cov-report=term-missing` → **2715 passed, exit 0, TOTAL 99%** (>90% gate)
- `uv run ruff check . && uv run pyright` → "All checks passed!" / "0 errors, 0 warnings, 0 informations"
- `uv run pytest tests/e2e/test_image_documents.py -v --no-cov` → **4 passed, exit 0** (isolation)

**Completion criteria:** (1) images=true → described/embedded/displayed docs: ✅ (E2E + integration) · (2) images=false byte-identical + image docs survive sync: ✅ (E2E negative app + unit/integration) · (3) viewer + chat rendering with alt text; failed description skips + logs, sync completes: ✅ · (4) test/lint/coverage gates: ✅ · (5) commit + phase move: deferred to harness per this pass's rules (working tree left uncommitted).

**Notable deviation (pre-existing, documented in code):** image route uses `require_user` (phase-79 posture, same gate as the document content endpoint) rather than the phase text's "public" parenthetical — matches the endpoint it mirrors.

**Next pending phase:** `123_chat_image_questions`.
2026-09-25 01:54:23 -04:00

239 lines
9.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 six-key set (the phase-39/59/62 endpoint contract, phase 91
# task 03: the retired CSS-file theming's ``theme`` key is gone;
# phase 122 task 01: the ``images`` flag) 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",
"images", "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)