**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`.
258 lines
11 KiB
Python
258 lines
11 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 with the UI-customization keys; phase 91
|
|
# (task 03) deleted the retired CSS-file theming's ``theme`` key;
|
|
# phase 122 (task 01) added the ``images`` flag (inert false here
|
|
# — neither instance sets BOR_IMAGES) — the six keys below are
|
|
# the entire contract (this suite's instances carry no
|
|
# UI-customization overrides, so the string keys are their
|
|
# defaults).
|
|
assert set(body) == {
|
|
"app_name", "version", "docs_repo_configured",
|
|
"images", "input_placeholder", "footer_text",
|
|
}
|
|
assert body["app_name"] == TESTY_NAME
|
|
assert body["docs_repo_configured"] is False
|
|
assert body["images"] is False
|
|
|
|
# The shared conftest instance keeps the default (the other
|
|
# suites' title/label contract rides on it) — and its key set
|
|
# tracks the endpoint contract (six keys after phase 122, task 01).
|
|
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",
|
|
"images", "input_placeholder", "footer_text",
|
|
}
|
|
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>")
|