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/`.
225 lines
10 KiB
Python
225 lines
10 KiB
Python
"""Unit: the phase-39 brand layer (BOR_APP_NAME → window.BOR_BRAND).
|
|
|
|
No Python logic exists for this task — the behavior lives in
|
|
``frontend/assets/brand.js`` + the templates + the page scripts, and it
|
|
is E2E-gated by the story suite (task 03). Like the other
|
|
frontend-adjacent unit files, this module pins the JS markers the story
|
|
depends on, so a silent regression in the brand layer is caught without
|
|
a browser.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from pathlib import Path
|
|
|
|
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
|
ROOT = FRONTEND.parent
|
|
BRAND_JS = FRONTEND / "assets" / "brand.js"
|
|
APP_JS = FRONTEND / "assets" / "app.js"
|
|
DOCUMENT_JS = FRONTEND / "assets" / "document.js"
|
|
CONTAINERFILE = ROOT / "Containerfile"
|
|
|
|
#: Every page in the app ships the brand layer (phase 39: "every visible
|
|
#: brand string on every page resolves from one place").
|
|
#: Phase 76 (task 01): the Tuning page is folded into the shell (its
|
|
#: file is deleted) — the shell (index.html) stands in for it here.
|
|
#: Phase 76 (task 02): the RAG + Sources pages are folded too (both
|
|
#: files deleted — the shell stands in for them). Phase 76 (task 03):
|
|
#: history.html is folded too (deleted — the shell stands in for the
|
|
#: History view; all four folded view files are gone).
|
|
HTML_PAGES = (
|
|
"index.html",
|
|
"document.html",
|
|
"login.html",
|
|
"shared.html", # phase 51: the anonymous shared-conversation page
|
|
"doc-edit.html", # phase 59: the admin doc edit screen (flow page)
|
|
)
|
|
|
|
|
|
def _text(path: Path) -> str:
|
|
return path.read_text(encoding="utf-8")
|
|
|
|
|
|
def test_brand_js_is_a_classic_script_with_synchronous_default() -> None:
|
|
"""brand.js is NOT a module (its top level must run at parse time,
|
|
before the page's module scripts evaluate) and sets window.BOR_BRAND
|
|
synchronously, BEFORE the /api/config fetch starts."""
|
|
js = _text(BRAND_JS)
|
|
assert not re.search(r"^\s*import\s", js, re.M), (
|
|
"brand.js must be a classic script — no import statements"
|
|
)
|
|
assert not re.search(r"^\s*export\s", js, re.M), (
|
|
"brand.js must be a classic script — no export statements"
|
|
)
|
|
default_idx = js.find('window.BOR_BRAND = "Brain of Reese"')
|
|
fetch_idx = js.find('fetch("/api/config"')
|
|
assert 0 <= default_idx < fetch_idx, (
|
|
"the default name must be set at top level before the fetch"
|
|
)
|
|
|
|
|
|
def test_brand_js_fetches_api_config_no_store() -> None:
|
|
"""The single source of the name is GET /api/config, never cached —
|
|
a renamed app must not serve a stale name from the HTTP cache — and
|
|
a fetch failure only warns (the loadHealth house style: the page
|
|
never breaks)."""
|
|
js = _text(BRAND_JS)
|
|
assert 'fetch("/api/config", { cache: "no-store" })' in js
|
|
assert "console.warn" in js
|
|
|
|
|
|
def test_brand_js_defers_dom_application_until_ready() -> None:
|
|
"""The DOM passes run on DOMContentLoaded while parsing, otherwise
|
|
immediately (the script sits at the end of <body>)."""
|
|
js = _text(BRAND_JS)
|
|
assert 'document.readyState === "loading"' in js
|
|
assert 'document.addEventListener("DOMContentLoaded", applyBrand)' in js
|
|
|
|
|
|
def test_brand_js_reskins_title_brand_text_prose_and_attributes() -> None:
|
|
"""The four DOM passes (phase 39 task 02): the document title,
|
|
every .brand-text (bold split for "Brain of …" names, plain text
|
|
otherwise — the name is HTML-escaped before innerHTML), a
|
|
TreeWalker over the text nodes (script/style nodes rejected — the
|
|
page source is never rewritten), and the aria-label / placeholder /
|
|
meta content attributes."""
|
|
js = _text(BRAND_JS)
|
|
assert "document.title.replaceAll" in js
|
|
assert ".brand-text" in js
|
|
assert 'name.startsWith("Brain of ")' in js
|
|
assert "escapeHTML" in js, "the name must be escaped before innerHTML"
|
|
assert "el.textContent = name" in js, "non-'Brain of ' names render plain"
|
|
assert "document.createTreeWalker" in js
|
|
assert "NodeFilter.SHOW_TEXT" in js
|
|
assert 'tag === "SCRIPT" || tag === "STYLE"' in js
|
|
assert "replaceAll(BRAND_LITERAL, name)" in js
|
|
for marker in ('"aria-label"', '"placeholder"', "meta[content]"):
|
|
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 two customization STRING keys —
|
|
the #message-input placeholder and every .footer-text node. Phase
|
|
91 (task 03): the retired CSS-file theme link (the old step 7)
|
|
and its id-guard / styles.css-finder / onerror-degrade machinery
|
|
are GONE — color theming is server-side inline injection
|
|
(app/core/theming.py), never a brand.js DOM write.
|
|
No second network call: the keys ride the existing boot fetch."""
|
|
js = _text(BRAND_JS)
|
|
assert js.count('= fetch("/api/config"') == 1, (
|
|
"the 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
|
|
# Phase 91 (task 03): the retired theme-link machinery is absent —
|
|
# no theme key read, no link insertion (the whole step-7 block
|
|
# lived inside the themeName guard — themeName gone, block gone).
|
|
assert "themeName" not in js
|
|
assert 'insertAdjacentElement' 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) {"):
|
|
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)."""
|
|
assert _text(APP_JS).count('"Brain of Reese"') == 1, (
|
|
"app.js: the literal must appear only in the brand() fallback"
|
|
)
|
|
assert _text(DOCUMENT_JS).count('"Brain of Reese"') == 1, (
|
|
"document.js: the literal must appear only in the brand() fallback"
|
|
)
|
|
|
|
|
|
def test_app_js_labels_resolve_the_name_at_call_time() -> None:
|
|
"""Phase 39 task 02: the status labels, the typing label, the
|
|
elapsed-seconds hint and the tool labels read window.BOR_BRAND
|
|
through brand() — at CALL time, so a label set after /api/config
|
|
lands carries the configured name (a module-level const would
|
|
freeze the default)."""
|
|
js = _text(APP_JS)
|
|
assert 'const brand = () => window.BOR_BRAND || "Brain of Reese";' in js
|
|
assert "`${brand()} is thinking`" in js
|
|
assert "`${brand()} is answering`" in js
|
|
assert "`${brand()} is still thinking (${secs}s)`" in js
|
|
assert "`${brand()} is reading ${argument}`" in js
|
|
assert "`${brand()} is listing documents`" in js
|
|
# The frozen module-level literals must be gone (stale-name bug).
|
|
assert '"Brain of Reese is thinking"' not in js
|
|
assert '"Brain of Reese is answering"' not in js
|
|
|
|
|
|
def test_document_js_titles_resolve_the_name() -> None:
|
|
"""The viewer page titles (render + not-found) carry the resolved
|
|
name — the module sets them itself, so it must use brand() (the
|
|
static document.html title is handled by the brand.js title pass)."""
|
|
js = _text(DOCUMENT_JS)
|
|
assert 'document.title = `${doc.title} · ${brand()}`' in js
|
|
assert 'document.title = `Document not found · ${brand()}`' in js
|
|
assert 'window.BOR_BRAND || "Brain of Reese"' in js
|
|
|
|
|
|
def test_every_page_loads_brand_js_before_its_module_script() -> None:
|
|
"""All pages load brand.js as a CLASSIC script, before the page's
|
|
module script (modules execute after parsing — the synchronous
|
|
default must be set first)."""
|
|
for page in HTML_PAGES:
|
|
html = _text(FRONTEND / page)
|
|
# Optional leading slash: root-level pages use "assets/brand.js",
|
|
# but the shared page is served from the NESTED /shared/<token>
|
|
# route, where a relative ref would 404 — it uses "/assets/…".
|
|
m = re.search(r'<script src="(?:/)?assets/brand\.js"></script>', html)
|
|
assert m, f"{page}: brand.js must load"
|
|
brand_idx = m.start()
|
|
module_idx = html.find('<script type="module"')
|
|
assert module_idx != -1, f"{page}: the page module must load"
|
|
assert brand_idx < module_idx, (
|
|
f"{page}: brand.js must load BEFORE the module script"
|
|
)
|
|
assert 'type="module"' not in html[brand_idx : brand_idx + 60], (
|
|
f"{page}: brand.js must be a classic script"
|
|
)
|
|
|
|
|
|
def test_containerfile_builds_brand_js_like_its_classic_sibling() -> None:
|
|
"""The image build minifies brand.js (classic script — minify only,
|
|
no --bundle, exactly like markdown.js) so the container serves it."""
|
|
cf = _text(CONTAINERFILE)
|
|
lines = [ln for ln in cf.splitlines() if "brand.js" in ln]
|
|
assert len(lines) == 1, "one esbuild line for brand.js"
|
|
line = lines[0]
|
|
assert "esbuild ./assets/brand.js" in line
|
|
assert "--minify" in line
|
|
assert "--bundle" not in line, (
|
|
"classic script: minify only (the markdown.js pattern)"
|
|
)
|
|
assert "--outfile=/out/assets/brand.js" in line
|
|
|
|
|
|
def test_no_cdn_in_the_brand_layer() -> None:
|
|
"""AGENTS.md rule 6: the brand layer adds no external reference."""
|
|
js = _text(BRAND_JS)
|
|
assert "http://" not in js and "https://" not in js
|
|
for page in HTML_PAGES:
|
|
html = _text(FRONTEND / page)
|
|
assert 'src="https://' not in html and 'href="https://' not in html
|