phase: 174_git_sources_card_rhythm
Build and Push Containers / build-and-push-app (push) Successful in 1m51s
Build and Push Containers / build-and-push-db (push) Successful in 13s

All checks pass. Both docs verified consistent. Final state — no live-infrastructure changes were needed (pure CSS fix; verification ran against the already-running local dev DB).

**Phase 174 final verification pass — report**

- **Verified (no new work needed; both tasks already complete in `complete/`):** the single `#git-sources-content { display:flex; flex-direction:column; gap:1.25rem }` rule in `frontend/assets/styles.css` (with the phase-174 root-cause comment), `.git-sources-shell` untouched, the 4-test unit pin module, and the 4-test Playwright suite.
- **Full suite:** `uv run pytest --cov=app --cov-report=term-missing` → **4116 passed, 99% `app/` coverage** (gate >90% ✓).
- **Lint/types:** `uv run ruff check .` → clean; `uv run pyright` → 0 errors, 0 warnings.
- **Dedicated E2E in isolation:** `uv run pytest tests/e2e/test_git_sources_card_rhythm.py -v --no-cov` → **4 passed** (1440px + 375px bounding-box rhythm pins 20±2px, hidden-children + revealed-joins-rhythm, anonymous gate, a11y/no-CDN).
- **Regression set, each in isolation:** `test_git_sources_admin.py` **6 passed**, `test_archive_upload_sources.py` **5 passed**, `test_git_source_dates.py` **4 passed**.
- **Completion criteria:** cards stack with uniform ≈20px gap at both viewports ✓; hidden children stay hidden / gate+content flip intact ✓; ≤640px forms column-stack + table scrolls inside its card (no page-level h-scroll) ✓; full suite + coverage + ruff + pyright ✓; dedicated + regression E2E suites ✓.
- **Documentation:** README.md reviewed in full (every `.env.example` var documented; all documented routes/pages/endpoints exist; every config default cross-checked against `app/config.py`; payload-limit table, ports 8123/8901, Containerfile/compose/entrypoint claims all match) — **verified consistent, no changes needed**. AGENTS.md verified (all referenced files/sections exist: PLAN §2/§7.1/§7.4/§9, `response_budget.py`, `debugging.py` conditional import, `.agents/` tracked + gitignore rules, `commit.gpgsign=false`, phase-pinned CSS classes) — **verified consistent, no changes needed**.
- **Notable:** commit + phase-dir move are handled by the harness per pipeline rules (no git history/index touched by this pass); no live-infrastructure changes were required.
- **Next pending phase:** `175_history_pagination` (in `todo/`).
This commit is contained in:
2026-10-11 01:49:49 -04:00
parent da441ea799
commit 938d6cf09d
18 changed files with 1333 additions and 0 deletions
+563
View File
@@ -0,0 +1,563 @@
"""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 <label for=…>).
expect(page.get_by_label("Add a git source")).to_have_count(1)
expect(
page.get_by_label(
"Upload a source archive (.tar, .tar.gz, .tgz, .zip)"
)
).to_have_count(1)
# The table-row control reaches the 3px :focus-visible outline
# via a REAL keyboard Tab walk (the phase-171 walk shape: the
# skip link is the document's FIRST tabbable element — the
# script focus starts the cycle; every move after it is a real
# Tab keypress).
page.evaluate("() => document.querySelector('a.skip-link').focus()")
seen: list[str] = []
reached = False
for _ in range(60):
page.keyboard.press("Tab")
active = page.evaluate(
"""() => {
const el = document.activeElement;
if (!el) return { key: "", cls: "" };
return {
key: el.id || "",
cls: typeof el.className === "string" ? el.className : "",
};
}"""
)
seen.append(active["cls"] or active["key"] or "?")
if "git-source-remove" in active["cls"]:
reached = True
break
assert reached, (
f"the walk never reached the row's Remove control "
f"(seen={seen[:25]})"
)
outline = page.evaluate(
"() => getComputedStyle(document.activeElement).outlineWidth"
)
assert outline == "3px", (
f"focus-visible outline missing on the row control: {outline!r}"
)
# No CDN (rule 6): no https:// asset tags; every script/link
# ref is same-origin or a data: URI.
html = page.content()
assert 'src="https://' not in html and 'href="https://' not in html
refs = page.evaluate(
"""() => [...document.querySelectorAll("script[src], link[href]")]
.map((el) => el.src || el.href)"""
)
assert refs, "expected local asset references"
for ref in refs:
assert ref.startswith(app_url) or ref.startswith("data:"), (
f"non-local asset reference: {ref}"
)