576 lines
24 KiB
Python
576 lines
24 KiB
Python
"""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 (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"
|