"""Phase 176 E2E (Playwright): the mobile Settings Value-column width — the width contract from ``TODO.md`` L3, proven in a real browser. Source: ``TODO.md`` L3 — "On mobile, the settnigs values are impossibly small. This is a horizontally scrollable table so we can have slightly wider input fields." (the screenshot: at a 375px phone viewport the Value-column inputs render only 2–3 characters wide — "Br", "0.", "pr", "Wr", "Pc", "IN" — unreadable and unusable.) Run in isolation (DB must be up: ``podman compose up -d db``): uv run pytest tests/e2e/test_settings_mobile_value_width.py -v --no-cov Test → contract mapping (one story, one phase, one isolated file): 1. ``test_mobile_value_inputs_are_usable_width`` — the "impossibly small" pin: at 375px the admin arrives at the settings view (the phase-168 gate flow), and a representative spread of rows — one ``type="text"`` input, one ``select``, one ``textarea`` (three distinct control types among the catalogue's rows; the phase-168 catalogue guarantees text / number / select / textarea / password controls) — each renders its ``.settings-control`` bounding box ≥ 192px wide (12 rem — owner-locked A3: the deliberate margin under the 13 rem rule; the pin is 12 rem and ≥ 200px is NOT asserted, so the pin is not brittle to sub-pixel rounding), the ``.settings-table``'s own ``scrollWidth`` exceeds the ``.table-wrap`` card's ``clientWidth`` (the horizontal scroll is active on the phone — the TODO's own premise, the 720px mobile floor, owner-locked A2), while ``document.documentElement.scrollWidth ≤ 375`` (the PAGE does not overflow — the phase-171 pin: the scroll is the card's, not the page's). 2. ``test_mobile_table_still_scrolls_and_page_does_not_overflow`` — the "the card scrolls, never the page" pin: at 375px the ``.table-wrap`` computed ``overflow`` is ``auto``, the card's ``scrollWidth > clientWidth``, the page-level ``documentElement.scrollWidth ≤ 375 + 1`` (1px rounding), and the sticky-``thead`` contract survives — scroll the card and the ``thead th``s stay pinned at the card's top edge (computed ``position: sticky`` — the phase-156/168/171 catalogue contract intact under the new column floor). 3. ``test_desktop_value_width_is_unchanged`` — the "no desktop regression" pin: at 1440px the media query is INERT — the computed ``min-width`` of ``.settings-value-cell`` (and ``.settings-value-th``) is the UA ``0px``/``auto`` (``getComputedStyle`` returns ``0px`` or ``auto``, never ``208px``/``208.0px`` — the 13rem floor binds only inside the ≤640px block, owner-locked A1) and the ``.settings-table`` ``scrollWidth`` ≤ the card's ``clientWidth`` (no forced scroll at desktop — the 720px floor is inert, the 560px base rule is subsumed by the card's width). 4. ``test_keyboard_order_and_no_external_requests`` — PLAN §7.1 + rule 6: at 375px the keyboard tab order still reaches ``#settings-filter`` → the first row's Value control → ``#settings-save`` → ``#settings-clear-all`` (the phase-168/171 contract under the new column floor), and the whole test issues ZERO external (non-127.0.0.1) 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 NONE (no save is issued — the width contract needs no dirty row; test 4's typed row is form-dirty only, never PUT): the per-test TRUNCATE before/after is the same idiom as phases 168/171, 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; the panel's gate flow — ``#settings-disabled`` hidden — needs the overlay ON) and pins the branding base to DISTINCTIVE values (``Env placeholder for panel E2E`` / ``Env footer for panel E2E``, the phase-168 idiom). 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 phase-168 panel-suite idiom — the #: module boots exactly like the panel/layout suites, so the width #: contract is proven against the same app shape). ENV_PLACEHOLDER = "Env placeholder for panel E2E" ENV_FOOTER = "Env footer for panel E2E" #: The typed (never saved) dirty-row value — test 4's Save must be #: ENABLED (a disabled button is not tabbable) for the keyboard #: walk. DIRTY_VALUE = "E2E width brain" # --------------------------------------------------------------------------- # Per-module app env (the phase-171 env block — 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 176: the module opts BACK IN over the conftest's global # BOR_SETTINGS_DB=0 pin (the test_pictorial_pdf.py pattern) — # the panel's gate flow (test 1) needs the overlay ON. env["BOR_SETTINGS_DB"] = settings_db # The distinctive env base: the module boots exactly like the # phase-168/171 panel suites (the width contract is proven # against the same app shape). 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 phase-168 idiom) 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" def _login_admin(page: Page, app_url: str) -> None: """The phase-168 gate flow: the admin arrives at the settings view (the gate down, the overlay on).""" page.set_default_timeout(30_000) login(page, app_url, next="/settings.html") expect(page.locator("#nav-settings")).to_be_visible(timeout=15_000) expect(page.locator("#settings-content")).to_be_visible(timeout=15_000) expect(page.locator("#settings-gate")).to_be_hidden() expect(page.locator("#settings-disabled")).to_be_hidden() # the overlay is on # --------------------------------------------------------------------------- # 1. The "impossibly small" pin: a representative spread of rows — # one text input, one select, one textarea — each renders its # .settings-control ≥ 192px wide at 375px (12 rem, A3), the card # scrolls horizontally, the page does not # --------------------------------------------------------------------------- def test_mobile_value_inputs_are_usable_width( page: Page, app_url: str, db_ready: None ) -> None: # The gate flow at the default (1280) viewport — the nav link is # only visible on desktop (the ≤640px hamburger owns the nav # pill); the width contract itself is proven at 375px below. _login_admin(page, app_url) page.set_viewport_size({"width": 375, "height": 900}) page.wait_for_timeout(150) # the reflow settles before the measure expect(page.locator("#settings-content")).to_be_visible() # A representative spread: the FIRST row of each of three # distinct control types (one type="text" input, one select, one # textarea) among the catalogue's rows (the phase-168 # catalogue guarantees text / number / select / textarea / # password controls). keys = page.evaluate( """() => { const row = (sel) => { const c = document.querySelector(sel); return c ? c.closest("tr").dataset.key : null; }; return { text: row("tr.settings-row input.settings-control[type='text']"), select: row("tr.settings-row select.settings-control"), textarea: row("tr.settings-row textarea.settings-control"), }; }""" ) rows = page.evaluate( "() => document.querySelectorAll('tr.settings-row').length" ) assert keys["text"] is not None, ( f"the catalogue must carry a text-input row (catalogue = {rows} rows)" ) assert keys["select"] is not None, "the catalogue must carry a select row" assert keys["textarea"] is not None, "the catalogue must carry a textarea row" assert len(set(keys.values())) == 3, ( f"the three probes must be three DISTINCT rows (got {keys!r})" ) # The "impossibly small" pin: every probe's .settings-control # bounding box is ≥ 192px (12 rem — owner-locked A3: the # deliberate margin under the 13 rem rule). The pin is 12 rem; # ≥ 200px is deliberately NOT asserted (13 rem ≈ 208px is the # RULE, 12 rem the pin) so the pin is not brittle to sub-pixel # rounding. for kind, key in keys.items(): box = page.locator( f'tr[data-key="{key}"] .settings-control' ).bounding_box() assert box is not None, f"the {kind} control ({key}) must render at 375px" assert box["width"] >= 192, ( f"the {kind} control ({key}) must be ≥ 192px (12 rem) at 375px " f"(got {box['width']:.1f}px)" ) # The TODO's own premise: the table is horizontally scrollable — # at 375px the .settings-table's own scrollWidth exceeds the # .table-wrap card's clientWidth (the 720px mobile floor, # owner-locked A2)… scroll = page.evaluate( """() => { const card = document.querySelector(".settings-shell .table-wrap"); const table = document.querySelector( ".settings-shell .table-wrap .settings-table"); return { table: table.scrollWidth, card: card.clientWidth }; }""" ) assert scroll["table"] > scroll["card"], ( f"at 375px the table must overflow the card horizontally " f"(table {scroll['table']}px vs card {scroll['card']}px)" ) # …while the PAGE itself does not overflow (the phase-171 pin — # the scroll is the card's, not the page's). page_width = page.evaluate("() => document.documentElement.scrollWidth") assert page_width <= 375, ( f"the page must not overflow at 375px (got {page_width}px)" ) # --------------------------------------------------------------------------- # 2. The "the card scrolls, never the page" pin: overflow: auto, # scrollWidth > clientWidth, no page overflow, the sticky thead # survives a card scroll # --------------------------------------------------------------------------- def test_mobile_table_still_scrolls_and_page_does_not_overflow( page: Page, app_url: str, db_ready: None ) -> None: _login_admin(page, app_url) page.set_viewport_size({"width": 375, "height": 900}) page.wait_for_timeout(150) # the reflow settles before the measure expect(page.locator("#settings-content")).to_be_visible() # The .table-wrap is the bounded-scroll card (the phase-156 # catalogue contract): computed overflow auto… overflow = page.evaluate( "() => getComputedStyle(" "document.querySelector('.settings-shell .table-wrap')).overflow" ) assert overflow == "auto", ( f"the card's computed overflow must be `auto` (got {overflow!r})" ) # …and at 375px the card's horizontal scroll is active # (scrollWidth > clientWidth — the 720px table floor, the # TODO's premise). dims = page.evaluate( """() => { const card = document.querySelector(".settings-shell .table-wrap"); return { scroll: card.scrollWidth, client: card.clientWidth }; }""" ) assert dims["scroll"] > dims["client"], ( f"the card's horizontal scroll must be active at 375px " f"(scrollWidth {dims['scroll']}px vs clientWidth {dims['client']}px)" ) # …while the PAGE-level width stays inside the viewport (1px # rounding — the phase-171 pin). page_width = page.evaluate("() => document.documentElement.scrollWidth") assert page_width <= 375 + 1, ( f"the page must not overflow at 375px (got {page_width}px)" ) # The sticky-``thead`` contract survives the new column floor: # scroll the card down and the thead ths stay pinned at the # card's top edge (computed position: sticky — the phase-156/168 # contract, inherited). page.evaluate( "() => { const c = document.querySelector('.settings-shell .table-wrap');" " c.scrollTop = 300; }" ) page.wait_for_timeout(100) # the sticky reposition settles 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 after a card scroll (got {sticky!r})" ) pinned = page.evaluate( """() => { const card = document.querySelector(".settings-shell .table-wrap"); const th = document.querySelector( ".settings-shell .table-wrap .settings-table thead th"); return { th: th.getBoundingClientRect().top, card: card.getBoundingClientRect().top }; }""" ) assert abs(pinned["th"] - pinned["card"]) <= 1, ( f"the thead th must stay pinned at the card's top edge after a " f"scroll (th {pinned['th']:.1f}px vs card {pinned['card']:.1f}px)" ) # --------------------------------------------------------------------------- # 3. The "no desktop regression" pin: at 1440px the media query is # INERT — no 13rem floor on the Value cell, no forced scroll # --------------------------------------------------------------------------- def test_desktop_value_width_is_unchanged( page: Page, app_url: str, db_ready: None ) -> None: _login_admin(page, app_url) page.set_viewport_size({"width": 1440, "height": 900}) page.wait_for_timeout(150) # the reflow settles before the measure expect(page.locator("#settings-content")).to_be_visible() # The fixed probe row (the first type="text" control) renders — # the desktop layout is the pre-phase natural layout. first = page.locator("tr.settings-row input[type='text']").first.bounding_box() assert first is not None, "the first text control must render at 1440px" assert first["width"] > 0 and first["height"] > 0, ( "the first text control must be non-degenerate at 1440px" ) # The contract (the media query is INERT — owner-locked A1): the # computed min-width of .settings-value-cell (and its header # twin .settings-value-th) at 1440px is the UA 0px/auto — # never 208px/208.0px (the 13rem floor binds only inside the # ≤640px block; the 720px floor with it). for selector in (".settings-value-cell", ".settings-value-th"): min_width = page.evaluate( f"() => getComputedStyle(" f"document.querySelector('{selector}')).minWidth" ) normalized = min_width.strip().lower() assert normalized in ("0px", "0.0px", "auto"), ( f"the 13rem floor must be inert at 1440px: {selector} computed " f"min-width must be `0px`/`auto` (got {min_width!r})" ) assert "208" not in min_width, ( f"the 13rem floor (208px) must not apply at 1440px " f"({selector} min-width {min_width!r})" ) # And no forced horizontal scroll at desktop: the table's # scrollWidth stays inside the card's clientWidth (the 720px # floor is inert — the 560px base rule is subsumed by the # card's width). dims = page.evaluate( """() => { const card = document.querySelector(".settings-shell .table-wrap"); const table = document.querySelector( ".settings-shell .table-wrap .settings-table"); return { table: table.scrollWidth, card: card.clientWidth }; }""" ) assert dims["table"] <= dims["card"], ( f"the table must not force horizontal scroll at 1440px " f"(table {dims['table']}px vs card {dims['card']}px)" ) # --------------------------------------------------------------------------- # 4. PLAN §7.1 + rule 6: the keyboard walk under the new column # floor at 375px, and ZERO external requests # --------------------------------------------------------------------------- def test_keyboard_order_and_no_external_requests( 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_admin(page, app_url) page.set_viewport_size({"width": 375, "height": 900}) page.wait_for_timeout(150) # the reflow settles before the measure expect(page.locator("#settings-content")).to_be_visible() # The first row's Value control — the walk's middle stop. first_control_id = page.evaluate( "() => document.querySelector('tr.settings-row .settings-control').id" ) assert first_control_id.startswith("set-"), ( f"the first row's Value control must be a settings control " f"(got {first_control_id!r})" ) # A dirty row (typed, never saved — the suite leaves no override # and issues no save): the walk's Save must be ENABLED (a # disabled button is not tabbable). page.fill(f"#{first_control_id}", DIRTY_VALUE) expect(page.locator("#settings-dirty")).to_have_text("1 unsaved changes") expect(page.locator("#settings-save")).to_be_enabled() # The keyboard tab order still reaches filter → first row's Value # control → Save settings → Clear all overrides (the # phase-168/171 contract under the new column floor, at 375px). # 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.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 == first_control_id), 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, ( f"the walk never reached the first row's Value control " f"({first_control_id}; seen={seen[:20]})" ) 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" # The suite's only write is NONE: no save was issued (the dirty # row is form-state only — the per-test TRUNCATE + the module # teardown pin app_settings empty). # 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}"