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`.
705 lines
31 KiB
Python
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
|