"""Phase 171 E2E (Playwright): the admin Settings page LAYOUT — the four layout contracts from ``TODO.md`` L4, proven in a real browser. Source: ``TODO.md`` L4 — "In the admin settings page: The search settings text box overlaps settings table. The settings category headers are not differentiated from the settings themselves, making them confusing at a glance. The save settings and clear all overrides are smushed right up against the settings table. The save settings shows a loading cursor when moused over rather than just being disabled." Run in isolation (DB must be up: ``podman compose up -d db``): uv run pytest tests/e2e/test_admin_settings_layout.py -v --no-cov Test → contract mapping (one story, one phase, one isolated file): 1. ``test_search_row_is_clear_of_the_table`` — the "overlap" pin: at 1440px and 375px the ``#settings-filter`` box and the ``.table-wrap`` card's box do not intersect (the vertical gap between the search row's bottom and the card's top is > 0), and the card keeps the phase-156/168 bounded-scroll + sticky- ``thead`` contract under the new ``#settings-content`` rhythm. 2. ``test_actions_row_is_gapped_from_the_table`` — the "smushed" pin: at 1440px and 375px the ``.settings-actions`` row is gapped from the card (the gap between the card's bottom and the action row's top is > 0) and ``#settings-save`` / ``#settings-clear-all`` are both reachable (no clipping at either viewport). 3. ``test_group_bands_are_distinct_from_the_rows`` — the "not differentiated" pin: the ``Retrieval & honesty`` and ``LLM (aipi)`` ``tr.settings-group-row`` bands render; a band's computed background-color ≠ the ``.table-wrap`` card's computed background (the ``--surface`` row-cell fill), the band's computed font-weight is ≥ 700 and its computed border-top-width is ≥ 1px (the hairline — no merge with the card edge or a previous band). 4. ``test_disabled_save_is_just_disabled`` — the "loading cursor" pin: before any edit ``#settings-save`` is disabled with ``cursor: default`` (NOT ``wait``); editing one row (a ``type="search"`` filter + a value change — the phase-168 dirty-row flow) enables it (``cursor: pointer``) and a settled **Save settings** dims it back to ``default`` (the §7.4 dimmed-``aria-busy`` lifecycle, phase 168, intact under the new cursor). 5. ``test_settings_page_layout_and_accessibility`` — PLAN §7.1 + rule 6: no horizontal page overflow at 375px and 1440px, the table is ≥80% of the 72rem container (not a skinny list), the keyboard tab order still reaches filter → first row input → **Save settings** → **Clear all overrides** (the phase-168 keyboard contract under the new rhythm), and the whole test issues ZERO external requests. DB isolation: the shared e2e Postgres keeps ``app_settings`` (the overlay rows — a leftover row would retune every later suite's app through the conftest pin's failure mode), ``ui_settings`` (the single row the caching middleware reads for EVERY served page — the effective-strings resolution ``/api/config`` uses) and ``api_tokens`` rows across suites. An autouse fixture truncates ``app_settings`` and ``ui_settings`` and deletes the ``e2e-``-labeled tokens before AND after every test (never a TRUNCATE on ``api_tokens`` — the shared DB may hold the owner's real tokens); the module teardown pins ``app_settings`` empty. This suite's only write is test 4's one-row save (the lifecycle pin) — the per-test TRUNCATE before/ after is the same idiom as phase 168, and the teardown guarantees no leftover row. The module app opts BACK IN over the conftest's global ``BOR_SETTINGS_DB=0`` pin (``env["BOR_SETTINGS_DB"] = "1"`` — the ``test_pictorial_pdf.py`` opt-in pattern) and pins the branding base to DISTINCTIVE values (``Env placeholder for panel E2E`` / ``Env footer for panel E2E``). A module-scope ``.env`` mtime guard pins LOCKED A1: no code path the suite drives opens the env file for writing. """ from __future__ import annotations import json import os import subprocess import sys from collections.abc import Iterator import pytest from playwright.sync_api import Page, expect from sqlalchemy import text from app.config import Settings from app.db import SessionLocal 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 distinctive env base (the panel-suite pin): "the DB row now #: wins" is a VISIBLE DOM change, not a no-op against the code #: default. ENV_PLACEHOLDER = "Env placeholder for panel E2E" ENV_FOOTER = "Env footer for panel E2E" #: The save's own acceptance value (test 4 — the dirty-row flow). SAVED_PLACEHOLDER = "DB placeholder — panel saved this" # --------------------------------------------------------------------------- # Per-module app env (the tuning/theme pattern — the test_admin_settings_panel.py # fixture, verbatim) # --------------------------------------------------------------------------- def _boot_app(port: int, settings_db: str) -> subprocess.Popen[bytes]: """One module app (the shared env block + the task's pins), on ``port`` with the overlay ``settings_db`` (``"1"`` / ``"0"``).""" 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_PORT}/v1" ) # The E2E-calibrated gate pins (the conftest pattern — the mock's # token-overlap distribution, explicit so an operator's local # .env cannot leak a different gate into the suite's app). 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 ) # The app name stays the CODE default (the theme-suite pin — the # assertions below name it). env["BOR_APP_NAME"] = Settings.model_fields["app_name"].default # Phase 168 (task 06): the module opts BACK IN over the conftest's # global BOR_SETTINGS_DB=0 pin (the test_pictorial_pdf.py pattern) # — or stays OUT for the kill-switch app (test 11). env["BOR_SETTINGS_DB"] = settings_db # The distinctive env base: "the DB row now wins" is a visible # DOM change, not a no-op against the default. env["BOR_INPUT_PLACEHOLDER"] = ENV_PLACEHOLDER env["BOR_FOOTER_TEXT"] = ENV_FOOTER env["BOR_GIT_SOURCES"] = "" return subprocess.Popen( [sys.executable, "-m", "uvicorn", "app.main:app", "--host", "127.0.0.1", "--port", str(port), "--log-level", "warning"], cwd=REPO, env=env, ) def _stop_app(proc: subprocess.Popen[bytes]) -> None: proc.terminate() try: proc.wait(timeout=10) except subprocess.TimeoutExpired: proc.kill() @pytest.fixture(scope="module") def app_server(mock_llm: int) -> Iterator[str]: """The real app under test — per-module env: the branding vars pinned to the DISTINCTIVE E2E values (the panel's saved value must visibly WIN over them) and ``BOR_SETTINGS_DB=1`` (the module opts back in over the conftest pin).""" global _MOCK_PORT _MOCK_PORT = mock_llm proc = _boot_app(APP_PORT, "1") try: _wait_http(f"{APP_URL}/api/health") yield APP_URL finally: _stop_app(proc) @pytest.fixture(scope="module") def app_url(app_server: str) -> str: return app_server # --------------------------------------------------------------------------- # DB isolation + helpers # --------------------------------------------------------------------------- def _clean_settings_state() -> None: """Fresh settings + theme + token state per test: truncate ``app_settings`` (a leftover row is the cross-suite poison this phase's D6 exists to prevent) and the single-row ``ui_settings`` (the middleware reads it for EVERY page — the effective-strings resolution ``/api/config`` uses), and delete this suite's issued tokens (label-scoped on ``e2e-`` — never a TRUNCATE: the shared DB may hold the owner's real tokens).""" with SessionLocal() as db: db.execute(text("TRUNCATE app_settings")) db.execute(text("TRUNCATE ui_settings")) db.execute(text("DELETE FROM api_tokens WHERE label LIKE 'e2e-%'")) db.commit() @pytest.fixture(autouse=True) def _clean(db_ready: None) -> Iterator[None]: _clean_settings_state() yield _clean_settings_state() # The module's final state: app_settings EMPTY (the D6 poison # guard — the next suite's app must boot with no overrides). with SessionLocal() as db: assert db.execute(text("SELECT count(*) FROM app_settings")).scalar() == 0 @pytest.fixture(scope="module", autouse=True) def _env_never_written() -> Iterator[None]: """LOCKED A1, mtime-pinned: no code path the suite drives opens ``.env`` for writing (the file is gitignored — a ``git status`` check cannot see it, so ``stat``'s mtime is the assertion).""" env_file = REPO / ".env" before = env_file.stat().st_mtime_ns if env_file.exists() else None yield after = env_file.stat().st_mtime_ns if env_file.exists() else None assert before == after, "the app under test must never write .env" # --------------------------------------------------------------------------- # 1. The "overlap" pin: the search row clears the table card (and the # card keeps its bounded scroll + sticky thead under the new # rhythm) # --------------------------------------------------------------------------- def test_search_row_is_clear_of_the_table(page: Page, app_url: str, db_ready: None) -> None: page.set_default_timeout(30_000) login(page, app_url, next="/settings.html") expect(page.locator("#settings-content")).to_be_visible(timeout=15_000) for width in (1440, 375): page.set_viewport_size({"width": width, "height": 900}) page.wait_for_timeout(150) # the reflow settles before the measure filter_box = page.locator("#settings-filter").bounding_box() search_box = page.locator(".settings-search").bounding_box() card_box = page.locator(".settings-shell .table-wrap").bounding_box() assert filter_box is not None, f"the filter input must render at {width}px" assert search_box is not None, f"the search row must render at {width}px" assert card_box is not None, f"the table card must render at {width}px" # The "overlap" pin: the filter input's box does not intersect # the card's box… assert filter_box["y"] + filter_box["height"] <= card_box["y"], ( f"the search input overlaps the table card at {width}px " f"(input bottom {filter_box['y'] + filter_box['height']:.1f} " f"vs card top {card_box['y']:.1f})" ) # …and the row as a whole clears the card: the vertical gap # between the search row's bottom and the card's top is > 0 # (the #settings-content flex gap, phase 171 task 01). gap = card_box["y"] - (search_box["y"] + search_box["height"]) assert gap > 0, ( f"the search row must clear the table card at {width}px " f"(gap {gap:.1f}px)" ) # The card keeps the phase-156/168 contract under the new # rhythm: the sticky thead (the bounded scroll's sticky # header) is intact. sticky = page.evaluate( "() => getComputedStyle(" "document.querySelector(" "'.settings-shell .table-wrap .settings-table thead th'" ")).position" ) assert sticky == "sticky", ( f"the thead th must stay sticky at {width}px (got {sticky!r})" ) # --------------------------------------------------------------------------- # 2. The "smushed" pin: the action row is gapped from the table card # and both action buttons are reachable (no clipping) # --------------------------------------------------------------------------- def test_actions_row_is_gapped_from_the_table(page: Page, app_url: str, db_ready: None) -> None: page.set_default_timeout(30_000) login(page, app_url, next="/settings.html") expect(page.locator("#settings-content")).to_be_visible(timeout=15_000) for width in (1440, 375): page.set_viewport_size({"width": width, "height": 900}) page.wait_for_timeout(150) # the reflow settles before the measure card_box = page.locator(".settings-shell .table-wrap").bounding_box() actions_box = page.locator(".settings-actions").bounding_box() assert card_box is not None, f"the table card must render at {width}px" assert actions_box is not None, f"the action row must render at {width}px" # The "smushed" pin: the gap between the card's bottom and # the action row's top is > 0 (the #settings-content flex # gap, phase 171 task 01). gap = actions_box["y"] - (card_box["y"] + card_box["height"]) assert gap > 0, ( f"the action row must be gapped from the table card at " f"{width}px (gap {gap:.1f}px)" ) # Both action buttons are reachable — visible, non-degenerate, # and not clipped horizontally at either viewport (at 375px # the media query stacks the row; the buttons must not be # cut off). for selector in ("#settings-save", "#settings-clear-all"): expect(page.locator(selector)).to_be_visible() box = page.locator(selector).bounding_box() assert box is not None, f"{selector} must render at {width}px" assert box["width"] > 0 and box["height"] > 0, ( f"{selector} must be non-degenerate at {width}px" ) assert box["x"] >= 0, f"{selector} is clipped off-canvas at {width}px" assert box["x"] + box["width"] <= width + 1, ( f"{selector} is clipped horizontally at {width}px " f"(right edge {box['x'] + box['width']:.1f} > {width})" ) # --------------------------------------------------------------------------- # 3. The "not differentiated" pin: the group bands are distinct from # the rows (the --brand-soft tint, weight 700, the top hairline) # --------------------------------------------------------------------------- def test_group_bands_are_distinct_from_the_rows( page: Page, app_url: str, db_ready: None ) -> None: 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 pinned groups render (in the server's order — at least # these two). expect( page.locator("tr.settings-group-row", has_text="Retrieval & honesty") ).to_be_visible() expect(page.locator("tr.settings-group-row", has_text="LLM (aipi)")).to_be_visible() # The "not differentiated" pin: a band's computed # background-color ≠ the .table-wrap card's computed background # (the --surface row-cell fill), the band's font-weight is ≥ # 700, and the band carries a ≥1px top hairline (no merge with # the card's top edge or the band above it). band = page.evaluate( """() => { const th = document.querySelector('tr.settings-group-row th'); const card = document.querySelector('.settings-shell .table-wrap'); const t = getComputedStyle(th); return { band: t.backgroundColor, card: getComputedStyle(card).backgroundColor, weight: t.fontWeight, borderTop: parseFloat(t.borderTopWidth), }; }""" ) assert band["band"] != band["card"], ( f"the band must be tinted distinct from the card " f"({band['band']!r} vs {band['card']!r})" ) assert int(band["weight"]) >= 700, ( f"the band must be weight ≥ 700 (got {band['weight']!r})" ) assert float(band["borderTop"]) >= 1.0, ( f"the band must carry a ≥1px top hairline (got {band['borderTop']!r})" ) # --------------------------------------------------------------------------- # 4. The "loading cursor" pin: a disabled Save is JUST disabled # (cursor: default, not wait) — the §7.4 lifecycle intact under # the new cursor # --------------------------------------------------------------------------- def test_disabled_save_is_just_disabled(page: Page, app_url: str, db_ready: None) -> None: 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 search input is a type="search" control (the phase-168 # client-side filter row)… assert page.locator("#settings-filter").get_attribute("type") == "search" # Before any edit: Save is disabled and its computed cursor is # `default` — NOT `wait` (the loading-cursor pin, TODO L4). expect(page.locator("#settings-save")).to_be_disabled() cursor = page.evaluate( "() => getComputedStyle(document.querySelector('#settings-save')).cursor" ) assert cursor == "default", ( f"a disabled Save must show `default` (got {cursor!r})" ) assert cursor != "wait", "the loading cursor must be gone" # Edit one row (the phase-168 dirty-row flow): filter to the row, # change the value… page.fill("#settings-filter", "placeholder") expect(page.locator('tr[data-key="input_placeholder"]')).to_be_visible() page.fill('tr[data-key="input_placeholder"] .settings-control', SAVED_PLACEHOLDER) expect(page.locator("#settings-dirty")).to_have_text("1 unsaved changes") # …the button ENABLES — cursor `pointer` (a live control)… expect(page.locator("#settings-save")).to_be_enabled() cursor = page.evaluate( "() => getComputedStyle(document.querySelector('#settings-save')).cursor" ) assert cursor == "pointer", ( f"an enabled Save must show `pointer` (got {cursor!r})" ) # …Save settings (the §7.4 in-flight state is the dimmed # :disabled + aria-busy) and wait for it to settle… page.click("#settings-save") expect(page.locator("#settings-status")).to_contain_text( "Saved 1 settings.", timeout=30_000 ) # …the settled state: back to disabled, dimmed (opacity 0.6), # the aria-busy removed, and the cursor `default` again (the # §7.4 lifecycle, phase 168, intact under the new cursor). expect(page.locator("#settings-save")).to_be_disabled() expect(page.locator("#settings-save")).not_to_have_attribute("aria-busy") style = page.evaluate( """() => { const s = getComputedStyle(document.querySelector('#settings-save')); return { cursor: s.cursor, opacity: s.opacity }; }""" ) assert style["cursor"] == "default", ( f"the settled Save must show `default` (got {style['cursor']!r})" ) assert float(style["opacity"]) == 0.6, ( f"the settled Save must be dimmed to 0.6 (got {style['opacity']!r})" ) # --------------------------------------------------------------------------- # 5. PLAN §7.1 + rule 6: no horizontal overflow at 375/1440, the # table is ≥80% of the container, the keyboard walk, zero # external requests # --------------------------------------------------------------------------- def test_settings_page_layout_and_accessibility( page: Page, app_url: str, db_ready: None ) -> None: page.set_default_timeout(30_000) externals: list[str] = [] def _watch(request: object) -> None: url = getattr(request, "url", "") if url.startswith(("http://", "https://")) and "127.0.0.1" not in url: externals.append(url) page.on("request", _watch) login(page, app_url, next="/settings.html") expect(page.locator("#settings-content")).to_be_visible(timeout=15_000) # A dirty row (typed, never saved — the suite leaves no # override): the walk's Save must be ENABLED (a disabled # button is not tabbable). page.fill('tr[data-key="app_name"] .settings-control', "E2E layout brain") expect(page.locator("#settings-save")).to_be_enabled() # No horizontal page overflow at 375px or at 1440px (the 72rem # column at every viewport — the table scrolls INSIDE its card, # the page itself never does). for width in (375, 1440): page.set_viewport_size({"width": width, "height": 900}) page.wait_for_timeout(150) # the reflow settles before the measure overflow = page.evaluate( "() => document.documentElement.scrollWidth <= window.innerWidth + 1" ) assert overflow, f"horizontal overflow at {width}px" # At 1440 the table is ≥80% of the 72rem container (not a # skinny wasted-space list — PLAN §7.1). ratio = page.evaluate( """() => document.querySelector('.settings-shell .table-wrap') .getBoundingClientRect().width / document.querySelector('.settings-shell').getBoundingClientRect().width""" ) assert ratio >= 0.8, f"the table must be ≥80% of the container (got {ratio:.2f})" # The keyboard tab order still reaches filter → first row input → # Save settings → Clear all overrides (the phase-168 keyboard # contract under the new rhythm). The walk starts at the # skip-link — the document's FIRST tabbable element (a blur() # would only continue the cycle after the last focused element, # so the script focuses the cycle start; every move after it is # a real Tab keypress). page.set_viewport_size({"width": 1280, "height": 800}) page.evaluate("() => document.querySelector('a.skip-link').focus()") seen: list[str] = [] for _ in range(200): page.keyboard.press("Tab") seen.append( page.evaluate( """() => { const el = document.activeElement; if (!el) return ""; return el.id || (typeof el.className === "string" ? el.className : ""); }""" ) ) i_filter = next((i for i, s in enumerate(seen) if s == "settings-filter"), None) i_first_input = next((i for i, s in enumerate(seen) if s.startswith("set-")), None) i_save = next((i for i, s in enumerate(seen) if s == "settings-save"), None) i_clear = next((i for i, s in enumerate(seen) if s == "settings-clear-all"), None) assert i_filter is not None, f"the walk never reached the filter (seen={seen[:20]})" assert i_first_input is not None, "the walk never reached a settings control" assert i_save is not None, "the walk never reached Save (the dirty row enables it)" assert i_clear is not None, "the walk never reached Clear all" assert i_filter < i_first_input, "the filter must precede the first input" assert i_first_input < i_save, "the first input must precede Save" assert i_save < i_clear, "Save must precede Clear all" # Rule 6: the whole test issued ZERO external requests (the CSP # default-src 'self' posture — every asset is local). assert externals == [], f"external requests leaked: {externals}"