"""Phase 174 E2E (Playwright): the Sources (git sources) page — the card stack regains its 1.25rem rhythm. Source: owner bug report 2026-10-21 (screenshot of ``/git-sources.html``): "the bubbles on the sources page are really smooshed together" — the "Git sources" header card, the "Add a git source" card, the "Upload a source archive" card, and the sources table card stacked flush; only their 1px borders separated them. Root cause: the ``.git-sources-shell`` gap (1.25rem) only separates the sign-in gate from the single ``#git-sources-content`` wrapper; every visible card is a child of THAT div, which was a plain block. The fix (task 01) is the phase-171 ``#settings-content`` idiom applied to the one remaining admin view whose cards live inside a wrapper div: ``#git-sources-content`` itself becomes ``display: flex; flex-direction: column; gap: 1.25rem;`` — the shell's OWN existing token (no new spacing value). This suite proves the rhythm IN A BROWSER at 1440px and 375px, pins the hidden-children behavior (D2) on both sides, keeps the anonymous gate↔content flip (D3) intact, and runs the standard a11y / no-CDN light pass (AGENTS.md rules 5/6). Run in isolation (DB must be up: ``podman compose up -d db``): uv run pytest tests/e2e/test_git_sources_card_rhythm.py -v --no-cov This suite is UI-only: NO sync, NO git, NO network — the app boots plain and the cards render regardless of row count (the one seeded row per test below is the test-isolation reset that makes the TABLE card render: git-sources.js hides ``#git-sources-table-wrap`` while the table has no rows and shows ``#git-sources-empty`` instead). The per-test ``git_sources`` TRUNCATE (the ``test_git_sources_admin.py`` pattern) makes each test's table state its own. Per-module app env (the conftest pattern, module-scoped — the ``test_git_sources_admin.py`` app shape): this suite's app boots PLAIN but pins ``BOR_GIT_SOURCES=""`` (the phase-61 leak guard): the operator's local (gitignored) ``.env`` is loaded by pydantic-settings from ``cwd=REPO`` and may carry a real ``BOR_GIT_SOURCES`` value — with the per-test truncated (EMPTY) ``git_sources`` table, a leaked CSV would flip the page to the env fallback (env rows + the visible ``#git-sources-env-note``) and break the hidden-children pin and the four-card gap contract below. The session app is never started in this isolated run, so no port clash. Test → contract mapping (one phase, one isolated file): 1. ``test_cards_stack_with_uniform_rhythm_desktop`` — the "smooshed" pin at 1440×900 (admin): ``#git-sources-content``'s computed display is ``flex`` (column, 20px gap — the 1.25rem token at the 16px root) and the four visible cards — ``.page-head`` → ``#git-source-form`` → ``#archive-upload-form`` → ``#git-sources-table-wrap`` — are pairwise disjoint with each successive gap 20 ± 2px (pre-fix the gaps were ~0 — the 1px borders). 2. ``test_cards_stack_with_uniform_rhythm_mobile`` — the pin at 375×720 (admin): the same four cards are pairwise disjoint with ≈20px gaps; the two form cards column-stack per the ≤640px block (``#git-source-form`` / ``#archive-upload-form`` computed ``flex-direction: column``) and the table scrolls INSIDE its card (the ``#git-sources-table-wrap`` right edge is left of the viewport; no page-level horizontal scroll — ``document.scrollingElement.scrollWidth <= 375``). 3. ``test_hidden_children_stay_hidden_in_flex_wrapper`` — the D2 both-sides pin (admin): ``#git-sources-load-error`` and ``#git-sources-env-note`` (the phase docstring's ``#git-source-env-note`` names the element's CLASS) are not visible (the ``[hidden]`` UA ``display:none`` survives the flex parent — the parent's ``display`` never overrides a child's own — ``offsetParent`` is ``None``); then the load-error's ``hidden`` is removed and the element JOINS the rhythm (visible, ≥18px below the element above it). The D3 regression (anonymous): ``#git-sources-gate`` visible, ``#git-sources-content`` hidden, NO ``/api/git-sources`` call (the ``test_git_sources_admin.py`` anonymous contract — the gate↔content flip is unchanged by the fix). 4. ``test_sources_page_a11y_and_no_cdn_basics`` — the standard light pass (AGENTS.md rules 5/6, the ``test_git_sources_admin.py`` a11y shape): landmarks on the view, the labeled form controls, the 3px ``:focus-visible`` outline on a table-row control (a real keyboard Tab walk), same-origin assets only. """ from __future__ import annotations import json import os import subprocess import sys from collections.abc import Iterator from pathlib import Path import pytest from playwright.sync_api import FloatRect, Page, expect from sqlalchemy import text from app.config import Settings from app.db import SessionLocal from app.models import GitSource from e2e.auth_helpers import login from e2e.conftest import ( ADMIN_PASSWORD, SESSION_SECRET, USE_REAL_LLM, _wait_http, ) REPO = Path(__file__).resolve().parents[2] # Phase 79 (task 04, full inventory): the conftest session app owns # its port in a combined run — this module app binds its own port # instead (a same-port second uvicorn dies on bind and would drive # the wrong server). Env-overridable. APP_PORT = int(os.environ.get("E2E_APP_PORT_GITCARD", "8126")) APP_URL = f"http://127.0.0.1:{APP_PORT}" GIT_SOURCES_URL = "/git-sources.html" #: The four visible cards of the stack, in DOM order inside #: ``#git-sources-content`` (the hidden load-error / env-note #: children sit between the page-head and the forms). CARDS = ( "#git-sources-content .page-head", "#git-source-form", "#archive-upload-form", "#git-sources-table-wrap", ) #: The 1.25rem gap at the 16px root — the exact token (the pre-fix #: gaps were ~0: the 1px borders). GAP_PX = 20 GAP_TOL = 2 #: A revealed hidden child must sit at least this far below the #: element above it (20px gap − the 2px tolerance — the D2 #: both-sides pin). REVEALED_MIN_GAP = 18 # the task's floor (20px gap − 2px tolerance) # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture(scope="module") def app_server(mock_llm: int) -> Iterator[str]: """The real app under test — per-module env: BOOTS PLAIN (no sync, no git, no network; the cards render regardless of row count) with the phase-61 leak guard (``BOR_GIT_SOURCES=""`` — see the module docstring).""" 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" ) # Mock-calibrated gate pins (conftest pattern — the phase-112 # lesson: the code-default floor violates the startup validator # against the mock-calibrated threshold). No chat turn is ever # sent in this suite, but the app boots with the same env 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 # Phase 61/62 leak guards: force the code defaults so an # operator's local (gitignored) ``.env`` cannot leak corpus- # specific strings into the app under test (derived from the # class fields — never drifts from ``app/config.py``). env["BOR_DOCS_REPO"] = "" env["BOR_SUGGESTIONS"] = json.dumps( Settings.model_fields["suggestions"].default ) env["BOR_INPUT_PLACEHOLDER"] = Settings.model_fields["input_placeholder"].default env["BOR_FOOTER_TEXT"] = Settings.model_fields["footer_text"].default # The leak guard this suite exists for (module docstring): an # EMPTY effective CSV — with the per-test truncated (empty) # ``git_sources`` table, a leaked CSV would flip the page to the # env fallback (env rows + the visible env note) and break the # hidden-children pin and the four-card gap contract. 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 def _truncate_git_sources() -> None: with SessionLocal() as db: db.execute(text("TRUNCATE git_sources")) db.commit() @pytest.fixture(autouse=True) def _clean_git_sources(db_ready: None) -> Iterator[None]: """Fresh ``git_sources`` table per test (the ``test_git_sources_admin.py`` pattern) — each test's table state is its own (this suite never syncs, so nothing re-populates it mid-test). The table is emptied BOTH before and after every test: suites run in isolation but share one Postgres, and a leftover row would flip the next suite's app from the ``BOR_GIT_SOURCES`` env fallback to the DB list (the sync story's ``effective_git_sources`` resolver reads the DB first).""" _truncate_git_sources() yield _truncate_git_sources() # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _seed_row(url: str) -> None: """Store one row directly (the ``test_git_sources_admin.py`` ``_seed_rows`` shape) — a stored row makes ``#git-sources-table-wrap`` render (git-sources.js: ``tableWrap.hidden = !hasRows`` — with no rows the empty state replaces the table card) and renders the per-row action controls the a11y walk targets (env-fallback rows render only the "from .env" tag).""" with SessionLocal() as db: db.add(GitSource(url=url)) db.commit() def _admin_git_sources_page( page: Page, app_url: str, expect_sign_out: bool = True ) -> None: """Real form login landing on the git sources page (admin settled: the manager revealed by the page module — git-sources.js reveals it only once the cached whoami says admin, so the reveal IS the settle gate; the shared header's Sign out is visible on desktop, but at ≤640px the nav folds into the hamburger, so the mobile test passes ``expect_sign_out=False``).""" login(page, app_url, next=GIT_SOURCES_URL) expect(page).to_have_url(app_url + GIT_SOURCES_URL, timeout=30_000) expect(page.locator("#git-sources-content")).to_be_visible(timeout=15_000) expect(page.locator("#git-sources-gate")).to_be_hidden() if expect_sign_out: expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000) def _card_boxes(page: Page) -> list[FloatRect]: boxes: list[FloatRect] = [] for sel in CARDS: box = page.locator(sel).bounding_box() assert box is not None, f"{sel} must render" boxes.append(box) return boxes def _assert_rhythm(boxes: list[FloatRect], width: int) -> None: """The pre-fix regression pin: the four cards are pairwise disjoint (no two card edges touch — pre-fix the gaps were ~0, the 1px borders) and each successive gap is 20 ± 2px (the 1.25rem token).""" for i in range(len(boxes)): for j in range(i + 1, len(boxes)): a, b = boxes[i], boxes[j] disjoint = ( a["x"] + a["width"] <= b["x"] + 0.5 or b["x"] + b["width"] <= a["x"] + 0.5 or a["y"] + a["height"] <= b["y"] + 0.5 or b["y"] + b["height"] <= a["y"] + 0.5 ) assert disjoint, ( f"the {CARDS[i]} and {CARDS[j]} cards touch at {width}px " f"(a is {a}, b is {b})" ) for i in range(len(boxes) - 1): gap = boxes[i + 1]["y"] - (boxes[i]["y"] + boxes[i]["height"]) assert GAP_PX - GAP_TOL <= gap <= GAP_PX + GAP_TOL, ( f"the gap between {CARDS[i]} and {CARDS[i + 1]} is {gap:.1f}px " f"at {width}px (expected {GAP_PX} ± {GAP_TOL} — pre-fix the " f"gaps were ~0: the 1px borders)" ) # --------------------------------------------------------------------------- # 1. The "smooshed" pin at 1440×900 (admin) # --------------------------------------------------------------------------- def test_cards_stack_with_uniform_rhythm_desktop( page: Page, app_url: str, db_ready: None ) -> None: page.set_default_timeout(30_000) page.set_viewport_size({"width": 1440, "height": 900}) _seed_row("https://github.com/reese/card-rhythm.git") _admin_git_sources_page(page, app_url) # The wrapper IS the flex-column rhythm (task 01's rule, # computed — the phase-171 idiom): display flex, column, and the # EXACT 1.25rem token at the 16px root (20px — a regression to # any other value fails). style = page.evaluate( """() => { const s = getComputedStyle( document.getElementById("git-sources-content") ); return { display: s.display, direction: s.flexDirection, rowGap: s.rowGap, columnGap: s.columnGap, }; }""" ) assert style["display"] == "flex", ( f"#git-sources-content must be display:flex (got {style['display']!r})" ) assert style["direction"] == "column", ( f"#git-sources-content must be flex-direction:column " f"(got {style['direction']!r})" ) assert style["rowGap"] == "20px" and style["columnGap"] == "20px", ( f"#git-sources-content must keep the 1.25rem (20px) gap — the " f"shell's own token (got {style['rowGap']!r}/" f"{style['columnGap']!r})" ) # The four always-visible cards (the seeded row keeps the table # card rendered; the revealed load-error / env note join the # same rhythm — pinned in test 3): pairwise disjoint, each # successive gap 20 ± 2px. _assert_rhythm(_card_boxes(page), 1440) # --------------------------------------------------------------------------- # 2. The pin at 375×720 (admin): the rhythm survives the mobile # layout (the ≤640px block composes with the fix unchanged) # --------------------------------------------------------------------------- def test_cards_stack_with_uniform_rhythm_mobile( page: Page, app_url: str, db_ready: None ) -> None: page.set_default_timeout(30_000) page.set_viewport_size({"width": 375, "height": 720}) _seed_row("https://github.com/reese/card-rhythm.git") _admin_git_sources_page(page, app_url, expect_sign_out=False) # The same four cards are pairwise disjoint with ≈20px gaps at # 375px. _assert_rhythm(_card_boxes(page), 375) # The two form cards column-stack per the ≤640px block (the # L6400–6403 media query — the fix composes with it unchanged). for sel in ("#git-source-form", "#archive-upload-form"): direction = page.evaluate( f"() => getComputedStyle(document.querySelector('{sel}'))" ".flexDirection" ) assert direction == "column", ( f"{sel} must column-stack at 375px (got {direction!r})" ) # The table scrolls INSIDE its card: the card's right edge is # left of the viewport… card = page.locator("#git-sources-table-wrap").bounding_box() assert card is not None, "the table card must render at 375px" assert card["x"] + card["width"] <= 375 + 1, ( f"the table card's right edge ({card['x'] + card['width']:.1f}) " f"reaches/past the 375px viewport — it must sit inside" ) # …and the page itself never scrolls horizontally (the # table-wrap's own overflow-x scroll is the mobile scroll). scroll_width = page.evaluate( "() => document.scrollingElement.scrollWidth" ) assert scroll_width <= 375, ( f"page-level horizontal scroll at 375px " f"(scrollWidth {scroll_width})" ) # --------------------------------------------------------------------------- # 3. The D2 both-sides pin (hidden children inside the flex wrapper) # + the D3 anonymous gate↔content regression # --------------------------------------------------------------------------- def test_hidden_children_stay_hidden_in_flex_wrapper( page: Page, app_url: str, db_ready: None ) -> None: page.set_default_timeout(30_000) page.set_viewport_size({"width": 1440, "height": 900}) _seed_row("https://github.com/reese/card-rhythm.git") _admin_git_sources_page(page, app_url) # --- the hidden side (D2): the [hidden] UA display:none # survives the flex parent (the parent's display never overrides # a child's own display) — both hidden children are absent from # the layout, git-sources.js's reveal lifecycle untouched… for sel in ("#git-sources-load-error", "#git-sources-env-note"): el = page.locator(sel) assert not el.is_visible(), f"{sel} must stay hidden in the flex wrapper" offset = page.evaluate(f"() => document.querySelector('{sel}').offsetParent") assert offset is None, ( f"{sel} must be display:none inside the flex wrapper " f"(offsetParent {offset!r} — the [hidden] UA rule was " f"overridden)" ) # …and a revealed one JOINS the rhythm (the both-sides pin): # un-hide the load-error and it stacks ≥18px below the element # above it (the page-head — the env note is still hidden) with # the same 1.25rem gap. page.evaluate( "() => document.getElementById('git-sources-load-error')" ".removeAttribute('hidden')" ) err = page.locator("#git-sources-load-error") assert err.is_visible(), "the un-hidden load error must become visible" offset = page.evaluate( "() => document.getElementById('git-sources-load-error').offsetParent" ) assert offset is not None, "the un-hidden load error must be laid out" err_box = err.bounding_box() head_box = page.locator("#git-sources-content .page-head").bounding_box() assert err_box is not None and head_box is not None gap = err_box["y"] - (head_box["y"] + head_box["height"]) assert gap >= REVEALED_MIN_GAP, ( f"the revealed load error must join the rhythm: it sits " f"{gap:.1f}px below the element above it (floor " f"{REVEALED_MIN_GAP}px)" ) # --- the D3 regression (anonymous): the gate↔content flip is # unchanged by the fix — reset to anonymous (the session cookie # cleared; the shared header's whoami is re-fetched on the # reload — the module-level promise cache is per page load)… page.context.clear_cookies() api_calls: list[str] = [] page.on( "request", lambda r: api_calls.append(r.url) if "/api/git-sources" in r.url else None, ) page.goto(app_url + GIT_SOURCES_URL) # The header's whoami toggle has landed (one of Sign in / Sign # out visible)… expect(page.locator("#sign-in-link")).to_be_visible(timeout=15_000) # …the sign-in gate is what anonymous sees… expect(page.locator("#git-sources-gate")).to_be_visible() # …and the manager (the flex wrapper) stays hidden… expect(page.locator("#git-sources-content")).to_be_hidden() # …and the gate never called the admin API (the # test_git_sources_admin.py anonymous contract; a short settle # wait so the claim is made on a settled page). page.wait_for_timeout(500) assert api_calls == [], ( f"anonymous page called the git sources API: {api_calls}" ) # --------------------------------------------------------------------------- # 4. UI Structure Check (AGENTS.md rule 5) + no CDN (rule 6) # --------------------------------------------------------------------------- def test_sources_page_a11y_and_no_cdn_basics( page: Page, app_url: str, db_ready: None ) -> None: page.set_default_timeout(30_000) _seed_row("https://github.com/reese/card-rhythm.git") _admin_git_sources_page(page, app_url) expect(page.locator("#git-sources-tbody tr")).to_have_count(1) # Standard app frame: landmarks + skip link (PLAN §7.2). expect(page.locator("header.app-header")).to_have_count(1) expect(page.locator('nav[aria-label="Primary"]')).to_have_count(1) expect(page.locator("main#main")).to_have_count(1) expect(page.locator("footer.app-footer")).to_have_count(1) expect(page.locator(".skip-link")).to_have_count(1) # The form controls are labeled (visible