phase: 190_app_name_settings_fix
Build and Push Containers / build-and-push-app (push) Successful in 2m9s
Build and Push Containers / build-and-push-db (push) Successful in 15s

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`.
This commit is contained in:
2026-10-18 02:59:19 -04:00
parent 47456a44df
commit c6ffa050a6
59 changed files with 4104 additions and 556 deletions
+152 -72
View File
@@ -17,16 +17,24 @@ Test → contract mapping (one story, one phase, one isolated file):
1. ``test_theme_tab_admin_save`` — "buttons and color pickers": the
admin sees the "Theme" nav link and the form (gate hidden); the
20 inputs show the effective defaults (the 3 template strings +
the 17 built-in hexes — the 9 identity + the 8 semantic state,
phase 93 — parsed out of ``styles.css``'s ``:root`` IN-TEST — the
suite can never drift from the stylesheet); Save
17 color inputs show the effective defaults (the built-in hexes —
the 9 identity + the 8 semantic state, phase 93 — parsed out of
``styles.css``'s ``:root`` IN-TEST — the suite can never drift from
the stylesheet); Save
runs the §7.4 lifecycle (disabled + "Saving…" while the PUT is
held, then restored) and lands the role=status "Theme saved.";
the inputs re-populate to the saved values; the persisted row is
the one saved; and the saved non-AA palette lists its failing
pair in ``#theme-contrast`` without blocking the save
(warning-only).
(warning-only), and the row lands with its 17 colors and NO brand
string (phase 190, A1).
1b. ``test_tab_renders_the_17_colors_and_no_string_inputs`` — the
phase-190 (A1) strip in the browser: the tab's DOM holds exactly the
17 ``type=color`` inputs and NONE of the old string inputs
(``#theme-app-name`` / ``#theme-placeholder`` / ``#theme-footer`` —
count 0), the admin Settings panel being the single writable owner of
the brand strings; a Save posts a body with the 17 colors and no
string key.
2. ``test_saved_theme_is_pre_paint_for_everyone`` — "the theme
should load immediately, not pop in": after a save, the RAW
served HTML of ``/`` carries exactly one ``<style
@@ -34,8 +42,11 @@ Test → contract mapping (one story, one phase, one isolated file):
IMMEDIATELY before ``</head>`` — for the admin AND a fresh
anonymous context — and the computed ``:root`` custom properties
equal the saved hexes at load (the inline tag precedes every
stylesheet application). The 3 strings stay on the brand.js boot
fetch (the B4 split: colors pre-paint, strings via the fetch).
stylesheet application). Phase 190 (A1) turned the other half of the
old B4 split into a contract: a palette save writes NO brand string,
so the anonymous page still renders the env/template name,
placeholder and footer line after the save (the boot fetch has
nothing new to apply — a theme save can no longer rename the app).
3. ``test_anonymous_and_token_user_are_walled`` — the admin gate:
anonymous meets ``#theme-gate`` (sign-in link
``?next=/theme.html``) with ``#theme-content`` hidden and the
@@ -45,8 +56,8 @@ Test → contract mapping (one story, one phase, one isolated file):
4. ``test_reset_restores_the_builtin_byte_identical`` — "reset":
Reset to defaults runs the §7.4 lifecycle ("Resetting…"), lands
the role=status "Reset to the built-in theme.", re-populates the
20 defaults, serves NO theme tag, and the served bytes equal a
row-less deployment byte for byte (the no-op injection
17 built-in colors, serves NO theme tag, and the served bytes equal
a row-less deployment byte for byte (the no-op injection
contract).
5. ``test_contrast_warning_does_not_block`` — the WCAG warnings:
``--ink`` set within 0.1 ratio of ``--bg`` lists the failing
@@ -89,7 +100,7 @@ from playwright.sync_api import Browser, BrowserContext, Page, Route, expect
from sqlalchemy import text
from app.config import Settings
from app.core.theming import COLOR_FIELDS
from app.core.theming import COLOR_FIELDS, STRING_FIELDS
from app.db import SessionLocal
from app.models import UiSettings
from e2e.auth_helpers import login, login_with_token
@@ -133,14 +144,10 @@ PALETTE: dict[str, str] = {
"accent_ink": "#d9c37a",
"accent_line": "#b8963e",
}
APP_NAME = "Theme E2E"
PLACEHOLDER = "Ask the themed brain…"
FOOTER = "E2E footer"
SAVED_STRINGS: dict[str, str] = {
"app_name": APP_NAME,
"input_placeholder": PLACEHOLDER,
"footer_text": FOOTER,
}
# The three brand strings are GONE from this surface (phase 190, A1 —
# the admin Settings panel is their single writable owner), so this
# suite carries no saved-string fixture any more: a palette save writes
# colors only, and every string assertion reads the env/template default.
#: The failing-pair leg (test 5): --ink set within 0.1 ratio of --bg
#: (the deterministic near-identical pick — 1.0:1 on both dark pairs).
@@ -306,12 +313,21 @@ def _cookies(page: Page) -> dict[str, str]:
def _seed_theme_via_api(app_url: str, cookies: dict[str, str]) -> None:
"""Admin ``PUT /api/ui-settings`` with the full theme (the API
seed — the UI save itself is test 1's job)."""
body = {**PALETTE, **SAVED_STRINGS}
r = httpx.put(f"{app_url}/api/ui-settings", json=body, cookies=cookies, timeout=10)
"""Admin ``PUT /api/ui-settings`` with the full 17-color palette
(the API seed — the UI save itself is test 1's job).
Phase 190 (A1): no string key in the body — the Settings panel owns
the brand strings and this API ignores them. The seed asserts the
colors round-trip AND that the effective strings are still the
env/template defaults, i.e. a theme save cannot rename the app."""
defaults = _template_defaults()
r = httpx.put(f"{app_url}/api/ui-settings", json=PALETTE, cookies=cookies, timeout=10)
assert r.status_code == 200, r.text
assert r.json() == body, "the PUT must echo the new effective values"
body = r.json()
for field in COLOR_FIELDS:
assert body[field] == PALETTE[field], f"effective {field} != {PALETTE[field]}"
for field, value in defaults.items():
assert body[field] == value, f"{field} must stay the effective default (A1)"
def _hold_theme_puts(page: Page, hold_s: float = 0.6) -> None:
@@ -336,27 +352,22 @@ def _release_theme_puts(page: Page) -> None:
page.unroute("**/api/ui-settings")
def _fill_theme_form(
page: Page,
palette: dict[str, str],
strings: dict[str, str] | None = None,
) -> None:
"""Fill the 20 inputs: the 3 text fields (``strings``, default
the E2E set) + the 17 color pickers (``palette`` — all 17 vars,
phase 93)."""
text_values = strings if strings is not None else SAVED_STRINGS
page.fill("#theme-app-name", text_values["app_name"])
page.fill("#theme-placeholder", text_values["input_placeholder"])
page.fill("#theme-footer", text_values["footer_text"])
#: The three string inputs the Theme tab used to render (phase 91) —
#: REMOVED in phase 190 (A1: the admin Settings panel is the single
#: writable owner of the brand strings). Every pin below asserts they
#: count 0 in the tab DOM.
REMOVED_STRING_IDS = ("#theme-app-name", "#theme-placeholder", "#theme-footer")
def _fill_theme_form(page: Page, palette: dict[str, str]) -> None:
"""Fill the 17 color pickers (``palette`` — all 17 vars, phase 93).
The tab has no string inputs since phase 190 (A1)."""
for field, value in palette.items():
page.fill(COLOR_INPUT_IDS[field], value)
def _expect_form_values(page: Page, strings: dict[str, str], colors: dict[str, str]) -> None:
"""Assert all 20 inputs show the given effective values."""
expect(page.locator("#theme-app-name")).to_have_value(strings["app_name"])
expect(page.locator("#theme-placeholder")).to_have_value(strings["input_placeholder"])
expect(page.locator("#theme-footer")).to_have_value(strings["footer_text"])
def _expect_form_values(page: Page, colors: dict[str, str]) -> None:
"""Assert the 17 color inputs show the given effective values."""
for field in COLOR_FIELDS:
expect(page.locator(COLOR_INPUT_IDS[field])).to_have_value(colors[field])
@@ -401,7 +412,6 @@ def _wait_theme_computed(page: Page, colors: dict[str, str], timeout: int = 15_0
def test_theme_tab_admin_save(page: Page, app_url: str, db_ready: None) -> None:
defaults = _template_defaults()
builtin = _builtin_colors()
page.set_default_timeout(30_000)
login(page, app_url, next="/theme.html")
@@ -418,15 +428,13 @@ def test_theme_tab_admin_save(page: Page, app_url: str, db_ready: None) -> None:
expect(page.locator("#theme-gate")).to_be_hidden()
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
# The 20 inputs show the EFFECTIVE defaults: the 3 template
# strings + the 17 built-in hexes (9 identity + 8 semantic)
# parsed straight out of styles.css's :root (the resolver's
# missing-row branch).
_expect_form_values(page, defaults, builtin)
# The 17 color inputs show the EFFECTIVE defaults: the built-in
# hexes (9 identity + 8 semantic) parsed straight out of
# styles.css's :root (the resolver's missing-row branch).
_expect_form_values(page, builtin)
# Set a distinct palette + the 3 strings, then Save through the
# real form — the PUT held so the §7.4 in-flight state is
# observable deterministically.
# Set a distinct palette, then Save through the real form — the PUT
# held so the §7.4 in-flight state is observable deterministically.
_fill_theme_form(page, PALETTE)
_hold_theme_puts(page)
try:
@@ -448,19 +456,20 @@ def test_theme_tab_admin_save(page: Page, app_url: str, db_ready: None) -> None:
finally:
_release_theme_puts(page)
# The inputs re-populate to the SAVED (effective) values (the
# The inputs re-populate to the SAVED (effective) colors (the
# save's refetch is the canonical state).
_expect_form_values(page, SAVED_STRINGS, PALETTE)
_expect_form_values(page, PALETTE)
# The row landed in Postgres (the id-1 single row, all 20 values
# — every palette color differs from its built-in, so nothing
# collapsed to NULL).
# The row landed in Postgres (the id-1 single row) with its 17
# colors — every palette color differs from its built-in, so nothing
# collapsed to NULL — and with NO brand string: the tab no longer
# writes them, which is the write half of the TODO.md L3 fix
# (phase 190, A1).
with SessionLocal() as db:
row = db.get(UiSettings, 1)
assert row is not None, "the PUT must upsert the id-1 row"
assert row.app_name == APP_NAME
assert row.input_placeholder == PLACEHOLDER
assert row.footer_text == FOOTER
for field in STRING_FIELDS:
assert getattr(row, field) is None, f"{field} must never be stored"
for field in COLOR_FIELDS:
assert getattr(row, field) == PALETTE[field]
@@ -476,10 +485,73 @@ def test_theme_tab_admin_save(page: Page, app_url: str, db_ready: None) -> None:
expect(contrast).to_have_text("--bg on --brand: 3.0:1 — needs 4.5:1")
# ---------------------------------------------------------------------------
# 1b. The phase-190 (A1) strip in the browser: 17 color inputs, no
# string inputs, and a Save body that carries no string key
# ---------------------------------------------------------------------------
def test_tab_renders_the_17_colors_and_no_string_inputs(
page: Page, app_url: str, db_ready: None
) -> None:
"""The owner-visible shape of the strip (phase 190, A1): the Theme
tab is a COLORS surface — its DOM holds exactly the 17
``type=color`` pickers and NONE of the three string inputs it used
to render, and the body Save posts carries the 17 colors and no
string key. That is the read half of "changing the App name in
settings broke": the tab can neither show a stale name nor bake one
into its row (the write half is the ignored-string PUT, pinned by
``tests/integration/test_ui_settings_api.py``)."""
builtin = _builtin_colors()
page.set_default_timeout(30_000)
login(page, app_url, next="/theme.html")
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
# The three string inputs are ABSENT — not hidden, not empty: gone
# from the DOM, so no selector can fill one and no populate() can
# write a stale effective value into it.
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)
# The 17 color pickers are all there and still type=color.
color_inputs = page.locator('#view-theme input[type="color"]')
expect(color_inputs).to_have_count(len(COLOR_FIELDS))
_expect_form_values(page, builtin)
# Save posts the 17 colors only — the captured PUT body names no
# string field (the client half of the bake being dead).
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:
_fill_theme_form(page, PALETTE)
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 set(body) == set(COLOR_FIELDS), (
f"the PUT body must be the 17 colors only, got {sorted(body)}"
)
for field in STRING_FIELDS:
assert field not in body
assert body.get("bg") == PALETTE["bg"]
# ---------------------------------------------------------------------------
# 2. Pre-paint, for everyone: the inline :root in the RAW served HTML
# + the computed palette at load (the no-pop-in proof), the B4
# strings via the boot fetch
# + the computed palette at load (the no-pop-in proof), and the
# brand strings UNTOUCHED by a palette save (phase 190 A1)
# ---------------------------------------------------------------------------
@@ -527,16 +599,25 @@ def test_saved_theme_is_pre_paint_for_everyone(
anon.goto(app_url + "/")
_assert_raw_tag(anon.content(), PALETTE)
_wait_theme_computed(anon, PALETTE)
# The B4 split: the 3 strings are NOT pre-paint — they apply
# post-fetch via the /api/config boot fetch (brand.js) on the
# anonymous page too: the name (header brand + window
# global), the placeholder, and the footer line.
expect(anon.locator(".brand-text")).to_have_text(APP_NAME, timeout=15_000)
assert anon.evaluate("() => window.BOR_BRAND") == APP_NAME
# Phase 190 (A1): the palette save wrote NO brand string, so
# the anonymous page renders the env/template strings through
# the SAME brand.js boot fetch (name + window global, the
# composer placeholder, the footer line) — saving a theme can no
# longer rename the deployment, which is the owner-visible half
# of the TODO.md L3 fix. (The strings' own surface — the admin
# Settings panel — is pinned by test_admin_settings_panel.py and
# the phase-190 suite.)
defaults = _template_defaults()
expect(anon.locator(".brand-text")).to_have_text(
defaults["app_name"], timeout=15_000
)
assert anon.evaluate("() => window.BOR_BRAND") == defaults["app_name"]
expect(anon.locator("#message-input")).to_have_attribute(
"placeholder", PLACEHOLDER
"placeholder", defaults["input_placeholder"]
)
expect(anon.locator(".footer-text").first).to_have_text(
defaults["footer_text"]
)
expect(anon.locator(".footer-text").first).to_have_text(FOOTER)
finally:
if anon_ctx is not None:
anon_ctx.close()
@@ -625,7 +706,7 @@ def test_anonymous_and_token_user_are_walled(
# ---------------------------------------------------------------------------
# 4. Reset: the §7.4 lifecycle, the 20 defaults, NO theme tag, and
# 4. Reset: the §7.4 lifecycle, the 17 built-in colors, NO theme tag, and
# byte-identical served HTML (the no-op injection contract)
# ---------------------------------------------------------------------------
@@ -633,14 +714,13 @@ def test_anonymous_and_token_user_are_walled(
def test_reset_restores_the_builtin_byte_identical(
page: Page, app_url: str, db_ready: None
) -> None:
defaults = _template_defaults()
builtin = _builtin_colors()
page.set_default_timeout(30_000)
login(page, app_url, next="/theme.html")
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
# The form settles on the effective defaults (the row-less
# state — the autouse clean truncated the row).
_expect_form_values(page, defaults, builtin)
_expect_form_values(page, builtin)
# Save a distinct theme through the UI (the reset must undo a
# REAL save)…
@@ -672,9 +752,9 @@ def test_reset_restores_the_builtin_byte_identical(
finally:
_release_theme_puts(page)
# The form re-populates to the 20 defaults (the env/built-in
# The form re-populates to the 17 built-in colors (the built-in
# merge, re-rendered from the refetch)…
_expect_form_values(page, defaults, builtin)
_expect_form_values(page, builtin)
# …and the WCAG warning is gone (the built-in palette passes all
# eight pairs).
expect(page.locator("#theme-contrast")).to_be_hidden()
+704
View File
@@ -0,0 +1,704 @@
"""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
+42 -44
View File
@@ -145,14 +145,9 @@ PALETTE: dict[str, str] = {
"accent_ink": "#d9c37a",
"accent_line": "#b8963e",
}
APP_NAME = "Theme E2E"
PLACEHOLDER = "Ask the themed brain…"
FOOTER = "E2E footer"
SAVED_STRINGS: dict[str, str] = {
"app_name": APP_NAME,
"input_placeholder": PLACEHOLDER,
"footer_text": FOOTER,
}
# No saved-string fixture since phase 190 (A1): the admin Settings
# panel owns the brand strings, so this surface saves COLORS only and
# every string assertion reads the env/template default.
#: The E2E-stable color-input ids, in COLOR_FIELDS order (the form's
#: own markup — the static E2E-stable-selectors house convention).
@@ -234,17 +229,23 @@ def _template_defaults() -> dict[str, str]:
}
def _wait_mount_settled(page: Page) -> None:
"""The theme view's mount-time load has SETTLED: its populate is
the last visible step — the app-name input carries the effective
default (the static markup ships it empty, so only a settled
``loadSettings`` can have written it). From here on, no in-flight
mount load can race the test's own save/reset refetch (an
out-of-order settle would re-reconcile the open document onto a
stale read)."""
expect(page.locator("#theme-app-name")).to_have_value(
_template_defaults()["app_name"], timeout=15_000
)
def _login_to_theme(page: Page, app_url: str) -> None:
"""Log in straight into the Theme view AND wait for its mount-time
``GET /api/ui-settings`` to ANSWER — the settle signal.
Phase 190 (task 02) moved it: the settle used to be read off the
``#theme-app-name`` input (the only input the static markup ships
empty, so a value in it proved ``populate`` had run) — that input is
gone with the tab's string fieldset, so the network is the source of
truth now. From here on, no in-flight mount load can race the test's
own save/reset refetch (an out-of-order settle would re-reconcile the
open document onto a stale read)."""
with page.expect_response(
lambda r: "/api/ui-settings" in r.url, timeout=30_000
) as info:
login(page, app_url, next="/theme.html")
assert info.value.status == 200, "the mount's GET /api/ui-settings failed"
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
def _expected_overrides(colors: dict[str, str], builtins: dict[str, str]) -> dict[str, str]:
@@ -476,12 +477,20 @@ def _cookies(page: Page) -> dict[str, str]:
def _seed_theme_via_api(app_url: str, cookies: dict[str, str]) -> None:
"""Admin ``PUT /api/ui-settings`` with the full theme (the API
seed — the UI save itself is test 1's job)."""
body = {**PALETTE, **SAVED_STRINGS}
r = httpx.put(f"{app_url}/api/ui-settings", json=body, cookies=cookies, timeout=10)
"""Admin ``PUT /api/ui-settings`` with the full 17-color palette
(the API seed — the UI save path is this module's own tests' job).
Phase 190 (A1): no string key in the body — the Settings panel owns
the brand strings — and the response keeps reporting the effective
(env/template) strings, so a palette seed cannot rename the app."""
defaults = _template_defaults()
r = httpx.put(f"{app_url}/api/ui-settings", json=PALETTE, cookies=cookies, timeout=10)
assert r.status_code == 200, r.text
assert r.json() == body, "the PUT must echo the new effective values"
body = r.json()
for field in COLOR_FIELDS:
assert body[field] == PALETTE[field], f"effective {field} != {PALETTE[field]}"
for field, value in defaults.items():
assert body[field] == value, f"{field} must stay the effective default (A1)"
def _hold_theme_puts(page: Page, hold_s: float = 0.6) -> None:
@@ -506,18 +515,9 @@ def _release_theme_puts(page: Page) -> None:
page.unroute("**/api/ui-settings")
def _fill_theme_form(
page: Page,
palette: dict[str, str],
strings: dict[str, str] | None = None,
) -> None:
"""Fill the 20 inputs: the 3 text fields (``strings``, default
the E2E set) + the 17 color pickers (``palette`` — all 17 vars,
phase 93)."""
text_values = strings if strings is not None else SAVED_STRINGS
page.fill("#theme-app-name", text_values["app_name"])
page.fill("#theme-placeholder", text_values["input_placeholder"])
page.fill("#theme-footer", text_values["footer_text"])
def _fill_theme_form(page: Page, palette: dict[str, str]) -> None:
"""Fill the 17 color pickers (``palette`` — all 17 vars, phase 93).
The tab has no string inputs since phase 190 (A1)."""
for field, value in palette.items():
page.fill(COLOR_INPUT_IDS[field], value)
@@ -545,16 +545,14 @@ def _assert_raw_tag(raw: str, colors: dict[str, str]) -> None:
def test_save_applies_live_without_reload(page: Page, app_url: str, db_ready: None) -> None:
builtin = _builtin_colors()
page.set_default_timeout(30_000)
login(page, app_url, next="/theme.html")
# The mount's initial load has SETTLED inside this helper (the
# row-less state — no tag, no overrides) before the test touches
# the form: no in-flight load can race the save's own refetch.
_login_to_theme(page, app_url)
expect(page.locator("#theme-gate")).to_be_hidden()
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
# The mount's initial load has settled (the row-less state — no
# tag, no overrides) before the test touches the form: no
# in-flight load can race the save's own refetch.
_wait_mount_settled(page)
# Fill the 20 inputs (the 3 strings + the 17-color palette) and Save
# through the real form — the PUT held so the §7.4 in-flight state
# Fill the 17 color inputs and Save through the real form — the PUT
# held so the §7.4 in-flight state
# is observable deterministically (the same lifecycle assertions
# as the phase-91 suite — the save's contract is unchanged).
_fill_theme_form(page, PALETTE)
+10 -7
View File
@@ -467,11 +467,11 @@ def test_tab_ui_save_gray_roundtrip(page: Page, app_url: str, db_ready: None) ->
# feed the PUT body, the live preview, and the refetch).
for field in COLOR_FIELDS:
page.fill(f"#theme-{field.replace('_', '-')}", GRAY[field])
# The 3 strings stay default (untouched — they hold the effective
# template defaults the module server pins).
# The tab has no string inputs (phase 190, A1) — the 3 brand
# strings are the Settings panel's, so nothing here can touch them.
# Capture the PUT body the form builds (the wiring proof: all 17
# grays ride the wire together with the 3 default strings).
# Capture the PUT body the form builds (the wiring proof: the 17
# grays ride the wire and NOTHING else — no string key, phase 190).
captured: dict[str, Any] = {}
def handle(route: Route) -> None:
@@ -498,11 +498,14 @@ def test_tab_ui_save_gray_roundtrip(page: Page, app_url: str, db_ready: None) ->
# hidden (it re-checks the saved state on the save's refetch).
expect(page.locator("#theme-contrast")).to_be_hidden()
# The PUT carried every field: the 17 grays + the 3 default strings
# (the strings were never touched, so they ride at their defaults).
# The PUT carried the 17 grays and NOTHING else (phase 190, A1:
# the brand strings left the tab's body — an old client's save can
# no longer re-bake one and shadow the Settings panel).
body = captured.get("body")
assert isinstance(body, dict), "the Save PUT was not captured (route hook?)"
assert body == {**GRAY, **defaults}, f"unexpected PUT body: {body}"
assert body == GRAY, f"unexpected PUT body: {body}"
for field in defaults:
assert field not in body, f"{field} must never ride the tab's PUT body"
# The inputs re-populate to the SAVED (effective) grays (the
# save's refetch is the canonical state)…
+21 -34
View File
@@ -176,26 +176,20 @@ def _parse_roles(svg: str) -> dict[str, str]:
}
def _template_defaults() -> dict[str, str]:
"""The 3 template strings from the CODE defaults (derived from
the class fields — never drifts from ``app/config.py``; the
module server pins the same values, so the effective strings
start exactly here)."""
return {
"app_name": Settings.model_fields["app_name"].default,
"input_placeholder": Settings.model_fields["input_placeholder"].default,
"footer_text": Settings.model_fields["footer_text"].default,
}
def _login_to_theme(page: Page, app_url: str) -> None:
"""Log in straight into the Theme view AND wait for its mount-time
``GET /api/ui-settings`` to ANSWER — the settle signal.
def _wait_mount_settled(page: Page) -> None:
"""The theme view's mount-time load has SETTLED: its populate
is the last visible step — the app-name input carries the
effective default (the static markup ships it empty, so only a
settled ``loadSettings`` can have written it)."""
expect(page.locator("#theme-app-name")).to_have_value(
_template_defaults()["app_name"], timeout=15_000
)
Phase 190 (task 02) moved it with the tab's string fieldset: the
settle used to be read off the (now removed) ``#theme-app-name``
input, the only input the static markup shipped empty. The network
is the source of truth now."""
with page.expect_response(
lambda r: "/api/ui-settings" in r.url, timeout=30_000
) as info:
login(page, app_url, next="/theme.html")
assert info.value.status == 200, "the mount's GET /api/ui-settings failed"
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
def _favicon_href(page: Page) -> str:
@@ -519,9 +513,7 @@ def test_live_preview_repaints_the_favicon(
a non-driving pick (``#theme-bg``) never touches the href."""
page.set_default_timeout(30_000)
externals, _data_requests, failed_data, csp_violations = _zero_external(page)
login(page, app_url, next="/theme.html")
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
_wait_mount_settled(page)
_login_to_theme(page, app_url)
# A clean start: the href is the route (no row → the golden
# art served by the route).
@@ -568,9 +560,7 @@ def test_save_restores_the_route_url(
re-reads the DB per request, no process cache)."""
page.set_default_timeout(30_000)
externals, _d, failed_data, csp = _zero_external(page)
login(page, app_url, next="/theme.html")
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
_wait_mount_settled(page)
_login_to_theme(page, app_url)
# A live pick first — so the save has a data: href to restore.
for field in ("surface", "brand", "brand_ink"):
@@ -588,9 +578,10 @@ def test_save_restores_the_route_url(
assert _favicon_href(page) == "/favicon.svg"
# The settled palette governs: the route serves the saved
# palette's bytes (the PUT saved all 20 form values — the 3
# driving colors + the 14 built-ins (→ NULL) + the 3 template
# strings; the effective palette is the 3 + the built-ins).
# palette's bytes (the PUT saved the 17 form colors — the 3
# driving colors + the 14 built-ins (→ NULL); the brand strings are
# the Settings panel's since phase 190 (A1) and this body carried
# none, so the effective palette is the 3 + the built-ins).
effective = {**theming.BUILTIN_COLORS, **DRIVING}
r = httpx.get(f"{app_url}/favicon.svg", timeout=10)
assert r.status_code == 200
@@ -614,9 +605,7 @@ def test_reset_restores_the_route_url(
(the reset nulled the row — the built-ins govern)."""
page.set_default_timeout(30_000)
externals, _d, failed_data, csp = _zero_external(page)
login(page, app_url, next="/theme.html")
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
_wait_mount_settled(page)
_login_to_theme(page, app_url)
# Save first — the route is themed (the reset then has a real
# state to settle back from).
@@ -672,9 +661,7 @@ def test_view_refresh_restores_the_route_url(
golden bytes)."""
page.set_default_timeout(30_000)
externals, _d, failed_data, csp = _zero_external(page)
login(page, app_url, next="/theme.html")
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
_wait_mount_settled(page)
_login_to_theme(page, app_url)
# A live pick — the href is the data: one the re-show restores.
for field in ("surface", "brand", "brand_ink"):