Files
brain-of-reese/tests/e2e/test_app_name_settings_fix.py
T
ducoterra c6ffa050a6
Build and Push Containers / build-and-push-app (push) Successful in 2m9s
Build and Push Containers / build-and-push-db (push) Successful in 15s
phase: 190_app_name_settings_fix
All gates re-verified green. Report:

- **Verified (all 4 tasks already complete; this was the final pass):** `effective_settings` resolves the 3 brand strings panel-row → tab-row → env (`applied_override_keys`, presence-not-value); `PUT /api/ui-settings` ignores string body fields; Theme-tab form is colors-only (17 pickers, no `#theme-app-name`/`#theme-placeholder`/`#theme-footer`); the ONE shared `applyBrandFromConfig` re-applies title/`.brand-text`/footer/placeholder after a panel save-reset and a tab save-reset; boot path byte-identical (single `/api/config`).
- **Completion criteria:**
  - strings order panel→tab→env, both branches — ✅ `tests/unit/test_theming.py::test_effective_app_name_prefers_settings_panel_row_over_ui_settings_row`, `..._fall_back_to_ui_settings_row_without_panel_row`, `..._per_field_panel_then_tab_then_env`, `..._ignore_panel_rows_when_the_overlay_is_off`
  - PUT ignores string fields + no 3 inputs in the tab — ✅ `tests/integration/test_ui_settings_api.py::test_put_ignores_string_fields_in_body` (+2) and the E2E `test_theme_tab_dom_is_colors_only`
  - live apply without reload + incident regression — ✅ `tests/e2e/test_app_name_settings_fix.py` tests 1–4
  - byte-identical boot, no double-apply — ✅ `tests/unit/test_brand_live.py` markers + E2E "exactly one /api/config"
  - suite green, coverage >90%, ruff+pyright clean — ✅
  - no behavior change in prior phases — ✅ (palette, phase-168 panel, env fallback suites all green)
  - atomic commit — **left to the harness** (pipeline rule overrides per-phase committing; no git write run)
- **Gates:** `uv run pytest --cov=app --cov-report=term-missing` → **4630 passed, TOTAL 6941 16 99%**, exit 0 · `uv run pytest tests/e2e/test_app_name_settings_fix.py -v --no-cov` → **6 passed** in isolation · regression E2E (isolated, one process each, all exit 0): `test_smoke` `test_configurable_brand` `test_admin_theme_tab` `test_theme_save_and_coverage` `test_theme_semantic_completion` `test_themed_favicon` `test_dark_tech_theme` `test_admin_settings_panel` `test_admin_settings_layout` `test_settings_mobile_value_width` · `uv run ruff check .` → All checks passed · `uv run pyright` → 0 errors.
- **Documentation:** README — fixed "saving one repaints *every* open page" (only the open document repaints; other pages on next load) and a broken paragraph wrap in *Customizing the look*; AGENTS.md rule 16 — `test_chunks_hnsw_recall.py` now described accurately (index paths forced with `enable_seqscan`/`indexonlyscan`/`bitmapscan` off, unforced plan asserted only on a pristine relation); `tests/unit/test_theme_frontend.py` — two stale section headers ("20 form fields"/"20 inputs" → 17 color). No AGENTS.md settings-overlay/resolution-order clause exists, so per the phase none was added. Full env-var cross-check (`BOR_*` in `app/config.py` vs README) → 0 gaps; 0 missing file references.
- **Notable:** the working tree also carries this phase's flake hardening of the phase-188 `tests/integration/test_chunks_hnsw_recall.py` (verified legit and green). Live infra untouched; shared DB left clean (0 `app_settings`, 0 `ui_settings`). **Flagged, not changed:** `settings_overlay.applied_override_keys` lets a `ProgrammingError` (missing `app_settings`) propagate from public `GET /api/config`, where `load_overrides` deliberately fails soft — only reachable on an un-migrated (0027) DB, outside this phase's pinned A1 semantics, so left for the owner to rule on rather than silently deviating.
- **Next pending phase:** `191_source_name_labels_tags`.
2026-10-18 02:59:19 -04:00

705 lines
31 KiB
Python

"""Phase 190 E2E (Playwright): the Settings-panel app name IS the app name.
Source: ``TODO.md`` L3 — "changing the App name in settings broke, it no
longer updates the name." The incident, in two defects:
1. **the shadow bake** — the admin Theme tab (phase 91) carried the three
brand strings in its form pre-populated with the EFFECTIVE value, and
``PUT /api/ui-settings`` wrote every one of them. So the first palette
save BAKED the then-effective name into the single ``ui_settings`` row,
``effective_settings`` resolved the strings tab-row-first, and every
LATER Settings-panel rename was shadowed: the panel showed the new value
with its ``db`` badge while ``/api/config`` — and the page — kept the
baked one;
2. **the stale open document** — neither surface re-applied the brand
strings after a save: ``settings.js`` re-GET the catalogue and stopped,
``theme.js`` ran the phase-92 palette reconcile and stopped, ``brand.js``
ran its pass once at boot. Even a save the server agreed with kept
painting the old name until a manual reload.
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_app_name_settings_fix.py -v --no-cov
Test → contract mapping (one story, one phase, one isolated file):
1. ``test_incident_the_panel_name_survives_a_theme_tab_color_save`` — the
owner's own sequence at the wire: set the name in the Settings panel
(``/api/config`` serves it, the catalogue badge says ``db``) → change ONE
color in the Theme tab and save → ``/api/config`` STILL reports the panel
value, the stored ``ui_settings`` string columns stay NULL (never baked),
and a SECOND panel rename reaches ``/api/config`` and a fresh page (the
shadow the incident is). An OLD client whose PUT still carries the string
keys is accepted and changes NOTHING (A1's defensive half).
2. ``test_panel_brand_save_repaints_the_open_document`` — the A2 contract:
save the panel's three brand rows and the document that is ALREADY open
carries them in ``document.title``, ``.brand-text``, the footer line and
the composer placeholder (the no-reload half pinned by a document-life
sentinel) — and a SECOND rename repaints again, because the pass replaces
the name the page SHOWS and not only the template literal.
3. ``test_panel_row_reset_hands_the_brand_back_to_the_env_live`` — the
per-row Reset (``DELETE /api/admin/settings/{key}``) IS the string
fallback path, and it is live: all four places fall back to the env
values without a reload.
4. ``test_legacy_baked_tab_row_paints_then_the_panel_row_beats_it_live`` —
the A1 fallback + the tab's self-heal: a pre-190 baked ``ui_settings``
row still paints at boot when no panel row owns the string; a panel row
written from ANOTHER browser wins; the Theme tab's own Save re-reads it
onto the open document without a reload; the Theme-tab Reset never blanks
the brand; and the stored row keeps its legacy string (the tab's PUT
neither rewrites nor clears those columns).
5. ``test_boot_paints_the_effective_name_with_one_config_request`` — the boot
path stays byte-identical (task 03's extraction, not a duplication): a
fresh load with no save paints the env name in all four places and issues
EXACTLY ONE ``/api/config`` request (no double-apply).
6. ``test_theme_tab_dom_is_colors_only`` — the task-02 strip in the browser:
17 ``type=color`` pickers and NONE of ``#theme-app-name`` /
``#theme-placeholder`` / ``#theme-footer``, and the Save body carries no
string key.
Deviation note on the phase file's (c) ("a Theme-tab Reset → the brand falls
back"): the Theme tab's Reset is the phase-91 all-null COLORS PUT, and A1
forbids it from touching the string columns, so a palette reset can never
change a brand string. The live fall-back the owner sees is therefore pinned
where it actually happens — the panel's per-row Reset (test 3) and a panel
row removed while the tab is open (test 4) — and the tab's Reset is pinned to
re-read the effective string and NEVER blank the brand (test 4).
Per-module app env (the phase-168 / phase-91 module-app pattern): ONE app
with ``BOR_SETTINGS_DB=1`` (the panel writes must actually take effect — the
conftest pins the overlay OFF for every other suite) and the three brand vars
pinned to DISTINCTIVE env values, so "the DB row now wins" and "the row is
gone again" are visible DOM changes rather than no-ops against the code
defaults.
DB isolation: the shared e2e Postgres keeps ``app_settings`` (the panel rows —
a leftover row retunes every later suite's app) and the single ``ui_settings``
row across suites. An autouse fixture truncates ``ui_settings`` AND clears the
overrides THROUGH THE API (``DELETE /api/admin/settings``, which deletes every
row and calls ``invalidate_settings()``) before and after every test — a bare
TRUNCATE would leave the app's cached ``Settings`` (the phase-168
``lru_cache``) serving a previous test's override, and every test here starts
from the real env base. The module teardown pins both tables empty.
"""
from __future__ import annotations
import json
import os
import re
import subprocess
import sys
from collections.abc import Iterator
import httpx
import pytest
from playwright.sync_api import Page, Request, Route, expect
from sqlalchemy import text
from app.config import Settings
from app.db import SessionLocal
from app.models import UiSettings
from e2e.auth_helpers import login
from e2e.conftest import (
ADMIN_PASSWORD,
APP_PORT,
REPO,
SESSION_SECRET,
USE_REAL_LLM,
_wait_http,
)
APP_URL = f"http://127.0.0.1:{APP_PORT}"
# ---------------------------------------------------------------------------
# The fixtures of this incident — every value distinct from every other AND
# from the code defaults, so "which name is on screen" is never ambiguous.
# ---------------------------------------------------------------------------
#: The env base — the module app's own environment, the fallback target of
#: every Reset here and the boot path's answer with no rows anywhere.
ENV_NAME = "Environment Brain E2E"
ENV_PLACEHOLDER = "Environment placeholder E2E"
ENV_FOOTER = "Environment footer E2E"
#: The panel's first rename (the incident's "set the name in Settings").
PANEL_ONE = "Panel Name One E2E"
PANEL_ONE_PLACEHOLDER = "Panel placeholder E2E"
PANEL_ONE_FOOTER = "Panel footer E2E"
#: The panel's SECOND rename — the one the baked Theme-tab row used to
#: swallow, and the one the live re-apply has to paint over the first.
PANEL_TWO = "Panel Name Two E2E"
#: What a pre-phase-190 Theme-tab save baked into ``ui_settings``.
LEGACY_BAKED_NAME = "Legacy Baked Tab Name"
#: One color pick for the Theme-tab save — distinct from the built-in
#: ``#f43f5e``, so the save is a real write and not the NULL collapse.
THEME_FIELD = "brand"
THEME_INPUT = "#theme-brand"
THEME_PICK = "#4f46e5"
THEME_PICK_2 = "#556677"
#: The three string inputs the Theme tab used to render (phase 91), REMOVED
#: in phase 190 (A1 — the panel is the strings' single writable owner).
REMOVED_STRING_IDS = ("#theme-app-name", "#theme-placeholder", "#theme-footer")
# ---------------------------------------------------------------------------
# Per-module app env (the phase-168 panel / phase-91 theme pattern)
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def app_server(mock_llm: int) -> Iterator[str]:
"""The real app under test — per-module env: the overlay ON (the whole
subject here is a SAVED brand string; the conftest pins it off for every
other suite — the ``test_pictorial_pdf.py`` opt-in pattern) and the three
brand vars pinned to DISTINCTIVE env values (the phase-61/62 leak guard:
an operator's local ``.env`` may carry the owner's name, and "the DB row
wins / the env wins again" has to be measured against values this suite
owns)."""
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-calibrated gate pins (the conftest pattern) — no chat turn is
# sent here, but the app boots with the same shape.
env["BOR_RELEVANCE_THRESHOLD"] = "0.30"
env["BOR_LEXICAL_SUPPORT_FLOOR"] = "0.15"
env["BOR_SOURCE_USEFULNESS_FLOOR"] = "0.15"
env["BOR_LLM_RETRY_DELAY"] = "0"
env["BOR_LLM_RETRIES"] = str(Settings.model_fields["llm_retries"].default)
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
env["BOR_DOCS_REPO"] = ""
env["BOR_SUGGESTIONS"] = json.dumps(
Settings.model_fields["suggestions"].default
)
# Phase 168's kill switch, ON for this suite (the opt-in).
env["BOR_SETTINGS_DB"] = "1"
# The distinctive env base — what every Reset hands control back to.
env["BOR_APP_NAME"] = ENV_NAME
env["BOR_INPUT_PLACEHOLDER"] = ENV_PLACEHOLDER
env["BOR_FOOTER_TEXT"] = ENV_FOOTER
env["BOR_GIT_SOURCES"] = ""
proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "app.main:app",
"--host", "127.0.0.1", "--port", str(APP_PORT), "--log-level", "warning"],
cwd=REPO,
env=env,
)
try:
_wait_http(f"{APP_URL}/api/health")
yield APP_URL
finally:
proc.terminate()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
@pytest.fixture(scope="module")
def app_url(app_server: str) -> str:
return app_server
# ---------------------------------------------------------------------------
# DB isolation + helpers
# ---------------------------------------------------------------------------
def _admin_client() -> httpx.Client:
"""The API side of the form login — the panel in ANOTHER browser, i.e.
the out-of-band writer the self-heal test needs."""
client = httpx.Client(timeout=60.0)
r = client.post(f"{APP_URL}/api/login", json={"password": ADMIN_PASSWORD})
assert r.status_code == 204, f"admin login → {r.status_code}: {r.text[:120]}"
return client
def _clean_state() -> None:
"""Back to the env base: truncate the single ``ui_settings`` row (raw —
the theme row is read per request, no process cache) and clear the panel
overrides THROUGH THE API, which deletes every ``app_settings`` row AND
invalidates the app's cached ``Settings`` (a bare TRUNCATE would leave
the previous test's override in effect inside the app)."""
with SessionLocal() as db:
db.execute(text("TRUNCATE ui_settings"))
db.commit()
client = _admin_client()
try:
r = client.delete(f"{APP_URL}/api/admin/settings", timeout=30)
assert r.status_code == 200, r.text[:200]
finally:
client.close()
@pytest.fixture(autouse=True)
def _clean(db_ready: None) -> Iterator[None]:
_clean_state()
yield
_clean_state()
with SessionLocal() as db:
assert db.execute(text("SELECT count(*) FROM app_settings")).scalar() == 0
assert db.execute(text("SELECT count(*) FROM ui_settings")).scalar() == 0
def _save_panel_rows(page: Page, values: dict[str, str]) -> None:
"""Type the given catalogue rows and press Save (the phase-168 flow),
waiting for the save's own status line AND the settled dirty hint (the
transition, not just the text — a second save lands the same line)."""
page.fill("#settings-filter", "")
for key, value in values.items():
page.fill(f'tr[data-key="{key}"] .settings-control', value)
expect(page.locator("#settings-dirty")).to_have_text(
f"{len(values)} unsaved changes"
)
page.click("#settings-save")
expect(page.locator("#settings-status")).to_contain_text(
f"Saved {len(values)} settings.", timeout=30_000
)
expect(page.locator("#settings-dirty")).to_have_text("", timeout=30_000)
def _config(page: Page) -> dict[str, object]:
"""``GET /api/config`` from the browser context — the very payload
``brand.js`` applies at boot and every live re-apply reads."""
r = page.request.get(f"{APP_URL}/api/config")
assert r.status == 200, r.text()[:200]
body: dict[str, object] = r.json()
return body
def _panel_source(page: Page, key: str) -> str:
"""The catalogue's own source badge for one key (``db`` / ``env`` /
``default``) — the panel's statement of WHO owns the value."""
r = page.request.get(f"{APP_URL}/api/admin/settings")
assert r.status == 200, r.text()[:200]
for item in r.json()["items"]:
if item["key"] == key:
return str(item["source"])
raise AssertionError(f"{key} missing from the catalogue")
def _seed_legacy_tab_row(app_name: str) -> None:
"""Put a pre-phase-190 baked row in the table — the shape an old
Theme-tab save wrote (the then-effective name beside a color). The API
CANNOT write the string columns any more (that is what A1 changed), so
the legacy row is seeded directly."""
with SessionLocal() as db:
db.merge(
UiSettings(
id=1,
app_name=app_name,
input_placeholder=None,
footer_text=None,
brand=THEME_PICK,
)
)
db.commit()
def _tab_row_strings() -> dict[str, str | None]:
"""The STORED string columns of the single ``ui_settings`` row — the DB
truth, not the effective view the GET reports."""
with SessionLocal() as db:
row = db.get(UiSettings, 1)
if row is None:
return {"app_name": None, "input_placeholder": None, "footer_text": None}
return {
"app_name": row.app_name,
"input_placeholder": row.input_placeholder,
"footer_text": row.footer_text,
}
def _arm_no_reload(page: Page) -> None:
"""A document-life sentinel: any navigation or reload wipes it, so the
later equality check IS the "without a reload" proof."""
page.evaluate("() => { window.__bor190Doc = 1; }")
def _assert_same_document(page: Page) -> None:
assert page.evaluate("() => window.__bor190Doc") == 1, (
"the open document was replaced — the assertions around this one "
"would pass on a reload and prove nothing about the live apply"
)
def _expect_brand(page: Page, name: str, placeholder: str, footer: str) -> None:
"""The four DOM places the A2 contract covers, on the document that is
open RIGHT NOW (each ``expect`` retries on its own clock)."""
expect(page).to_have_title(re.compile(re.escape(name)), timeout=15_000)
expect(page.locator(".brand-text")).to_have_text(name)
expect(page.locator(".footer-text").first).to_have_text(footer)
expect(page.locator("#message-input")).to_have_attribute("placeholder", placeholder)
# ---------------------------------------------------------------------------
# 1. THE INCIDENT: a Theme-tab color save used to shadow every later
# Settings-panel rename
# ---------------------------------------------------------------------------
def test_incident_the_panel_name_survives_a_theme_tab_color_save(
page: Page, db_ready: None
) -> None:
"""The owner's sequence end to end: rename in the panel → save a color in
the Theme tab → the panel's name is STILL the effective one → rename
AGAIN and the new name is what the server serves and the next page paints
(pre-fix the FIRST rename was baked into ``ui_settings`` and shadowed the
second one forever)."""
page.set_default_timeout(30_000)
login(page, APP_URL, next="/settings.html")
expect(page.locator("#settings-content")).to_be_visible(timeout=15_000)
# The starting state: no row anywhere, so the effective name is the env
# base this app booted with.
assert _config(page)["app_name"] == ENV_NAME
assert _panel_source(page, "app_name") == "env"
# 1. Set the app name in the Settings panel (the phase-168 flow).
_save_panel_rows(page, {"app_name": PANEL_ONE})
expect(page.locator('tr[data-key="app_name"] .settings-src')).to_have_text("DB")
assert _config(page)["app_name"] == PANEL_ONE, (
"the panel row must be what /api/config serves"
)
assert _panel_source(page, "app_name") == "db"
# 2. The admin opens the Theme tab, changes ONE color and saves. Pre-fix
# this PUT carried the pre-populated effective strings and baked
# PANEL_ONE into the row.
page.goto(APP_URL + "/theme.html")
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
page.fill(THEME_INPUT, THEME_PICK)
page.click("#theme-save")
expect(page.locator("#theme-result")).to_have_text("Theme saved.", timeout=30_000)
# …the panel value survives the palette save (the wire answer the brand
# layer boots from).
assert _config(page)["app_name"] == PANEL_ONE, (
"a Theme-tab color save must not take ownership of the app name"
)
# …the row it wrote carries the color and NO brand string: the string
# columns stay exactly as stored (NULL here — never baked).
assert _tab_row_strings() == {
"app_name": None,
"input_placeholder": None,
"footer_text": None,
}, f"the Theme-tab save baked the brand strings: {_tab_row_strings()}"
# …and the tab's own GET still REPORTS the effective strings, read-only
# (old-client compat): the panel's name, the env placeholder and footer.
r = page.request.get(f"{APP_URL}/api/ui-settings")
assert r.status == 200, r.text()[:200]
body = r.json()
assert body["app_name"] == PANEL_ONE
assert body["input_placeholder"] == ENV_PLACEHOLDER
assert body["footer_text"] == ENV_FOOTER
# 3. THE INCIDENT HALF: rename again in the panel. Pre-fix the baked row
# shadowed this save — the panel showed "Two", the app kept "One".
page.goto(APP_URL + "/settings.html")
expect(page.locator("#settings-content")).to_be_visible(timeout=15_000)
_save_panel_rows(page, {"app_name": PANEL_TWO})
assert _config(page)["app_name"] == PANEL_TWO, (
"the second rename must reach /api/config — the shadow IS the incident"
)
page.goto(APP_URL + "/")
expect(page.locator(".brand-text")).to_have_text(PANEL_TWO, timeout=15_000)
expect(page).to_have_title(re.compile(re.escape(PANEL_TWO)))
# 4. The defensive half (A1): an OLD client that still PUTs the three
# string keys is accepted and changes NOTHING — the strings are
# dropped before validation and the columns are neither written nor
# cleared. Only the color moves.
stale = page.request.put(
f"{APP_URL}/api/ui-settings",
data=json.dumps(
{
THEME_FIELD: THEME_PICK_2,
"app_name": "Rebaked By An Old Client",
"input_placeholder": "Rebaked placeholder",
"footer_text": "Rebaked footer",
}
),
headers={"Content-Type": "application/json"},
)
assert stale.status == 200, stale.text()[:200]
assert _config(page)["app_name"] == PANEL_TWO, "an old client cannot re-bake the name"
assert _tab_row_strings() == {
"app_name": None,
"input_placeholder": None,
"footer_text": None,
}, "the ignored strings must not be written into the row either"
assert stale.json()["app_name"] == PANEL_TWO, (
"the response reports the EFFECTIVE strings, not the body it dropped"
)
# ---------------------------------------------------------------------------
# 2. The live apply (A2): the panel's save repaints the OPEN document
# ---------------------------------------------------------------------------
def test_panel_brand_save_repaints_the_open_document(page: Page, db_ready: None) -> None:
"""Save the brand rows in the panel and the document that is ALREADY open
repaints — the title, the header brand, the footer line and the composer
placeholder — with no reload (the phase-168 "takes effect on clicking
save" contract, extended from the catalogue to the display). A second
rename repaints again: the shared pass replaces the name the page SHOWS,
not only the template literal, or the second save would paint nothing."""
page.set_default_timeout(30_000)
login(page, APP_URL, next="/settings.html")
expect(page.locator("#settings-content")).to_be_visible(timeout=15_000)
_arm_no_reload(page)
# The starting paint is the env base, on the surface that is open now.
_expect_brand(page, ENV_NAME, ENV_PLACEHOLDER, ENV_FOOTER)
# ONE batch over the panel's three brand rows.
_save_panel_rows(
page,
{
"app_name": PANEL_ONE,
"input_placeholder": PANEL_ONE_PLACEHOLDER,
"footer_text": PANEL_ONE_FOOTER,
},
)
# …the SAME document carries all of them — no navigation, no reload (the
# sentinel below is what turns "without a reload" into an assertion).
_expect_brand(page, PANEL_ONE, PANEL_ONE_PLACEHOLDER, PANEL_ONE_FOOTER)
assert page.evaluate("() => window.BOR_BRAND") == PANEL_ONE
_assert_same_document(page)
# The second rename: the page shows PANEL_ONE, so a pass that only ever
# replaced the template literal would silently paint nothing.
_save_panel_rows(page, {"app_name": PANEL_TWO})
_expect_brand(page, PANEL_TWO, PANEL_ONE_PLACEHOLDER, PANEL_ONE_FOOTER)
assert page.evaluate("() => window.BOR_BRAND") == PANEL_TWO
_assert_same_document(page)
# A FRESH document (the boot pass alone) shows the same name everywhere —
# the prose the boot pass rewrites included, with no stale env name left.
page.goto(APP_URL + "/")
expect(page.locator(".brand-text")).to_have_text(PANEL_TWO, timeout=15_000)
expect(page.locator("h1.empty-state-title")).to_have_text(f"Hey! I'm {PANEL_TWO}.")
assert ENV_NAME not in page.locator("#view-chat").inner_text(), (
"a stale env name survived the boot pass"
)
# ---------------------------------------------------------------------------
# 3. The panel's Reset IS the string fallback — and it is live too
# ---------------------------------------------------------------------------
def test_panel_row_reset_hands_the_brand_back_to_the_env_live(
page: Page, db_ready: None
) -> None:
"""``DELETE /api/admin/settings/{key}`` is how a brand string falls back
(the Theme tab's PUT never touches those columns), and the fall-back is
visible on the open document with no reload."""
page.set_default_timeout(30_000)
login(page, APP_URL, next="/settings.html")
expect(page.locator("#settings-content")).to_be_visible(timeout=15_000)
# A saved override is live first (the baseline the Reset has to undo).
_save_panel_rows(
page,
{
"app_name": PANEL_ONE,
"input_placeholder": PANEL_ONE_PLACEHOLDER,
"footer_text": PANEL_ONE_FOOTER,
},
)
_arm_no_reload(page)
_expect_brand(page, PANEL_ONE, PANEL_ONE_PLACEHOLDER, PANEL_ONE_FOOTER)
# Reset all three rows: each DELETE lands its own status line AND
# re-applies the brand to the open document.
for key, env_var in (
("app_name", "BOR_APP_NAME"),
("input_placeholder", "BOR_INPUT_PLACEHOLDER"),
("footer_text", "BOR_FOOTER_TEXT"),
):
page.click(f'tr[data-key="{key}"] .settings-reset')
expect(page.locator("#settings-status")).to_contain_text(
f"{env_var} is back to the environment value.", timeout=30_000
)
expect(page.locator(f'tr[data-key="{key}"] .settings-src')).to_have_text("env")
# …the open document is back on the env base, still the same document.
_expect_brand(page, ENV_NAME, ENV_PLACEHOLDER, ENV_FOOTER)
assert page.evaluate("() => window.BOR_BRAND") == ENV_NAME
_assert_same_document(page)
assert _config(page)["app_name"] == ENV_NAME
# ---------------------------------------------------------------------------
# 4. A legacy baked row still applies (the A1 fallback), the panel row beats
# it, and the Theme tab's own settle re-reads the effective name
# ---------------------------------------------------------------------------
def test_legacy_baked_tab_row_paints_then_the_panel_row_beats_it_live(
page: Page, db_ready: None
) -> None:
"""No data migration happened (A1): a row an old Theme-tab save baked
still paints when no panel row owns the string, and stops shadowing the
moment the panel takes ownership — including onto the OPEN tab, whose
Save re-reads the effective brand while the stored row keeps its legacy
string (the tab's PUT neither rewrites nor clears those columns)."""
page.set_default_timeout(30_000)
_seed_legacy_tab_row(LEGACY_BAKED_NAME)
# The fallback branch: no panel row → the baked tab row IS the name
# (the public /api/config answer, and the catalogue's own badge: the
# panel owns nothing here).
assert _config(page)["app_name"] == LEGACY_BAKED_NAME
login(page, APP_URL, next="/theme.html")
assert _panel_source(page, "app_name") == "env"
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
_arm_no_reload(page)
expect(page.locator(".brand-text")).to_have_text(LEGACY_BAKED_NAME, timeout=15_000)
expect(page).to_have_title(re.compile(re.escape(LEGACY_BAKED_NAME)))
# The panel (ANOTHER browser) takes ownership of the string while this
# tab is open — the open tab cannot know until its next settle.
other = _admin_client()
try:
r = other.put(f"{APP_URL}/api/admin/settings/app_name", json={"value": PANEL_ONE})
assert r.status_code == 200, r.text
finally:
other.close()
assert _config(page)["app_name"] == PANEL_ONE, "the panel row beats the baked row"
page.wait_for_timeout(500) # nothing pulls the open tab — it stays put
expect(page.locator(".brand-text")).to_have_text(LEGACY_BAKED_NAME)
# The Theme tab's Save (a color pick only — the strings are not its to
# write) re-reads the effective brand onto the OPEN document.
page.fill(THEME_INPUT, THEME_PICK_2)
page.click("#theme-save")
expect(page.locator("#theme-result")).to_have_text("Theme saved.", timeout=30_000)
expect(page.locator(".brand-text")).to_have_text(PANEL_ONE, timeout=15_000)
expect(page).to_have_title(re.compile(re.escape(PANEL_ONE)))
_assert_same_document(page)
# The stored row still carries the legacy string (never rewritten, never
# cleared), so the documented fallback keeps working.
assert _tab_row_strings()["app_name"] == LEGACY_BAKED_NAME
# Handing the string back to the environment (the panel's DELETE, the
# string fallback path) while the tab is open, then the tab's Reset: the
# open document re-reads the effective name — the legacy row again — and
# a palette reset NEVER blanks the brand.
other = _admin_client()
try:
r = other.delete(f"{APP_URL}/api/admin/settings/app_name")
assert r.status_code == 200, r.text
finally:
other.close()
assert _config(page)["app_name"] == LEGACY_BAKED_NAME
page.click("#theme-reset")
expect(page.locator("#theme-result")).to_have_text(
"Reset to the built-in theme.", timeout=30_000
)
expect(page.locator(".brand-text")).to_have_text(LEGACY_BAKED_NAME, timeout=15_000)
expect(page).to_have_title(re.compile(re.escape(LEGACY_BAKED_NAME)))
_assert_same_document(page)
# ---------------------------------------------------------------------------
# 5. The boot path stays byte-identical: ONE /api/config request, no re-apply
# ---------------------------------------------------------------------------
def test_boot_paints_the_effective_name_with_one_config_request(
page: Page, db_ready: None
) -> None:
"""Task 03 extracted the boot DOM pass into the shared function; it must
not DOUBLE-apply. A fresh load with no save paints the effective (here:
env) name in all four places and issues EXACTLY ONE ``/api/config``
request — the boot fetch ``brand.js`` has always made, nothing more."""
page.set_default_timeout(30_000)
login(page, APP_URL, next="/")
expect(page.locator("#message-input")).to_be_visible(timeout=15_000)
config_requests: list[str] = []
def watch(request: Request) -> None:
if "/api/config" in request.url:
config_requests.append(request.url)
page.on("request", watch)
page.goto(APP_URL + "/")
expect(page.locator("#message-input")).to_be_visible(timeout=15_000)
_expect_brand(page, ENV_NAME, ENV_PLACEHOLDER, ENV_FOOTER)
assert page.evaluate("() => window.BOR_BRAND") == ENV_NAME
assert page.evaluate("() => window.BOR_BRAND_APPLIED") == ENV_NAME, (
"the shared pass records what the document shows (the re-apply's "
"source string) — and the boot pass is the only one that ran"
)
page.wait_for_timeout(500) # any second, stray fetch would have landed
assert config_requests == [f"{APP_URL}/api/config"], (
f"the boot is ONE /api/config request, got {config_requests}"
)
# ---------------------------------------------------------------------------
# 6. The Theme tab is a COLORS surface (the task-02 strip)
# ---------------------------------------------------------------------------
def test_theme_tab_dom_is_colors_only(page: Page, db_ready: None) -> None:
"""The read half of the single-writer fix, in the isolated suite: the tab
renders the 17 color pickers and NONE of the three string inputs — so no
save can pre-populate an effective name and bake it back — and its Save
posts a body with no string key."""
page.set_default_timeout(30_000)
login(page, APP_URL, next="/theme.html")
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
for selector in REMOVED_STRING_IDS:
expect(page.locator(selector)).to_have_count(0)
expect(page.get_by_label("App name")).to_have_count(0)
expect(page.locator('#view-theme input[type="color"]')).to_have_count(17)
# The body the Save posts carries the colors only — no string key.
captured: dict[str, object] = {}
def handle(route: Route) -> None:
if route.request.method == "PUT":
captured["body"] = route.request.post_data_json
route.continue_()
page.route("**/api/ui-settings", handle)
try:
page.fill(THEME_INPUT, THEME_PICK)
page.click("#theme-save")
expect(page.locator("#theme-result")).to_have_text(
"Theme saved.", timeout=30_000
)
finally:
page.unroute("**/api/ui-settings")
body = captured.get("body")
assert isinstance(body, dict), "the Save PUT was not captured (route hook?)"
assert "app_name" not in body
assert "input_placeholder" not in body
assert "footer_text" not in body
assert body.get(THEME_FIELD) == THEME_PICK