feat(brand): configurable app name — BOR_APP_NAME drives /api/config + the frontend brand layer
Build and Push Containers / build-and-push (push) Successful in 1m50s
Build and Push Containers / build-and-push (push) Successful in 1m50s
One env var (BOR_APP_NAME, default "Brain of Reese") now drives the app's
display name everywhere (TODO.md L12 — owner ask: "a way to customize the
name for 'Brain of'. Should be an env var."). The existing app_name setting
is the source of truth (phase locked decision — no new variable, no rename);
with the variable unset the app is byte-identical to before.
Endpoint (A10 public/stateless, no secrets):
GET /api/config → exactly {app_name, version} (app/api/config.py, the
health.py pattern; registered before the static mount). Integration tests:
anonymous 200, default values, a Settings override follows, key set is
exactly two keys — no other setting may leak in later.
Frontend brand layer (A11 — runtime fetch, static templates stay static):
assets/brand.js — a CLASSIC script, first on all six pages, so its top
level runs at parse time: window.BOR_BRAND = "Brain of Reese"
synchronously (the default renders immediately, no blank flash), then a
no-store fetch of /api/config applies the name — document.title (global
replace), every .brand-text (a name starting "Brain of " keeps the bold
split Brain of <strong>rest</strong>, any other name renders plain; the
operator-controlled name is HTML-escaped before innerHTML), a TreeWalker
over text nodes (script/style rejected — page source never rewritten),
and the aria-label/placeholder/meta-content attributes. Fetch failure
keeps the default + console.warn (the loadHealth house style).
app.js (status labels, typing label, elapsed-hint aria, tool labels) and
document.js (viewer titles) read window.BOR_BRAND at CALL time via
brand() — a label set after the fetch lands carries the configured name.
Containerfile: esbuild minify line for brand.js (classic, like markdown.js);
the phase-33 ?v= cache-busting picks the new asset ref up automatically.
E2E (A16 — one story, one file, isolated): test_configurable_brand.py boots
a SECOND app instance (same DB/mock-LLM/admin-auth env block, port APP_PORT+1,
BOR_APP_NAME="Brain of Testy") — the shared conftest server keeps the
default name so every other suite's title/label assertions stay untouched —
and asserts /api/config on both instances, the index title/brand/greeting/
#messages aria-label, the sources + login page titles, and one pre-token
chat turn (think out loud marker) whose #send-status reads "Brain of Testy
is thinking"; the no-op regression pins the shared server's default bytes.
Docs: .env.example App section + README configuration reference — what it
affects (titles, header brand, status labels, aria text), the default, the
bold-split rendering rule.
Gates: 695 unit+integration passed, app/ coverage 99% (>90%), story E2E
green in isolation (two consecutive runs), brand-string suites (smoke,
shared header, header consistency, chat persistence) green, ruff + pyright
clean.
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
"""Phase 39 E2E (Playwright): the configurable app name (``BOR_APP_NAME``).
|
||||
|
||||
Story: ``.agent/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.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()
|
||||
assert set(body) == {"app_name", "version"}
|
||||
assert body["app_name"] == TESTY_NAME
|
||||
|
||||
# The shared conftest instance keeps the default (the other
|
||||
# suites' title/label contract rides on it).
|
||||
r2 = httpx.get(f"{app_server}/api/config", timeout=5)
|
||||
assert r2.status_code == 200
|
||||
assert r2.json()["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:
|
||||
page.goto(testy_server + "/")
|
||||
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>")
|
||||
@@ -17,6 +17,35 @@ def test_health_reports_ok(client) -> None:
|
||||
assert body["version"]
|
||||
|
||||
|
||||
def test_config_returns_default_app_metadata(client) -> None:
|
||||
"""GET /api/config is public (anonymous) and returns exactly two keys."""
|
||||
r = client.get("/api/config")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert set(body) == {"app_name", "version"}
|
||||
assert body["app_name"] == "Brain of Reese"
|
||||
assert body["version"] == get_settings().app_version
|
||||
|
||||
|
||||
def test_config_follows_overridden_app_name(client) -> None:
|
||||
"""GET /api/config reflects a Settings override (e.g. BOR_APP_NAME)."""
|
||||
from app.config import Settings
|
||||
from app.main import app as fastapi_app
|
||||
|
||||
fastapi_app.dependency_overrides[get_settings] = lambda: Settings(
|
||||
app_name="Brain of Testy"
|
||||
)
|
||||
try:
|
||||
r = client.get("/api/config")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert set(body) == {"app_name", "version"}
|
||||
assert body["app_name"] == "Brain of Testy"
|
||||
assert body["version"] == "0.1.0"
|
||||
finally:
|
||||
fastapi_app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_suggestions_returns_list(client) -> None:
|
||||
r = client.get("/api/suggestions")
|
||||
assert r.status_code == 200
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
"""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").
|
||||
HTML_PAGES = (
|
||||
"index.html",
|
||||
"sources.html",
|
||||
"tuning.html",
|
||||
"document.html",
|
||||
"login.html",
|
||||
"git-sources.html",
|
||||
)
|
||||
|
||||
|
||||
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_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)
|
||||
brand_idx = html.find('<script src="assets/brand.js"></script>')
|
||||
module_idx = html.find('<script type="module"')
|
||||
assert brand_idx != -1, f"{page}: brand.js must load"
|
||||
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
|
||||
@@ -47,9 +47,18 @@ def test_state_machine_has_all_four_states() -> None:
|
||||
|
||||
def test_typing_indicator_contract_strings() -> None:
|
||||
"""The indicator's accessible label and the 10s elapsed-seconds hint
|
||||
are the strings screen readers (and the E2E) rely on."""
|
||||
are the strings screen readers (and the E2E) rely on.
|
||||
|
||||
Phase 39: the label is BUILT at call time from window.BOR_BRAND
|
||||
(the classic brand.js layer) — `TYPING_LABEL()` — with the default
|
||||
name as the only fallback, so the default path renders the same
|
||||
bytes as before."""
|
||||
js = _js()
|
||||
assert 'TYPING_LABEL = "Brain of Reese is thinking"' in js
|
||||
assert "TYPING_LABEL = () =>" in js, "the label must be built at call time (phase 39)"
|
||||
assert "`${brand()} is thinking`" in js, "the label must resolve the name via brand()"
|
||||
assert 'window.BOR_BRAND || "Brain of Reese"' in js, (
|
||||
"the no-config fallback must keep the default name"
|
||||
)
|
||||
assert "still thinking" in js, "elapsed-seconds hint must update the aria label"
|
||||
assert "secs < 10" in js, "the hint must only appear after 10s of silence"
|
||||
assert "role=\"status\"" in js, "typing bubble must be role=status"
|
||||
|
||||
@@ -54,15 +54,16 @@ def test_tool_branch_is_a_first_class_turn_branch() -> None:
|
||||
|
||||
def test_calling_tool_label_strings() -> None:
|
||||
"""The 'calling tool' label strings the story keys off: the button
|
||||
text and the status/typing-indicator labels (plain literals —
|
||||
phase 39 centralizes brand strings; no helper here)."""
|
||||
text and the status/typing-indicator labels. Phase 39 centralizes
|
||||
the brand prefix: the name resolves from window.BOR_BRAND at call
|
||||
time via brand() (the default name renders the same bytes)."""
|
||||
js = _js()
|
||||
tool_idx = js.find('ev.type === "tool"')
|
||||
delta_idx = js.find('ev.type === "delta"')
|
||||
branch = js[tool_idx:delta_idx]
|
||||
assert '"Calling tool…"' in branch, "the button carries the calling-tool text"
|
||||
assert '"Brain of Reese is listing documents"' in branch
|
||||
assert "Brain of Reese is reading ${argument}" in branch
|
||||
assert "`${brand()} is listing documents`" in branch
|
||||
assert "`${brand()} is reading ${argument}`" in branch
|
||||
assert "sendStatus.textContent = toolStatus" in branch, (
|
||||
"the #send-status live region announces what Brain is doing"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user