"""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