feat(web): customizable placeholder, footer text, and color theme via BOR_* env vars
BOR_INPUT_PLACEHOLDER / BOR_FOOTER_TEXT / BOR_THEME (+ the indigo.css example theme); authoring guide: frontend/assets/themes/README.md, docs: README 'Customizing the look'.
This commit is contained in:
@@ -38,6 +38,15 @@ from app.config import Settings as _Settings # noqa: E402
|
||||
os.environ["BOR_DOCS_REPO"] = ""
|
||||
os.environ["BOR_SUGGESTIONS"] = json.dumps(_Settings.model_fields["suggestions"].default)
|
||||
|
||||
# Phase 62: the same leak class for the new UI customization settings —
|
||||
# an operator's local ``.env`` may legitimately carry
|
||||
# ``BOR_INPUT_PLACEHOLDER`` / ``BOR_FOOTER_TEXT`` / ``BOR_THEME``, and
|
||||
# the default-metadata pins must see the code defaults (derived from
|
||||
# the class fields, same pattern as the suggestions line above).
|
||||
os.environ["BOR_INPUT_PLACEHOLDER"] = _Settings.model_fields["input_placeholder"].default
|
||||
os.environ["BOR_FOOTER_TEXT"] = _Settings.model_fields["footer_text"].default
|
||||
os.environ["BOR_THEME"] = _Settings.model_fields["theme"].default
|
||||
|
||||
from app.db import SessionLocal, db_available # noqa: E402
|
||||
from app.main import app as fastapi_app # noqa: E402
|
||||
|
||||
|
||||
@@ -112,6 +112,16 @@ def app_server(mock_llm: int) -> Iterator[str]:
|
||||
env["BOR_SUGGESTIONS"] = json.dumps(
|
||||
_Settings.model_fields["suggestions"].default
|
||||
)
|
||||
# Phase 62: the same leak class for the new UI customization
|
||||
# settings — an operator's local (gitignored) ``.env`` may
|
||||
# legitimately carry ``BOR_INPUT_PLACEHOLDER`` / ``BOR_FOOTER_TEXT``
|
||||
# / ``BOR_THEME``, and the byte-identical default contract (task
|
||||
# 05's ``test_default_server_is_byte_identical``) must see the code
|
||||
# defaults (derived from the class fields, never drifts from
|
||||
# ``app/config.py``).
|
||||
env["BOR_INPUT_PLACEHOLDER"] = _Settings.model_fields["input_placeholder"].default
|
||||
env["BOR_FOOTER_TEXT"] = _Settings.model_fields["footer_text"].default
|
||||
env["BOR_THEME"] = _Settings.model_fields["theme"].default
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "-m", "uvicorn", "app.main:app",
|
||||
"--host", "127.0.0.1", "--port", str(APP_PORT), "--log-level", "warning"],
|
||||
|
||||
@@ -146,16 +146,28 @@ def test_api_config_serves_both_names(testy_server: str, app_server: str) -> Non
|
||||
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.
|
||||
assert set(body) == {"app_name", "version", "docs_repo_configured"}
|
||||
# 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).
|
||||
# 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
|
||||
assert r2.json()["app_name"] == DEFAULT_NAME
|
||||
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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
"""Phase 62 E2E (Playwright): UI customization — placeholder, footer, theme.
|
||||
|
||||
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``, ``BOR_THEME`` — A4/A5).
|
||||
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 ALL THREE customization vars set shows the
|
||||
custom look end-to-end: the ``GET /api/config`` overrides, the chat
|
||||
composer placeholder (``#message-input``), the footer line on multiple
|
||||
pages (``.footer-text``), and the computed ``:root --brand`` from the
|
||||
inserted ``<link id="theme-override" href="/assets/themes/indigo.css">``
|
||||
(the indigo example theme, ``--brand: #818cf8``);
|
||||
* with NOTHING set the shared conftest server is byte-identical to the
|
||||
phase-39/61 no-op contract: the default placeholder, the default
|
||||
footer, NO theme link, the built-in ``--brand: #f43f5e``;
|
||||
* a malformed ``BOR_THEME`` (``../evil.css``) refuses startup loudly,
|
||||
naming the value — the phase-56 fail-loud style, proven end-to-end
|
||||
via a real boot attempt, not just the validator unit test.
|
||||
|
||||
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 three changes: port
|
||||
``APP_PORT + 2`` (the brand suite owns ``APP_PORT + 1`` — do not
|
||||
collide) and the three env overrides. Every assertion is settled-state:
|
||||
Playwright's ``expect`` retries ride out the brand.js ``/api/config``
|
||||
fetch (the three keys are applied asynchronously, in the SAME fetch's
|
||||
settled ``.then`` — no second network call). The one absence assertion
|
||||
(no ``#theme-override`` on the default server) first waits for
|
||||
``window.BOR_CONFIG_PROMISE`` to settle, so it cannot race the fetch.
|
||||
|
||||
Test → contract mapping (Playwright Mapping Rule):
|
||||
1. ``test_config_serves_the_overrides``
|
||||
2. ``test_chat_page_shows_custom_placeholder_footer_theme``
|
||||
3. ``test_footer_text_applies_on_other_pages``
|
||||
4. ``test_default_server_is_byte_identical``
|
||||
5. ``test_malformed_theme_refuses_startup``
|
||||
"""
|
||||
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}"
|
||||
MALFORMED_PORT = APP_PORT + 3 # the refused boot never starts listening
|
||||
|
||||
# The three overrides (task 05) — the whole story:
|
||||
CUSTOM_PLACEHOLDER = "Ask the archive…"
|
||||
CUSTOM_FOOTER = "Custom footer line"
|
||||
CUSTOM_THEME = "indigo.css"
|
||||
INDIGO_BRAND = "#818cf8" # indigo.css's --brand (the computed token)
|
||||
|
||||
# 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 all three customization
|
||||
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 three changes: port
|
||||
``APP_PORT + 2`` (the brand suite owns ``APP_PORT + 1``) and the
|
||||
three 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 whole story:
|
||||
env["BOR_INPUT_PLACEHOLDER"] = CUSTOM_PLACEHOLDER
|
||||
env["BOR_FOOTER_TEXT"] = CUSTOM_FOOTER
|
||||
env["BOR_THEME"] = CUSTOM_THEME
|
||||
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 wait_for_brand_settled(page: Page, timeout: int = 15_000) -> None:
|
||||
"""Wait for the brand layer's boot fetch to settle.
|
||||
|
||||
The three customization keys are applied asynchronously, in the
|
||||
settled ``/api/config`` promise's ``.then`` — absence assertions
|
||||
(no ``#theme-override``) must not race that fetch. The promise
|
||||
NEVER rejects (the brand.js contract), so its resolution means the
|
||||
DOM pass has already run: ``applyBrand`` registered its callback on
|
||||
the same promise at page load, before this wait's callback, and
|
||||
promise callbacks run in registration order."""
|
||||
page.wait_for_function(
|
||||
"() => window.BOR_CONFIG_PROMISE.then(() => true)",
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
|
||||
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: ``#818cf8`` is
|
||||
exactly what indigo.css declares, ``#f43f5e`` exactly what
|
||||
styles.css declares (the built-in)."""
|
||||
page.wait_for_function(
|
||||
"""(expected) =>
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--brand")
|
||||
.trim() === expected""",
|
||||
arg=expected,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. The endpoint the brand layer reads — the three overrides, the
|
||||
# six-key set, and the theme file served from the dev static dir
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
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) with the
|
||||
# three 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", "theme",
|
||||
}
|
||||
assert body["app_name"] == DEFAULT_NAME
|
||||
assert body["input_placeholder"] == CUSTOM_PLACEHOLDER
|
||||
assert body["footer_text"] == CUSTOM_FOOTER
|
||||
assert body["theme"] == CUSTOM_THEME
|
||||
|
||||
# Served in dev from the static dir (the no-CDN rule): the theme
|
||||
# file the boot fetch names is reachable at its served path, and
|
||||
# it is the indigo example (its --brand is the E2E's theme proof).
|
||||
r2 = httpx.get(f"{CUSTOM_URL}/assets/themes/{CUSTOM_THEME}", timeout=5)
|
||||
assert r2.status_code == 200
|
||||
assert f"--brand: {INDIGO_BRAND}" in r2.text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. The chat page — placeholder, footer, the theme link + effect
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_chat_page_shows_custom_placeholder_footer_theme(
|
||||
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
|
||||
)
|
||||
# 7. The theme link in <head> — rel=stylesheet, the served path.
|
||||
expect(page.locator('head link#theme-override[rel="stylesheet"]')).to_have_attribute(
|
||||
"href", f"/assets/themes/{CUSTOM_THEME}", timeout=15_000
|
||||
)
|
||||
# And it takes effect: the computed :root --brand is the indigo
|
||||
# value (the built-in #f43f5e means the theme never loaded).
|
||||
expect_brand_var(page, INDIGO_BRAND)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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
|
||||
)
|
||||
# The theme link rides in <head> on every page too.
|
||||
expect(page.locator('head link#theme-override[rel="stylesheet"]')).to_have_attribute(
|
||||
"href", f"/assets/themes/{CUSTOM_THEME}", 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 + "/")
|
||||
# Settle the boot fetch BEFORE the absence assertion — it must not
|
||||
# race the (absent) theme-link insertion.
|
||||
wait_for_brand_settled(page)
|
||||
# The phase-39/61 no-op contract: the template defaults stand.
|
||||
expect(page.locator("#message-input")).to_have_attribute(
|
||||
"placeholder", DEFAULT_PLACEHOLDER
|
||||
)
|
||||
expect(page.locator(".footer-text").first).to_have_text(DEFAULT_FOOTER)
|
||||
# With BOR_THEME unset the brand layer inserts NO theme link:
|
||||
assert page.locator("#theme-override").count() == 0, (
|
||||
"with BOR_THEME unset the brand layer must NOT insert a theme "
|
||||
"link (the byte-identical no-op contract)"
|
||||
)
|
||||
# The built-in dark-tech palette stands.
|
||||
expect_brand_var(page, BUILTIN_BRAND)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. The fail-loud boot check — a malformed BOR_THEME refuses startup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_malformed_theme_refuses_startup() -> None:
|
||||
"""A malformed ``BOR_THEME`` (``../evil.css`` — a path, exactly the
|
||||
shape the A5 lock names as illegal) kills startup with the value
|
||||
NAMED on stderr (the phase-56 fail-loud house style), proven
|
||||
end-to-end via a real uvicorn boot attempt: the process exits
|
||||
non-zero within the timeout without ever starting to listen.
|
||||
|
||||
``app.main`` builds its settings at import time
|
||||
(``settings = get_settings()``), so the validator fires during the
|
||||
ASGI app import — before admin auth, before the port binds."""
|
||||
env = dict(os.environ)
|
||||
env.pop("DEBUGPY", None)
|
||||
env["BOR_ENVIRONMENT"] = "e2e"
|
||||
env["BOR_STATIC_DIR"] = str(REPO / "frontend")
|
||||
env["BOR_RELEVANCE_THRESHOLD"] = "0.30"
|
||||
env.setdefault(
|
||||
"BOR_DATABASE_URL",
|
||||
"postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese",
|
||||
)
|
||||
env["BOR_ADMIN_PASSWORD"] = ADMIN_PASSWORD
|
||||
env["BOR_SESSION_SECRET"] = SESSION_SECRET
|
||||
# The whole point: a malformed theme value.
|
||||
env["BOR_THEME"] = "../evil.css"
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "-m", "uvicorn", "app.main:app",
|
||||
"--host", "127.0.0.1", "--port", str(MALFORMED_PORT), "--log-level", "warning"],
|
||||
cwd=REPO,
|
||||
env=env,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
try:
|
||||
proc.wait(timeout=60)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
proc.wait()
|
||||
pytest.fail(
|
||||
"the app kept running with BOR_THEME='../evil.css' — a "
|
||||
"malformed theme must refuse startup, not silently 404"
|
||||
)
|
||||
assert proc.returncode != 0, (
|
||||
"the malformed BOR_THEME must make uvicorn exit non-zero"
|
||||
)
|
||||
stderr = proc.stderr.read() if proc.stderr else ""
|
||||
# Fail-loud names the offending value (phase-56 house style):
|
||||
assert "'../evil.css'" in stderr, (
|
||||
f"stderr must name the offending value, got tail: {stderr[-2000:]}"
|
||||
)
|
||||
assert "theme must be a bare .css filename" in stderr
|
||||
@@ -18,16 +18,26 @@ def test_health_reports_ok(client) -> None:
|
||||
|
||||
|
||||
def test_config_returns_default_app_metadata(client) -> None:
|
||||
"""GET /api/config is public (anonymous) and returns exactly three
|
||||
keys — the phase-39 app metadata + the phase-59 docs flag (inert
|
||||
false while BOR_DOCS_REPO is empty — the "Save as doc" gating)."""
|
||||
"""GET /api/config is public (anonymous) and returns exactly six
|
||||
keys — the phase-39 app metadata, the phase-59 docs flag (inert
|
||||
false while BOR_DOCS_REPO is empty — the "Save as doc" gating),
|
||||
and the phase-62 UI customization strings (composer placeholder,
|
||||
footer line, theme file name)."""
|
||||
r = client.get("/api/config")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert set(body) == {"app_name", "version", "docs_repo_configured"}
|
||||
assert set(body) == {
|
||||
"app_name", "version", "docs_repo_configured",
|
||||
"input_placeholder", "footer_text", "theme",
|
||||
}
|
||||
assert body["app_name"] == "Brain of Reese"
|
||||
assert body["version"] == get_settings().app_version
|
||||
assert body["docs_repo_configured"] is False
|
||||
# Phase 62: UNSET => the phase-61 neutral copy stands (the
|
||||
# byte-identical contract); an empty theme = the built-in palette.
|
||||
assert body["input_placeholder"] == "Ask me anything…"
|
||||
assert body["footer_text"] == "Powered by self-hosted models"
|
||||
assert body["theme"] == ""
|
||||
|
||||
|
||||
def test_config_follows_overridden_app_name(client) -> None:
|
||||
@@ -42,7 +52,10 @@ def test_config_follows_overridden_app_name(client) -> None:
|
||||
r = client.get("/api/config")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert set(body) == {"app_name", "version", "docs_repo_configured"}
|
||||
assert set(body) == {
|
||||
"app_name", "version", "docs_repo_configured",
|
||||
"input_placeholder", "footer_text", "theme",
|
||||
}
|
||||
assert body["app_name"] == "Brain of Testy"
|
||||
assert body["version"] == "0.1.0"
|
||||
assert body["docs_repo_configured"] is False
|
||||
@@ -50,6 +63,34 @@ def test_config_follows_overridden_app_name(client) -> None:
|
||||
fastapi_app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_config_serves_ui_customization_overrides(client) -> None:
|
||||
"""Phase 62: the three UI customization keys mirror Settings
|
||||
overrides (``BOR_INPUT_PLACEHOLDER`` / ``BOR_FOOTER_TEXT`` /
|
||||
``BOR_THEME``) verbatim — the values the frontend brand layer
|
||||
applies at boot, so this dict is the whole contract."""
|
||||
from app.config import Settings
|
||||
from app.main import app as fastapi_app
|
||||
|
||||
fastapi_app.dependency_overrides[get_settings] = lambda: Settings(
|
||||
input_placeholder="Ask the vault…",
|
||||
footer_text="Powered by my own models",
|
||||
theme="indigo.css",
|
||||
)
|
||||
try:
|
||||
r = client.get("/api/config")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert set(body) == {
|
||||
"app_name", "version", "docs_repo_configured",
|
||||
"input_placeholder", "footer_text", "theme",
|
||||
}
|
||||
assert body["input_placeholder"] == "Ask the vault…"
|
||||
assert body["footer_text"] == "Powered by my own models"
|
||||
assert body["theme"] == "indigo.css"
|
||||
finally:
|
||||
fastapi_app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_config_docs_flag_tracks_settings(client) -> None:
|
||||
"""Phase 59 (task 05): ``docs_repo_configured`` mirrors
|
||||
``settings.docs_configured`` — a real bool (never a truthy string)
|
||||
|
||||
@@ -397,3 +397,77 @@ def test_docs_branchs_garbage_ignored_when_repo_unset(
|
||||
s = _settings()
|
||||
assert s.docs_configured is False
|
||||
assert s.docs_branch == "bor docs.." # stored verbatim, never used
|
||||
|
||||
|
||||
# --- UI customization (phase 62, TODO L3) ---
|
||||
|
||||
|
||||
def test_ui_customization_defaults_are_the_phase_61_copy() -> None:
|
||||
"""UNSET => byte-identical to the phase-61 neutral UI: the locked
|
||||
phase-61 copy is the DEFAULT (composer placeholder + footer line),
|
||||
and an empty theme = the built-in dark-tech palette."""
|
||||
s = _settings()
|
||||
assert s.input_placeholder == "Ask me anything…"
|
||||
assert s.footer_text == "Powered by self-hosted models"
|
||||
assert s.theme == ""
|
||||
|
||||
|
||||
def test_ui_customization_env_overrides(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""The three settings honor their ``BOR_`` env vars
|
||||
(``BOR_INPUT_PLACEHOLDER`` / ``BOR_FOOTER_TEXT`` / ``BOR_THEME``);
|
||||
placeholder/footer accept any string (empty is legal — the brand
|
||||
layer then keeps the template default)."""
|
||||
monkeypatch.setenv("BOR_INPUT_PLACEHOLDER", "Ask the vault…")
|
||||
monkeypatch.setenv("BOR_FOOTER_TEXT", "Powered by my own models")
|
||||
monkeypatch.setenv("BOR_THEME", "indigo.css")
|
||||
s = _settings()
|
||||
assert s.input_placeholder == "Ask the vault…"
|
||||
assert s.footer_text == "Powered by my own models"
|
||||
assert s.theme == "indigo.css"
|
||||
monkeypatch.setenv("BOR_INPUT_PLACEHOLDER", "")
|
||||
assert _settings().input_placeholder == "" # empty stands
|
||||
|
||||
|
||||
def test_theme_validator_accepts_empty_and_bare_css_filename() -> None:
|
||||
"""Phase 62 (A5): empty = the built-in palette; a bare lowercase
|
||||
``.css`` filename (the ``indigo.css`` example) is the only
|
||||
non-empty shape — dashes/underscores/digits are legal tokens."""
|
||||
assert _settings().theme == "" # "" passes
|
||||
assert _settings(theme="indigo.css").theme == "indigo.css"
|
||||
assert _settings(theme="dark-2026_v2.css").theme == "dark-2026_v2.css"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("bad", "match"),
|
||||
[
|
||||
# uppercase — the shape is lowercase-only
|
||||
("Indigo.css", "Indigo.css"),
|
||||
# path escape — a theme is a filename, never a path
|
||||
("../evil.css", r"\.\./evil\.css"),
|
||||
("a/b.css", r"a/b\.css"),
|
||||
("/abs.css", r"got '/abs\.css'"),
|
||||
# a missing extension is not a theme file
|
||||
("indigo", r"got 'indigo'"), # must not match the example text
|
||||
],
|
||||
)
|
||||
def test_theme_validator_rejects_malformed_naming_the_value(
|
||||
bad: str,
|
||||
match: str,
|
||||
) -> None:
|
||||
"""A typo in ``BOR_THEME`` must kill startup, not silently 404 at
|
||||
runtime — the rejection names the offending value (the phase-56
|
||||
fail-loud house style) alongside the allowed shape."""
|
||||
with pytest.raises(ValidationError, match=match):
|
||||
_settings(theme=bad)
|
||||
|
||||
|
||||
def test_bor_theme_env_malformed_fails_startup_naming_value(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The startup path: a malformed ``BOR_THEME`` in the environment
|
||||
fails Settings construction loudly (the app builds its settings at
|
||||
import time, so this is a refused boot), naming the value — the
|
||||
E2E boots-check lands in task 05."""
|
||||
monkeypatch.setenv("BOR_THEME", "../evil.css")
|
||||
with pytest.raises(ValidationError, match=r"\.\./evil\.css"):
|
||||
_settings()
|
||||
|
||||
@@ -95,6 +95,60 @@ def test_brand_js_reskins_title_brand_text_prose_and_attributes() -> None:
|
||||
assert marker in js, f"the attribute pass must cover {marker}"
|
||||
|
||||
|
||||
def test_brand_js_applies_phase_62_customization_from_the_same_fetch() -> None:
|
||||
"""Phase 62 (owner-locked 2026-09-01, TODO L3): the SAME settled
|
||||
/api/config answer also drives the three customization keys — the
|
||||
#message-input placeholder, every .footer-text node, and the theme
|
||||
stylesheet link (inserted right after the styles.css link, guarded
|
||||
by #theme-override, degrading with a console.warn on 404 — A5).
|
||||
No second network call: the keys ride the existing boot fetch."""
|
||||
js = _text(BRAND_JS)
|
||||
assert js.count('= fetch("/api/config"') == 1, (
|
||||
"the three keys must ride the existing boot fetch — no new call"
|
||||
)
|
||||
# 5. The composer placeholder (chat page only — the null guard
|
||||
# no-ops on every other page).
|
||||
assert 'document.querySelector("#message-input")' in js
|
||||
assert "input_placeholder" in js
|
||||
# 6. The footer line on all 9 pages (the phase-61 hook) — via
|
||||
# textContent: an operator string can't inject markup.
|
||||
assert 'document.querySelectorAll(".footer-text")' in js
|
||||
assert "footer_text" in js
|
||||
# 7. The theme link: /assets/themes/<name>, inserted right after
|
||||
# the styles.css link, tagged #theme-override (the idempotency
|
||||
# guard), with the A5 degradation warn.
|
||||
assert '"/assets/themes/"' in js
|
||||
assert 'link.id = "theme-override"' in js
|
||||
assert 'getElementById("theme-override")' in js
|
||||
assert 'insertAdjacentElement("afterend", link)' in js
|
||||
assert "link.onerror" in js
|
||||
assert '"brand: theme " + themeName' in js
|
||||
# The styles.css finder must survive the phase-33/54 cache-bust
|
||||
# rewrite: the SERVED HTML carries the asset ref with a
|
||||
# ?v=<token> query (and el.href is the absolute URL), so the match
|
||||
# has to run on the RAW attribute path with query/fragment
|
||||
# stripped — el.href.endsWith(…) would silently skip the insertion
|
||||
# (the theme never applied; found by the task-05 E2E).
|
||||
assert 'getAttribute("href")' in js
|
||||
assert "split(/[?#]/)[0]" in js
|
||||
assert 'endsWith("styles.css")' in js
|
||||
assert "el.href.endsWith" not in js
|
||||
# The empty-skip no-op contract: each key is guarded before any
|
||||
# DOM write, so an unset deployment stays byte-identical.
|
||||
for guard in ("if (placeholder) {", "if (footerText) {", "if (themeName) {"):
|
||||
assert guard in js, (
|
||||
f"an empty value must skip its application ({guard})"
|
||||
)
|
||||
# Independence: the phase-62 block sits AFTER the app_name passes
|
||||
# in the same .then — never gated by the name.
|
||||
assert js.index("// 4. Attributes:") < js.index("// Phase 62"), (
|
||||
"the customization keys must apply after the app_name block, "
|
||||
"even when the name is the default/empty"
|
||||
)
|
||||
# The app_name literal default pin still holds.
|
||||
assert 'window.BOR_BRAND = "Brain of Reese"' in js
|
||||
|
||||
|
||||
def test_page_scripts_keep_the_default_literal_exactly_once() -> None:
|
||||
"""The fallback literal lives in the page scripts' brand() reads —
|
||||
exactly one copy per file (a second copy could drift out of sync)."""
|
||||
|
||||
@@ -51,7 +51,12 @@ def test_app_config_dict_carries_the_docs_flag() -> None:
|
||||
|
||||
s = _settings()
|
||||
body = app_config(s)
|
||||
assert set(body) == {"app_name", "version", "docs_repo_configured"}
|
||||
# Phase 62 (task 01): the response grew to the six-key set — the
|
||||
# phase-62 UI customization keys ride the SAME endpoint.
|
||||
assert set(body) == {
|
||||
"app_name", "version", "docs_repo_configured",
|
||||
"input_placeholder", "footer_text", "theme",
|
||||
}
|
||||
assert body["docs_repo_configured"] is s.docs_configured
|
||||
assert body["docs_repo_configured"] is False
|
||||
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
"""Unit: the phase-62 example theme (``frontend/assets/themes/``) and
|
||||
the Containerfile line that ships it (A7).
|
||||
|
||||
No Python logic exists for this task — the mechanism lives in
|
||||
``brand.js`` (pinned by test_frontend_brand.py) and the theme is a
|
||||
drop-in stylesheet. Like the other frontend-adjacent unit files, this
|
||||
module pins the assets as text, so a silent regression (a theme file
|
||||
gaining a selector, a declaration drifting, the Containerfile line
|
||||
vanishing) is caught without a browser. The browser-visible layer
|
||||
(computed ``--brand``, the inserted ``<link>``) is E2E-gated by
|
||||
``tests/e2e/test_ui_customization.py`` (task 05).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
FRONTEND = REPO_ROOT / "frontend"
|
||||
THEMES = FRONTEND / "assets" / "themes"
|
||||
INDIGO = THEMES / "indigo.css"
|
||||
GUIDE = THEMES / "README.md"
|
||||
CONTAINERFILE = REPO_ROOT / "Containerfile"
|
||||
|
||||
#: The 8 identity variables a theme may override — and the EXACT set
|
||||
#: indigo.css ships (the semantic families accent/ok/err are states,
|
||||
#: not identity: a theme that overrides them stops being honest).
|
||||
IDENTITY_VARS = (
|
||||
"--bg",
|
||||
"--surface",
|
||||
"--ink",
|
||||
"--ink-soft",
|
||||
"--line",
|
||||
"--brand",
|
||||
"--brand-soft",
|
||||
"--brand-ink",
|
||||
)
|
||||
|
||||
INDIGO_VALUES: dict[str, str] = {
|
||||
"--bg": "#0a0e1a",
|
||||
"--surface": "#111726",
|
||||
"--ink": "#e6e9f0",
|
||||
"--ink-soft": "#a8b0c8",
|
||||
"--line": "#232c44",
|
||||
"--brand": "#818cf8",
|
||||
"--brand-soft": "#1a1f38",
|
||||
"--brand-ink": "#c7d2fe",
|
||||
}
|
||||
|
||||
|
||||
def _text(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _strip_comments(css: str) -> str:
|
||||
"""Drop ``/* … */`` comments — the pins assert against declarations,
|
||||
not prose."""
|
||||
return re.sub(r"/\*.*?\*/", "", css, flags=re.S)
|
||||
|
||||
|
||||
def _declarations(css: str) -> dict[str, str]:
|
||||
"""The ``--name: value`` declarations of the (single) ``:root``
|
||||
block, in file order."""
|
||||
return dict(re.findall(r"(--[a-z-]+)\s*:\s*([^;]+);", css))
|
||||
|
||||
|
||||
def test_example_theme_files_exist() -> None:
|
||||
"""The example theme and its authoring guide ship in the static
|
||||
dir (served at /assets/themes/… in dev AND in the image)."""
|
||||
assert INDIGO.is_file(), f"missing example theme: {INDIGO}"
|
||||
assert GUIDE.is_file(), f"missing authoring guide: {GUIDE}"
|
||||
|
||||
|
||||
def test_indigo_starts_with_a_single_root_block_and_nothing_else() -> None:
|
||||
"""The whole file is ONE ``:root`` block (the cascade is the entire
|
||||
mechanism): after stripping comments the first non-whitespace
|
||||
content is ``:root``, and no other rule, selector, or declaration
|
||||
exists anywhere in the file."""
|
||||
css = _strip_comments(_text(INDIGO))
|
||||
assert css.lstrip().startswith(":root"), (
|
||||
"indigo.css must start with the :root block (after its header "
|
||||
"comment) — nothing may precede it"
|
||||
)
|
||||
assert re.fullmatch(r"\s*:root\s*\{[^{}]*\}\s*", css, re.S) is not None, (
|
||||
"indigo.css must be exactly one :root block — no selectors, "
|
||||
"no @media, no nested or extra rules"
|
||||
)
|
||||
|
||||
|
||||
def test_indigo_overrides_exactly_the_eight_identity_variables() -> None:
|
||||
"""EXACTLY the 8 identity overrides with the locked values — no
|
||||
other declarations (a 9th declaration here would be the theme
|
||||
reaching past the palette), and the semantic families
|
||||
(accent/ok/err) must be untouched (they encode states)."""
|
||||
decls = _declarations(_strip_comments(_text(INDIGO)))
|
||||
assert set(decls) == set(IDENTITY_VARS), (
|
||||
f"indigo.css must override exactly the 8 identity variables, got "
|
||||
f"{sorted(decls)}"
|
||||
)
|
||||
for name in IDENTITY_VARS:
|
||||
assert decls[name].strip() == INDIGO_VALUES[name], (
|
||||
f"{name} drifted from the locked value "
|
||||
f"{INDIGO_VALUES[name]!r}, got {decls[name].strip()!r}"
|
||||
)
|
||||
for family in ("--accent-", "--ok-", "--err-"):
|
||||
assert not any(k.startswith(family) for k in decls), (
|
||||
f"semantic {family}* variables must stay the built-in "
|
||||
f"theme (they encode states)"
|
||||
)
|
||||
|
||||
|
||||
def test_indigo_identity_pairs_meet_wcag_aa() -> None:
|
||||
"""The five identity text/background pairs, computed from the file's
|
||||
OWN hex values (not re-typed), each meet WCAG 2.1 AA (>= 4.5:1) —
|
||||
AGENTS.md rule 5. The pairs are the ones the layout actually pairs:
|
||||
ink on bg/surface, ink-soft on surface, the dark bg ink on brand
|
||||
(text on brand buttons is --bg, never white — the built-in's
|
||||
documented 3.7:1 trap), brand-ink on surface."""
|
||||
decls = _declarations(_strip_comments(_text(INDIGO)))
|
||||
|
||||
def lum(hexcolor: str) -> float:
|
||||
h = hexcolor.lstrip("#")
|
||||
chans = (int(h[i : i + 2], 16) / 255.0 for i in (0, 2, 4))
|
||||
lin = [
|
||||
c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
|
||||
for c in chans
|
||||
]
|
||||
r, g, b = lin
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b
|
||||
|
||||
def ratio(fg: str, bg: str) -> float:
|
||||
l1, l2 = lum(fg), lum(bg)
|
||||
return (max(l1, l2) + 0.05) / (min(l1, l2) + 0.05)
|
||||
|
||||
pairs = (
|
||||
("ink on bg", decls["--ink"], decls["--bg"]),
|
||||
("ink on surface", decls["--ink"], decls["--surface"]),
|
||||
("ink-soft on surface", decls["--ink-soft"], decls["--surface"]),
|
||||
("bg ink on brand", decls["--bg"], decls["--brand"]),
|
||||
("brand-ink on surface", decls["--brand-ink"], decls["--surface"]),
|
||||
)
|
||||
for name, fg, bg in pairs:
|
||||
r = ratio(fg, bg)
|
||||
assert r >= 4.5, f"{name}: {r:.2f}:1 < 4.5:1 (WCAG 2.1 AA)"
|
||||
|
||||
|
||||
def test_containerfile_ships_the_whole_themes_directory() -> None:
|
||||
"""A7: stage 1 copies the WHOLE themes directory (no per-file
|
||||
esbuild — a future theme file needs no Containerfile edit), and it
|
||||
does so AFTER the styles.css minify line (so the served /assets/
|
||||
tree is complete before the pages cp)."""
|
||||
cf = _text(CONTAINERFILE)
|
||||
stage1 = cf.split("AS frontend", 1)[1].split("\nFROM", 1)[0]
|
||||
lines = stage1.splitlines()
|
||||
cp_idxs = [
|
||||
i
|
||||
for i, ln in enumerate(lines)
|
||||
if re.search(r"\bcp\s+-r\s+\./assets/themes\s+/out/assets/themes\b", ln)
|
||||
]
|
||||
assert len(cp_idxs) == 1, (
|
||||
"stage 1 must ship the themes directory with exactly one "
|
||||
"'cp -r ./assets/themes /out/assets/themes' line"
|
||||
)
|
||||
styles_idxs = [
|
||||
i for i, ln in enumerate(lines) if "esbuild ./assets/styles.css" in ln
|
||||
]
|
||||
assert len(styles_idxs) == 1, "stage 1 must minify styles.css"
|
||||
assert cp_idxs[0] > styles_idxs[0], (
|
||||
"the themes cp must come AFTER the styles.css minify line"
|
||||
)
|
||||
cp_line = lines[cp_idxs[0]]
|
||||
assert "--bundle" not in cp_line and "esbuild" not in cp_line, (
|
||||
"A7: the themes directory is copied verbatim — no per-file "
|
||||
"esbuild minify"
|
||||
)
|
||||
|
||||
|
||||
def test_authoring_guide_pins_the_contract() -> None:
|
||||
"""The guide documents the load path (BOR_THEME → /api/config →
|
||||
brand.js link after styles.css), the filename validator regex, the
|
||||
8-variable table, the 4.5:1 bar, the never-white-on-brand trap,
|
||||
and the A7 rebuild story (a new file needs no Containerfile edit)."""
|
||||
guide = _text(GUIDE)
|
||||
for marker in (
|
||||
"BOR_THEME",
|
||||
"/api/config",
|
||||
"styles.css",
|
||||
r"^[a-z0-9_-]+\.css$",
|
||||
"4.5:1",
|
||||
"white-on-brand",
|
||||
"cp -r ./assets/themes /out/assets/themes",
|
||||
):
|
||||
assert marker in guide, f"themes/README.md must document {marker!r}"
|
||||
for var in IDENTITY_VARS:
|
||||
assert var in guide, f"the variable table must list {var}"
|
||||
Reference in New Issue
Block a user