**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`.
239 lines
9.8 KiB
Python
239 lines
9.8 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 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)
|