Files
brain-of-reese/tests/e2e/test_git_sources_admin.py
T
ducoterra dbf2af26c6 refactor(agents): migrate .agent/ planning tree to .agents/
Standardize on the .agents/ directory (shared with project skills):
phases/, user_stories/, reports/, screenshots/, validate.sh, and
phase-sessions/ + pipeline.log all move to .agents/ (git mv preserves
history; runtime artifacts move alongside).

Updates every reference in AGENTS.md, README.md, .gitignore, app
docstrings, and test story headers. Historical KB content in data/
and the runtime pipeline.log transcript are left untouched.
2026-09-05 10:57:07 -04:00

576 lines
24 KiB
Python

"""Phase 35 story E2E (Playwright): the admin page to add / remove git
sources (``/git-sources.html``).
Story: ``.agents/user_stories/git-sources-admin.md``
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_git_sources_admin.py -v --no-cov
The story gate for the admin-managed git source list. **No git, no
network** — this suite is UI + API only (the clone/import pipeline is
mocked at the integration level, phase 35 task 03; a real sync is
deliberately never triggered here). The stored list (``git_sources``
table, migration 0006) is exercised through the real page and the real
admin-only CRUD API (phase 35 task 02).
Per-module app env (the conftest pattern, module-scoped — as in
``test_sync_button.py``): this story's app boots with a fixed
``BOR_GIT_SOURCES`` CSV (two deterministic URLs that are NEVER cloned —
nothing in this suite triggers a sync) so the empty-table env fallback
(the phase's locked decision: ``BOR_GIT_SOURCES`` only applies while the
``git_sources`` table is empty) can be asserted against real env rows.
The session app (no git sources) is never started in this isolated run,
so no port clash.
Contract under test:
* anonymous: the sign-in gate (the exact ``#sources-gate`` pattern), the
manager hidden (list + add form inert), NO ``/api/git-sources`` call,
403 on all three routes (asserted with the page context's request
client, the ``test_admin_auth.py``/``test_sync_button.py`` pattern),
and ``#nav-git-sources`` hidden on all five pages;
* admin: ``#nav-git-sources`` visible on all five pages (revealed by the
shared header on the cached whoami), clicking it from the chat lands
on ``/git-sources.html`` with the link ``is-active``; the stored rows
render as a full-width table (mono URL, added date, per-row Remove)
with the env note hidden while the DB has rows; add (201 → row, input
cleared, button re-enabled), duplicate (inline role=alert, no new
row), invalid shape (inline 422, no new row), remove (the
confirmation modal: accept → gone; cancel → stays — the native
confirm() retired, phase 69); with the table truncated the env rows
render with
"from .env" tags and ``#git-sources-env-note`` visible;
* the page a11y / no-CDN basics (UI Structure Check, AGENTS.md rule 5):
landmarks, labeled form control, full-width table, ≥44px targets,
3px focus-visible outline, the aria-live list announcer, same-origin
assets only (rule 6).
Test → story mapping (Playwright Mapping Rule):
1. ``test_anonymous_gate_no_api_calls_and_403s``
2. ``test_admin_nav_link_on_all_five_pages_and_click_navigates``
3. ``test_admin_sees_seeded_rows_with_dates_and_no_env_note``
4. ``test_admin_add_then_remove_lifecycle``
5. ``test_admin_env_fallback_rows_and_note``
6. ``test_admin_page_a11y_and_no_cdn``
"""
from __future__ import annotations
import os
import re
import subprocess
import sys
import uuid
from collections.abc import Iterator
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any
import pytest
from playwright.sync_api import Page, expect
from sqlalchemy import text
from app.db import SessionLocal
from app.models import GitSource
from e2e.auth_helpers import login
from e2e.conftest import (
ADMIN_PASSWORD,
APP_PORT,
SESSION_SECRET,
USE_REAL_LLM,
_wait_http,
)
REPO = Path(__file__).resolve().parents[2]
APP_URL = f"http://127.0.0.1:{APP_PORT}"
#: The five pages that ship the header (phase 34 contract) — the pages
#: task 05 gave the admin-only "Sources" nav link (then "Git sources").
CHAT_URL = "/"
SOURCES_URL = "/sources.html"
VIEWER_URL = "/document.html?source=docs&path=homelab%2Fkubernetes.md"
TUNING_URL = "/tuning.html"
GIT_SOURCES_URL = "/git-sources.html"
FIVE_PAGES = (
("chat", CHAT_URL),
("sources", SOURCES_URL),
("viewer", VIEWER_URL),
("tuning", TUNING_URL),
("git-sources", GIT_SOURCES_URL),
)
#: The module app's ``BOR_GIT_SOURCES`` — two deterministic URLs that
#: are NEVER cloned (no sync is triggered in this suite; the fallback
#: list is what is under test).
ENV_SOURCE_A = "https://github.com/reese/env-alpha.git"
ENV_SOURCE_B = "https://github.com/reese/env-beta.git"
ENV_SOURCES_CSV = ",".join((ENV_SOURCE_A, ENV_SOURCE_B))
#: ``is-active`` as a word-boundary regex — to_have_class() matches the
#: WHOLE class string, so the current-page marker is asserted the same
#: way test_tuning_nav_link.py does it.
IS_ACTIVE = re.compile(r"\bis-active\b")
#: Deterministic URLs for the UI-driven assertions.
SEED_URL = "https://gitlab.example.com/reese/seeded.git"
NEW_REPO_URL = "https://example.com/reese/new-repo.git"
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def app_server(mock_llm: int) -> Iterator[str]:
"""The real app under test — per-module env: a fixed two-URL
``BOR_GIT_SOURCES`` CSV so the empty-table env fallback (the phase's
locked decision) is live. No ``BOR_SOURCES_DIR``, no git: nothing in
this suite clones or syncs."""
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 threshold (conftest pattern) — 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.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 35: the empty-table fallback list (never cloned here).
env["BOR_GIT_SOURCES"] = ENV_SOURCES_CSV
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 — this suite owns the table
(the E2E isolation pattern); the KB tables are irrelevant here and
are left untouched (the viewer page may show its not-found state —
no document assertion is made about it). The table is emptied BOTH
before and after every test: suites run in isolation but share one
Postgres, and a leftover row would flip another 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_rows(urls: list[str]) -> None:
"""Store rows directly (deterministic, ordered added_at) — the task
allows seeding via the API *or* SessionLocal; direct inserts keep
the list-rendering test independent of the add/remove lifecycle
test."""
base = datetime(2026, 8, 26, 9, 0, 0, tzinfo=UTC)
with SessionLocal() as db:
for i, url in enumerate(urls):
db.add(GitSource(url=url, added_at=base + timedelta(minutes=i)))
db.commit()
def _admin_git_sources_page(page: Page, app_url: str) -> None:
"""Real form login landing on the git sources page (admin settled:
Sign out visible, the manager revealed by the page module)."""
login(page, app_url, next=GIT_SOURCES_URL)
expect(page).to_have_url(app_url + GIT_SOURCES_URL, timeout=30_000)
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000)
expect(page.locator("#git-sources-gate")).to_be_hidden()
expect(page.locator("#git-sources-content")).to_be_visible()
def _row(page: Page, url: str) -> Any:
"""The table row whose mono URL cell shows ``url``."""
return page.locator("#git-sources-tbody tr", has_text=url)
def _assert_settled(page: Page, admin: bool) -> None:
"""The shared header's whoami toggle has landed (one of Sign in /
Sign out visible) — the same settled-state gate as
test_nav_consistency.py."""
if admin:
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000)
expect(page.locator("#sign-in-link")).to_be_hidden()
else:
expect(page.locator("#sign-in-link")).to_be_visible(timeout=15_000)
expect(page.locator("#sign-out-btn")).to_be_hidden()
# ---------------------------------------------------------------------------
# 1. Anonymous: gate, inert manager, no API calls, 403s, hidden link
# ---------------------------------------------------------------------------
def test_anonymous_gate_no_api_calls_and_403s(
page: Page, app_url: str, db_ready: None
) -> None:
page.set_default_timeout(30_000)
# Track every /api/git-sources request the page itself makes — the
# gate must be reached WITHOUT touching the admin API (the
# test_admin_auth.py pattern for the Sources page's /api/docs).
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)
_assert_settled(page, admin=False)
# The sign-in gate (the #sources-gate pattern, phase 16)…
gate = page.locator("#git-sources-gate")
expect(gate).to_be_visible()
expect(gate).to_contain_text("Sign in to manage the git sources")
link = gate.locator("a[href='/login.html?next=/git-sources.html']")
expect(link).to_have_count(1)
box = link.bounding_box()
assert box is not None and box["height"] >= 44, f"gate link too small: {box}"
# …and the manager is absent/inert: list, add form, env note — all
# inside the hidden #git-sources-content.
expect(page.locator("#git-sources-content")).to_be_hidden()
expect(page.locator("#git-sources-table")).to_be_hidden()
expect(page.locator("#git-source-form")).to_be_hidden()
expect(page.locator("#git-sources-env-note")).to_be_hidden()
# The admin-only nav link is hidden on this page…
expect(page.locator("#nav-git-sources")).to_be_hidden()
# …and on the other four pages (all five ship it hidden by default).
for _name, path in FIVE_PAGES:
if path == GIT_SOURCES_URL:
continue
page.goto(app_url + path)
_assert_settled(page, admin=False)
expect(page.locator("#nav-git-sources")).to_be_hidden()
# The gate never called the admin API…
assert api_calls == [], f"anonymous page called the git sources API: {api_calls}"
# …and the API 403s anonymous callers on all three routes (the
# test_sync_button.py pattern — the page context has no cookie).
assert page.request.get(f"{app_url}/api/git-sources").status == 403
assert (
page.request.post(
f"{app_url}/api/git-sources", data={"url": NEW_REPO_URL}
).status
== 403
)
assert page.request.delete(f"{app_url}/api/git-sources/{uuid.uuid4()}").status == 403
# ---------------------------------------------------------------------------
# 2. Admin: the nav link on all five pages; the click lands + is-active
# ---------------------------------------------------------------------------
def test_admin_nav_link_on_all_five_pages_and_click_navigates(
page: Page, app_url: str, db_ready: None
) -> None:
page.set_default_timeout(30_000)
login(page, app_url, next=CHAT_URL)
expect(page).to_have_url(app_url + CHAT_URL, timeout=30_000)
# The link is revealed (admin) on every one of the five pages,
# pointing at the git sources page, labeled "Sources" — and it
# is NOT the current page on the four non-git-sources pages.
for _name, path in FIVE_PAGES:
if path != CHAT_URL:
page.goto(app_url + path)
_assert_settled(page, admin=True)
link = page.locator("#nav-git-sources")
expect(link).to_be_visible(timeout=15_000)
expect(link).to_have_attribute("href", GIT_SOURCES_URL)
expect(link).to_have_text("Sources")
if path != GIT_SOURCES_URL:
expect(link).not_to_have_class(IS_ACTIVE)
# From the chat: a real click navigates…
page.goto(app_url + CHAT_URL)
_assert_settled(page, admin=True)
page.click("#nav-git-sources")
expect(page).to_have_url(app_url + GIT_SOURCES_URL, timeout=30_000)
# …where the page's own link is the current-page marker.
link = page.locator("#nav-git-sources")
expect(link).to_be_visible(timeout=15_000)
expect(link).to_have_class(IS_ACTIVE)
expect(link).to_have_attribute("aria-current", "page")
# ---------------------------------------------------------------------------
# 3. Admin: two seeded rows render (mono URL + added date); env note
# hidden while the DB has rows
# ---------------------------------------------------------------------------
def test_admin_sees_seeded_rows_with_dates_and_no_env_note(
page: Page, app_url: str, db_ready: None
) -> None:
page.set_default_timeout(30_000)
_seed_rows([SEED_URL, ENV_SOURCE_A]) # ordered by added_at
_admin_git_sources_page(page, app_url)
# Both rows render, oldest first…
expect(page.locator("#git-sources-tbody tr")).to_have_count(2)
first = _row(page, SEED_URL)
second = _row(page, ENV_SOURCE_A)
expect(first.locator("td.git-source-url-cell code")).to_have_text(SEED_URL)
expect(second.locator("td.git-source-url-cell code")).to_have_text(ENV_SOURCE_A)
expect(page.locator("#git-sources-tbody tr").first).to_contain_text(SEED_URL)
expect(page.locator("#git-sources-tbody tr").last).to_contain_text(ENV_SOURCE_A)
# …each with the mono URL cell, a rendered added date (not the "—"
# env-fallback placeholder), and a labeled per-row Remove button.
for row, url in ((first, SEED_URL), (second, ENV_SOURCE_A)):
added = row.locator("td").nth(1)
expect(added).not_to_be_empty()
expect(added).not_to_have_text("—")
remove = row.locator(".git-source-remove")
expect(remove).to_have_count(1)
expect(remove).to_have_attribute("aria-label", f"Remove git source: {url}")
# DB rows exist → from_env is false: the env note is hidden and the
# empty state too.
expect(page.locator("#git-sources-env-note")).to_be_hidden()
expect(page.locator("#git-sources-empty")).to_be_hidden()
# ---------------------------------------------------------------------------
# 4. Admin: add → duplicate → invalid → remove (accept + cancel) — the
# full never-stale lifecycle
# ---------------------------------------------------------------------------
def test_admin_add_then_remove_lifecycle(
page: Page, app_url: str, db_ready: None
) -> None:
page.set_default_timeout(30_000)
_seed_rows([SEED_URL])
_admin_git_sources_page(page, app_url)
expect(page.locator("#git-sources-tbody tr")).to_have_count(1)
error = page.locator("#git-source-error")
add_btn = page.locator("#git-source-add")
# --- add: 201 → the row appears, the input clears, the button
# re-enables (never stale) ---------------------------------------
page.fill("#git-source-url", NEW_REPO_URL)
add_btn.click()
expect(page.locator("#git-sources-tbody tr")).to_have_count(2, timeout=30_000)
expect(_row(page, NEW_REPO_URL).locator("td.git-source-url-cell code")).to_have_text(
NEW_REPO_URL
)
expect(page.locator("#git-source-url")).to_have_value("")
expect(add_btn).to_be_enabled()
expect(add_btn).to_have_text("Add source")
# A stored row exists → the env note stays hidden.
expect(page.locator("#git-sources-env-note")).to_be_hidden()
# --- duplicate: inline role=alert, NO new row, button re-enabled,
# the (failed) input survives for one edit ------------------------
page.fill("#git-source-url", NEW_REPO_URL)
add_btn.click()
expect(error).to_be_visible(timeout=30_000)
assert error.get_attribute("role") == "alert"
expect(error).to_contain_text("already exists")
expect(page.locator("#git-sources-tbody tr")).to_have_count(2)
expect(add_btn).to_be_enabled()
expect(add_btn).to_have_text("Add source")
expect(page.locator("#git-source-url")).to_have_value(NEW_REPO_URL)
# --- invalid shape: inline 422 (the server detail, never the
# echoed input), NO new row, button re-enabled --------------------
page.fill("#git-source-url", "not a valid url")
add_btn.click()
expect(error).to_be_visible(timeout=30_000)
expect(error).to_contain_text("not a valid git URL")
expect(page.locator("#git-sources-tbody tr")).to_have_count(2)
expect(add_btn).to_be_enabled()
expect(add_btn).to_have_text("Add source")
# --- remove: accept the confirmation modal → the row disappears ---
# (phase 69: the native confirm() is retired — a row's Remove opens
# the page-local alertdialog; "Remove source" accepts, Cancel
# cancels without a request.)
deletes: list[str] = []
def track_delete(r: Any) -> None:
if r.method == "DELETE" and "/api/git-sources/" in r.url:
deletes.append(r.url)
page.on("request", track_delete)
try:
_row(page, NEW_REPO_URL).locator(".git-source-remove").click()
dialog = page.locator("#remove-confirm-dialog")
expect(dialog).to_be_visible(timeout=30_000)
expect(page.locator("#remove-confirm-source")).to_have_text(NEW_REPO_URL)
page.locator("#remove-confirm-remove").click()
expect(page.locator("#git-sources-tbody tr")).to_have_count(1, timeout=30_000)
expect(_row(page, NEW_REPO_URL)).to_have_count(0)
expect(dialog).to_be_hidden()
# The seed row survived — and exactly one DELETE went out.
expect(_row(page, SEED_URL)).to_have_count(1)
assert len(deletes) == 1, f"expected one DELETE, saw: {deletes}"
# --- remove: cancel the modal → the row stays, NO DELETE ------
_row(page, SEED_URL).locator(".git-source-remove").click()
expect(page.locator("#remove-confirm-dialog")).to_be_visible(timeout=30_000)
page.locator("#remove-confirm-cancel").click()
expect(page.locator("#remove-confirm-dialog")).to_be_hidden()
# Wait for the cancel round-trip to settle (Cancel closes the
# dialog without fetching) so the "no second DELETE" claim is
# made on a settled page.
page.wait_for_timeout(500)
assert len(deletes) == 1, f"canceled removal still deleted: {deletes}"
expect(_row(page, SEED_URL)).to_have_count(1)
expect(page.locator("#git-sources-tbody tr")).to_have_count(1)
finally:
page.remove_listener("request", track_delete)
# ---------------------------------------------------------------------------
# 5. Admin: empty table → the env rows render + the env note is visible
# (BOR_GIT_SOURCES is the empty-table fallback)
# ---------------------------------------------------------------------------
def test_admin_env_fallback_rows_and_note(
page: Page, app_url: str, db_ready: None
) -> None:
page.set_default_timeout(30_000)
# The autouse fixture truncated git_sources — the table is EMPTY, so
# the module app's BOR_GIT_SOURCES CSV is the effective list.
_admin_git_sources_page(page, app_url)
# The two env rows render (CSV order), each tagged "from .env" with
# no Remove (nothing is stored to remove) and the "—" added date.
expect(page.locator("#git-sources-tbody tr")).to_have_count(2)
expect(page.locator("#git-sources-tbody tr").first).to_contain_text(ENV_SOURCE_A)
expect(page.locator("#git-sources-tbody tr").last).to_contain_text(ENV_SOURCE_B)
for row in page.locator("#git-sources-tbody tr").all():
expect(row.locator(".git-source-env-tag")).to_have_count(1)
expect(row.locator(".git-source-env-tag")).to_have_text("from .env")
expect(row.locator(".git-source-remove")).to_have_count(0)
expect(row.locator("td").nth(1)).to_have_text("—")
# The env-fallback note explains the active list's origin…
expect(page.locator("#git-sources-env-note")).to_be_visible()
# …and the API agrees: from_env true, null ids, the env URLs.
r = page.request.get(f"{app_url}/api/git-sources")
assert r.status == 200, r.text
body = r.json()
assert body["from_env"] is True
assert [s["url"] for s in body["sources"]] == [ENV_SOURCE_A, ENV_SOURCE_B]
assert all(s["id"] is None and s["added_at"] is None for s in body["sources"])
# ---------------------------------------------------------------------------
# 6. UI Structure Check (AGENTS.md rule 5) + no CDN (rule 6)
# ---------------------------------------------------------------------------
def test_admin_page_a11y_and_no_cdn(
page: Page, app_url: str, db_ready: None
) -> None:
page.set_default_timeout(30_000)
_seed_rows([SEED_URL])
_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)
# 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}"
)
# The form input is labeled (visible <label for=…>).
url_input = page.get_by_label("Add a git source")
expect(url_input).to_have_count(1)
# Full-width table (PLAN §7.1 — no skinny list): the table fills the
# 72rem container (≥80%, the Sources-page assertion).
table = page.locator("#git-sources-table")
expect(table).to_be_visible()
table_box = table.bounding_box()
shell_box = page.locator(".git-sources-shell").bounding_box()
assert table_box is not None and shell_box is not None
assert table_box["width"] >= 0.80 * shell_box["width"], (
f"table is {table_box['width']:.0f}px in a {shell_box['width']:.0f}px container"
)
# Touch targets ≥44px (add button + a row's Remove).
for el in (page.locator("#git-source-add"), page.locator(".git-source-remove")):
box = el.bounding_box()
assert box is not None and box["height"] >= 44, f"target too small: {box}"
# :focus-visible draws the 3px outline (the theme contract).
page.focus("#git-source-url")
outline = page.evaluate(
"() => getComputedStyle(document.querySelector('#git-source-url')).outlineWidth"
)
assert outline == "3px", f"focus-visible outline missing: {outline!r}"
# The list updates are announced (aria-live) and the error line is a
# role=alert region; the env note + hint are role=note.
announcer = page.locator("#git-sources-announcer")
assert announcer.get_attribute("role") == "status"
assert announcer.get_attribute("aria-live") == "polite"
assert page.locator("#git-source-error").get_attribute("role") == "alert"
assert page.locator("#git-sources-env-note").get_attribute("role") == "note"
assert page.locator("#git-sources-hint").get_attribute("role") == "note"