feat(sources): admin page to add and remove git sources (TODO.md L4)
This commit is contained in:
@@ -0,0 +1,572 @@
|
||||
"""Phase 35 story E2E (Playwright): the admin page to add / remove git
|
||||
sources (``/git-sources.html``).
|
||||
|
||||
Story: ``.agent/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 (confirm → gone;
|
||||
cancel → stays); 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 Dialog, 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 "Git sources" nav link.
|
||||
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 "Git 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("Git 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 confirm → the row disappears --------------
|
||||
dialog_action: dict[str, bool] = {"accept": True}
|
||||
deletes: list[str] = []
|
||||
|
||||
def handle_dialog(dialog: Dialog) -> None:
|
||||
if dialog_action["accept"]:
|
||||
dialog.accept()
|
||||
else:
|
||||
dialog.dismiss()
|
||||
|
||||
def track_delete(r: Any) -> None:
|
||||
if r.method == "DELETE" and "/api/git-sources/" in r.url:
|
||||
deletes.append(r.url)
|
||||
|
||||
page.on("dialog", handle_dialog)
|
||||
page.on("request", track_delete)
|
||||
try:
|
||||
_row(page, NEW_REPO_URL).locator(".git-source-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)
|
||||
# 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 confirm → the row stays, NO DELETE ----
|
||||
dialog_action["accept"] = False
|
||||
_row(page, SEED_URL).locator(".git-source-remove").click()
|
||||
# Wait for the dialog round-trip to settle (dismiss → the JS
|
||||
# returns 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("dialog", handle_dialog)
|
||||
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"
|
||||
Reference in New Issue
Block a user