Files
brain-of-reese/tests/e2e/test_settings_mobile_value_width.py
T
ducoterra b34b8ca672
Build and Push Containers / build-and-push-app (push) Successful in 1m46s
Build and Push Containers / build-and-push-db (push) Successful in 12s
phase: 182_verbatim_show_text
**Phase 182 — final verification pass (all 8 tasks already complete; this was verification + docs + one defect fix).**

- Verified: `app/rag/verbatim.py` (locator grammar, `splitlines()` slice, line-boundary cap, `resolve_answer_blocks`), `SHOW_TEXT_TOOL` + `shown_texts`, `SHOW_TEXT_CLAUSE`, `done.answer_text` (9 fields, key dropped when empty) + `ChatMessageRecord.answer_text` (≤50, 600 000-char cap counted in the 8 M budget), `verbatim.js` + 4 `{verbatim:true}` renderers, `Containerfile` minify step, `.env.example` 3 vars.
- `uv run python -c "…answer_text, show_text_max_chars, show_text_max_blocks"` → `True 12000 5`; each negative refuses startup naming its field; `0` accepted as inert ✅
- `bash .agents/validate.sh` → **validation OK**: 4320 unit+integration tests green, `TOTAL 6849 17 → 99%` (gate app/ >90% OK), `ruff check .` "All checks passed!", `pyright` 0 errors (re-run after my edits, 08:28).
- E2E, each in isolation `--no-cov`: `test_verbatim_show_text.py` 13 passed (panel bytes == `documents.content`, server caption, clipboard == server string, `/document.html?source=…&path=…`, honest unresolved state, reload+share, `show_image` regression, flag-off app) ✅; green: `test_answer_images` `test_harness_aligned_tools` `test_agent_document_tools` `test_markdown_tables` `test_markdown_inline_links_images` `test_svg_document_preview` `test_smoke` `test_document_viewer` `test_markdown_blockquotes` `test_chat_persistence` `test_thinking_timeline` `test_chat_rag` `test_settings_mobile_value_width`.
- **Defect found & fixed (pre-existing, not phase 182):** `tests/e2e/test_admin_settings_panel.py::test_admin_sees_groups_and_source_badges` failed — its whole-page `assert "reese" not in page.content()` tripped on the shell's onboarding chips, which follow the newest saved chats in the one shared E2E DB (a leftover `reeselink.json` question from a tool suite). Scoped the strict sweep to `.settings-shell` and kept a whole-page `reese:reese` credential needle → 12 passed with the leftovers still present.
- Docs: README fixed 2 inconsistencies — Quick Start omitted the **required** `BOR_ADMIN_PASSWORD`/`BOR_SESSION_SECRET` (the app fail-loud refuses boot) and the broken anchor `#chat--` → `#chat-`; full re-read of README (1816 lines) + AGENTS confirmed the phase-182 feature/settings/log/UI-contract text matches the code, and every `.env.example` var + every `Settings` field is documented.
- Notable: kept `resolve_answer_blocks` synchronous on the SSE terminal path (bounded indexed SELECTs, matching the existing `query_log` precedent and the phase's documented fence-lookup bound); no live-infra changes made.
- Next pending phase: none — `todo/` holds only 182 (its 8 task files are in `complete/`; the harness commits + moves `00_phase.md`).
2026-10-15 08:33:12 -04:00

582 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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}"