All green — verification complete. Final report: **Phase 107 — final verification pass (all tasks already in `complete/`)** - **Verified:** `clone_or_pull` full-history fix (no `--depth` clone; `--is-shallow-repository` probe → one-time `git fetch --unshallow` self-heal → `git pull --ff-only`; D12 fail-loud) in `scripts/git_sync.py`; E2E suite `tests/e2e/test_git_source_dates.py` present; all five stale shallow/tip-date narratives corrected (no residual claims repo-wide; `docs_push.py` `--depth` fetches untouched as designed). - **Unit+integration:** `uv run pytest tests/unit/test_git_sync.py tests/integration/test_git_file_dates.py tests/integration/test_import_docs_git.py tests/integration/test_sync_api.py -v --no-cov` → **66 passed** (D10 tip-date test replaced by `test_url_clone_yields_true_per_file_dates` + `test_existing_shallow_checkout_self_heals`; unit pins: clone argv w/o `--depth`, probe→unshallow→pull order, unshallow-failure → `GitSyncError`). - **New E2E in isolation:** `uv run pytest tests/e2e/test_git_source_dates.py -v --no-cov` → **4 passed** (old file 2020-06-15 ≠ tip file 2024-06-15 in `GET /api/docs`, Sources `Created`/`Updated` columns, viewer `Created` badge ISO title; folder `updated_at` subtree maxes). - **Phase-106 regressions in isolation:** `test_document_dates.py` → 6 passed; `test_sync_button.py` → 3 passed; `test_git_sources_admin.py` → 6 passed. - **Full gates:** `uv run pytest` → **2302 passed**; `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (>90%); `uv run ruff check . && uv run pyright` → **clean, 0 errors**. - **Completion criteria:** 1 ✅ non-shallow URL clone + true per-file dates · 2 ✅ self-heal / plain-pull / fail-loud · 3 ✅ both suites green, bug-pin replaced · 4 ✅ E2E green in isolation · 5 ✅ all regressions + full suite + coverage + lint · 6 ✅ narratives corrected · 7 — no commit made (harness override: changes left in working tree; task files already in `complete/`). - **No defects found; no deviations.** Next pending phase: **108_history_wire_check**.
638 lines
28 KiB
Python
638 lines
28 KiB
Python
"""Phase 107 (task 02) E2E (Playwright): TRUE per-file git dates end to
|
|
end for a URL-transport git source.
|
|
|
|
Story: n/a — owner bug report 2026-09-16 (phase 106 follow-up): the
|
|
live ``https://gitea…/homelab.git`` URL source displayed the repo's
|
|
TIP-commit date for every document (``container_bifrost`` "created
|
|
8/16/2026", months off) because phase 106 D10 locked ``--depth 1``
|
|
shallow URL clones. Phase 107 D11 (task 01) removed the shallow clone
|
|
and added the one-time ``git fetch --unshallow`` self-heal — this
|
|
suite proves the FIX END TO END over the ``file://`` transport (the
|
|
transport-true stand-in for the live https source: pre-fix it rendered
|
|
the uniform tip date too).
|
|
|
|
Run in isolation (DB must be up: ``podman compose up -d db``; git on
|
|
PATH — a documented environment prerequisite, phase 28):
|
|
|
|
uv run pytest tests/e2e/test_git_source_dates.py -v --no-cov
|
|
|
|
The whole owner scenario through the REAL pipeline: a real
|
|
``file://`` git fixture with TWO commits of controlled
|
|
``GIT_COMMITTER_DATE``s (``old/old-note.md`` @ 2020-06-15,
|
|
``recent/recent-note.md`` @ the 2024-06-15 tip — MID-YEAR dates so
|
|
the browser's locale/TZ rendering of the YEAR is stable in any
|
|
timezone, the test_document_dates.py L455 lesson), a real in-app
|
|
admin Sync (``clone_or_pull`` task-01 full-history path → real
|
|
importer → real KB overview, mock LLM), and the assertions that the
|
|
OLD file shows its OLD commit date and the NEW file shows the TIP
|
|
date — in ``GET /api/docs`` / ``GET /api/docs/tree`` (deterministic
|
|
ISO) and in the Sources tables + document viewer (locale-tolerant
|
|
year regexes / the badge's full-ISO ``title``).
|
|
|
|
Per-module app env (the E2E conftest pattern, module-scoped — copied
|
|
verbatim in shape from ``test_sync_button.py``): this suite's app
|
|
boots with ``BOR_GIT_SOURCES=file://<fixture repo>`` and its own
|
|
``BOR_SOURCES_DIR`` (fresh dir — the first sync takes the NEW-CLONE
|
|
path, the later per-test syncs the existing-checkout probe→pull
|
|
path); the session app (no git sources) is never started in this
|
|
isolated run, so no port clash.
|
|
|
|
Test → contract mapping (four tests):
|
|
|
|
1. ``test_api_created_dates_are_true_per_file`` — THE REGRESSION PIN:
|
|
after a real sync, ``GET /api/docs`` carries the OLD commit date
|
|
(2020-06-15) for the old file and the TIP date (2024-06-15) for
|
|
the new file — the two DIFFER (the phase-106 bug made both carry
|
|
the tip date). ``GET /api/docs/tree``: the file nodes carry the
|
|
same ISO dates verbatim; the folder ``updated_at`` values are the
|
|
subtree MAXES (D9 — now over TRUE dates): ``old`` = 2020,
|
|
``recent`` = 2024, the source node = the 2024 max.
|
|
2. ``test_sources_tables_render_distinct_created_dates`` — the RAG
|
|
view's FILE table ``Created`` column (phase 106 D8 — between
|
|
``Chunks`` and ``Indexed``) renders 2020 for the old file and
|
|
2024 for the new one (year regexes — ``toLocaleString`` is
|
|
locale/TZ-dependent, the year is stable for mid-year dates; the
|
|
full ISO rides the cell's ``title``, asserted verbatim); the
|
|
FOLDER table ``Updated`` column (between ``Documents`` and
|
|
``Description``) renders 2020 / 2024 per folder, the source row
|
|
the 2024 max. The two file cells' visible text differs.
|
|
3. ``test_viewer_created_badge_is_the_true_git_date`` — opening the
|
|
old document (the phase-26 same-page modal) shows the
|
|
``.doc-created`` badge (phase 106, before the ``Indexed`` badge)
|
|
with the RAW ISO on its ``title`` — ``2020-06-15T12:00:00+00:00``
|
|
(``metaBadge``'s title, deterministic under any locale/TZ — the
|
|
house solution to the L455 rendering trap) and ``Created …`` in
|
|
the visible text; Escape closes the modal cleanly (focus
|
|
restored to the row link).
|
|
4. ``test_page_a11y_and_no_cdn_basics`` — the standard light pass
|
|
(AGENTS.md rules 5/6, the test_git_sources_admin.py a11y test
|
|
shape): landmarks on the Sources view, the ``Created`` /
|
|
``Updated`` ``<th>`` cells present in both tables (this phase
|
|
adds no color — no new contrast surface), the 3px
|
|
``:focus-visible`` outline on a table row link (a real keyboard
|
|
Tab walk), and same-origin assets only.
|
|
|
|
Isolation: per-test TRUNCATE of the KB tables (the
|
|
``test_sync_button.py`` ``_truncate_kb`` shape, extended with
|
|
``sources_meta``) so each test's sync counts are its own; own
|
|
``BOR_SOURCES_DIR``; the suite touches no other suite's fixtures.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
from collections.abc import Iterator
|
|
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 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_GITDATES", "8130"))
|
|
APP_URL = f"http://127.0.0.1:{APP_PORT}"
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Fixture constants (deterministic — see the module docstring)
|
|
# --------------------------------------------------------------------------
|
|
|
|
#: The fixture repo's directory name — the source name the import
|
|
#: records for it (the repo_name basename rule, phase 28).
|
|
REPO_NAME = "git-dates"
|
|
|
|
OLD_PATH = "old/old-note.md"
|
|
TIP_PATH = "recent/recent-note.md"
|
|
|
|
#: The controlled commit dates (MID-YEAR, 12:00Z — the local year of
|
|
#: the instant is the committed year in ANY timezone, from UTC-12 to
|
|
#: UTC+14, so the browser's locale/TZ rendering of the year is stable,
|
|
#: the test_document_dates.py L455 lesson).
|
|
OLD_COMMIT = "2020-06-15T12:00:00Z"
|
|
TIP_COMMIT = "2024-06-15T12:00:00Z"
|
|
|
|
#: The same instants as stored (``%cI`` → ``datetime.fromisoformat`` →
|
|
#: ``normalize_doc_date`` → Postgres → ``isoformat()``): the exact ISO
|
|
#: the API, the cell ``title``s, and the viewer badge ``title`` carry.
|
|
OLD_ISO = "2020-06-15T12:00:00+00:00"
|
|
TIP_ISO = "2024-06-15T12:00:00+00:00"
|
|
|
|
OLD_TEXT = """\
|
|
# Old stable note
|
|
|
|
An old, stable note that has not changed since it was first written.
|
|
"""
|
|
|
|
TIP_TEXT = """\
|
|
# Recent note
|
|
|
|
A recent note added at the tip of the repository.
|
|
"""
|
|
|
|
#: "Synced HH:MM" — the local-time last-result label (sources.js's
|
|
#: fmtSyncTime), any hour/minute.
|
|
SYNCED_LABEL = re.compile(r"Synced \d{1,2}:\d{2}")
|
|
|
|
#: Real git clone + embed against the mock LLM — generous budget
|
|
#: (the sync can legitimately take a while, no client timeout).
|
|
SYNC_TIMEOUT_MS = 60_000
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# The fixture repo (module-scoped — real git, two controlled commits)
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
def _git(cwd: Path, *args: str, env: dict[str, str] | None = None) -> None:
|
|
"""Run git in *cwd*; a non-zero exit fails the fixture loudly.
|
|
|
|
*env* overrides (the ``GIT_AUTHOR_DATE`` / ``GIT_COMMITTER_DATE``
|
|
commit-date controls — the phase's whole point).
|
|
"""
|
|
full_env = dict(os.environ)
|
|
if env:
|
|
full_env.update(env)
|
|
proc = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True,
|
|
env=full_env)
|
|
if proc.returncode != 0:
|
|
raise AssertionError(f"git {' '.join(args)} failed: {proc.stderr.strip()}")
|
|
|
|
|
|
def _git_out(cwd: Path, *args: str) -> str:
|
|
proc = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True)
|
|
if proc.returncode != 0:
|
|
raise AssertionError(f"git {' '.join(args)} failed: {proc.stderr.strip()}")
|
|
return proc.stdout
|
|
|
|
|
|
def _commit(cwd: Path, message: str, when: str) -> None:
|
|
"""A ``git add -A`` + commit with BOTH the author and the committer
|
|
dates pinned to *when* (``file_commit_dates`` reads ``%cI`` — the
|
|
committer date; the author date is pinned too so the fixture has
|
|
one unambiguous date per commit)."""
|
|
_git(cwd, "add", "-A")
|
|
_git(
|
|
cwd,
|
|
"-c", "user.email=e@x", "-c", "user.name=t",
|
|
"-c", "commit.gpgsign=false", # the fixture commits never sign
|
|
"commit", "-qm", message,
|
|
env={"GIT_AUTHOR_DATE": when, "GIT_COMMITTER_DATE": when},
|
|
)
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def git_dates_repo(tmp_path_factory: pytest.TempPathFactory) -> Path:
|
|
"""A real two-commit git repo the sync must clone over ``file://``
|
|
(task 02 step 1): commit one @ 2020-06-15 adds ``old/old-note.md``;
|
|
commit two (the tip) @ 2024-06-15 adds ``recent/recent-note.md``.
|
|
|
|
Built under ``tmp_path_factory`` (``tmp_path`` is function-scoped
|
|
while the module-scoped app fixture needs the repo for the module's
|
|
lifetime) via real ``git`` subprocesses — the
|
|
``test_sync_button.py`` idiom.
|
|
"""
|
|
root = tmp_path_factory.mktemp("bor_git_dates")
|
|
repo = root / REPO_NAME
|
|
(repo / "old").mkdir(parents=True)
|
|
(repo / "old" / "old-note.md").write_text(OLD_TEXT, encoding="utf-8")
|
|
_git(repo, "init", "-q")
|
|
_commit(repo, "old note", OLD_COMMIT)
|
|
(repo / "recent").mkdir()
|
|
(repo / "recent" / "recent-note.md").write_text(TIP_TEXT, encoding="utf-8")
|
|
_commit(repo, "recent note", TIP_COMMIT)
|
|
assert (repo / ".git").is_dir()
|
|
assert _git_out(repo, "rev-list", "--count", "HEAD").strip() == "2"
|
|
return repo
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# The app under test (module-scoped env, the test_sync_button.py shape)
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def app_server(mock_llm: int, git_dates_repo: Path) -> Iterator[str]:
|
|
"""The real app under test — per-module env: the sync's subject is
|
|
a real ``file://`` URL-transport git source with its own checkout
|
|
dir (the conftest session app boots without ``BOR_GIT_SOURCES``
|
|
and is never started in this isolated run)."""
|
|
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
|
|
# 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 107: the subject — one real local repo over the URL
|
|
# transport, checked out under its own fresh dir (first sync =
|
|
# the task-01 NEW-CLONE full-history path; later per-test syncs =
|
|
# the existing-checkout probe → pull path).
|
|
env["BOR_GIT_SOURCES"] = f"file://{git_dates_repo}"
|
|
env["BOR_SOURCES_DIR"] = str(git_dates_repo.parent / "checkouts")
|
|
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_kb() -> None:
|
|
"""Fresh KB per test (the E2E isolation pattern): each test's sync
|
|
counts are its own. ``git_sources`` too: the shared Postgres may
|
|
carry leftover registry rows from other suites, and
|
|
effective_sources prefers the DB list over this app's
|
|
``BOR_GIT_SOURCES`` env fallback. ``sources_meta`` (the KB
|
|
generation counter) resets with the KB — the bump is an upsert, so
|
|
the sync re-creates the row."""
|
|
with SessionLocal() as db:
|
|
db.execute(text(
|
|
"TRUNCATE chunks, documents, query_log, kb_overview, "
|
|
"git_sources, sources_meta"
|
|
))
|
|
db.commit()
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _clean_kb(db_ready: None) -> Iterator[None]:
|
|
_truncate_kb()
|
|
yield
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Helpers
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
def _sync_in_app(page: Page, app_url: str) -> None:
|
|
"""Admin login → the admin Sources page → click "Sync sources" →
|
|
the terminal success label + the fresh-import counts (the
|
|
``test_sync_button.py`` lifecycle wait, generous timeout — a real
|
|
clone/pull + the real import + the KB overview, mock LLM)."""
|
|
login(page, app_url) # lands on /sources.html (the button's home)
|
|
btn = page.locator("#sync-btn")
|
|
expect(btn).to_be_visible()
|
|
# Fresh app state: "Sync sources" — or, when this app process has
|
|
# ALREADY run a previous test's sync (the per-module server-side
|
|
# status survives the per-test page), the boot re-attach adopted
|
|
# that terminal state ("Synced HH:MM") — either way the button is
|
|
# clickable (never stale).
|
|
expect(page.locator("#sync-label")).to_have_text(
|
|
re.compile(r"^Sync sources$|^Synced \d{1,2}:\d{2}$")
|
|
)
|
|
|
|
btn.click()
|
|
expect(btn).to_be_disabled()
|
|
expect(page.locator("#sync-label")).to_have_text("Syncing…")
|
|
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=SYNC_TIMEOUT_MS)
|
|
expect(btn).to_be_enabled() # never stale — re-enabled at the terminal state
|
|
# Both fixture docs are fresh after the per-test truncate.
|
|
expect(page.locator("#sync-result")).to_have_text("2 added")
|
|
|
|
|
|
def _source_node(tree_json: dict[str, Any]) -> dict[str, Any]:
|
|
for s in tree_json["sources"]:
|
|
if s["name"] == REPO_NAME:
|
|
return s
|
|
raise AssertionError(f"source {REPO_NAME!r} not in the tree")
|
|
|
|
|
|
def _folder_node(node: dict[str, Any], path: str) -> dict[str, Any]:
|
|
for child in node.get("children", []):
|
|
if child.get("kind") == "folder" and child["path"] == path:
|
|
return child
|
|
raise AssertionError(f"folder {path!r} not under the node")
|
|
|
|
|
|
def _find_file(node: dict[str, Any], path: str) -> dict[str, Any]:
|
|
"""The file node with *path* anywhere under *node* (recursing into
|
|
the folder children)."""
|
|
for child in node.get("children", []):
|
|
if child.get("kind") == "file" and child["path"] == path:
|
|
return child
|
|
if child.get("kind") == "folder":
|
|
try:
|
|
return _find_file(child, path)
|
|
except AssertionError:
|
|
continue
|
|
raise AssertionError(f"file {path!r} not under the node")
|
|
|
|
|
|
def _drill_to_folder(page: Page, folder: str) -> None:
|
|
"""Top level → the source → *folder* (the two real clicks)."""
|
|
page.locator("#folders-tbody .folder-link").first.wait_for(state="visible")
|
|
page.click(f'#folders-tbody a.folder-link:text-is("{REPO_NAME}")')
|
|
page.click(f'#folders-tbody a.folder-link:text-is("{folder}")')
|
|
|
|
|
|
def _back_to_source_level(page: Page) -> None:
|
|
"""From a folder level, the breadcrumb's source segment (the
|
|
"Knowledge base" segment goes all the way to the top)."""
|
|
page.click(f'#kb-crumb a.kb-crumb-link:text-is("{REPO_NAME}")')
|
|
expect(page.locator("#folders-tbody .folder-link")).to_have_count(2)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 1. THE REGRESSION PIN: the API dates are the TRUE per-file commit
|
|
# dates — 2020 for the old file, the tip date for the new file
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_api_created_dates_are_true_per_file(
|
|
page: Page, app_url: str, db_ready: None
|
|
) -> None:
|
|
"""After a real in-app sync of the ``file://`` fixture: the old
|
|
file's ``created_at`` is its OLD commit date (2020-06-15) and the
|
|
new file's is the TIP date (2024-06-15) — the two DIFFER (the
|
|
phase-106 bug made every URL-source file carry the tip date, i.e.
|
|
both ``2024-06-15``). The tree carries the same dates verbatim and
|
|
the folder/source ``updated_at`` values are the subtree MAXES (D9).
|
|
"""
|
|
_sync_in_app(page, app_url)
|
|
|
|
# GET /api/docs (the page context's request client — the login's
|
|
# cookie jar rides along).
|
|
r = page.request.get(f"{app_url}/api/docs")
|
|
assert r.status == 200, r.text
|
|
docs = {d["path"]: d for d in r.json()["documents"]}
|
|
old_created = docs[OLD_PATH]["created_at"]
|
|
tip_created = docs[TIP_PATH]["created_at"]
|
|
assert old_created[:10] == "2020-06-15"
|
|
assert tip_created[:10] == "2024-06-15"
|
|
assert old_created != tip_created, (
|
|
"the two dates must DIFFER — the phase-106 bug made a URL "
|
|
f"source carry the uniform tip date for both ({old_created!r}, {tip_created!r})"
|
|
)
|
|
# The TRUE commit dates, verbatim (the %cI instants through D3 —
|
|
# past dates pass normalize_doc_date unchanged).
|
|
assert old_created == OLD_ISO
|
|
assert tip_created == TIP_ISO
|
|
|
|
# GET /api/docs/tree: the file nodes carry the same ISO dates
|
|
# verbatim; the folder updated_at values are the subtree MAXES
|
|
# (old folder = the 2020 date alone, recent folder = the 2024
|
|
# date alone, source = the whole subtree's 2024 max — D9, now over
|
|
# TRUE dates).
|
|
t = page.request.get(f"{app_url}/api/docs/tree")
|
|
assert t.status == 200, t.text
|
|
source = _source_node(t.json())
|
|
assert source["documents"] == 2
|
|
assert _find_file(source, OLD_PATH)["created_at"] == OLD_ISO
|
|
assert _find_file(source, TIP_PATH)["created_at"] == TIP_ISO
|
|
old_folder = _folder_node(source, "old")
|
|
recent_folder = _folder_node(source, "recent")
|
|
assert old_folder["documents"] == 1
|
|
assert old_folder["updated_at"] is not None
|
|
assert old_folder["updated_at"][:10] == "2020-06-15"
|
|
assert recent_folder["documents"] == 1
|
|
assert recent_folder["updated_at"] is not None
|
|
assert recent_folder["updated_at"][:10] == "2024-06-15"
|
|
assert source["updated_at"] is not None
|
|
assert source["updated_at"][:10] == "2024-06-15" # max(2020, 2024)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 2. The Sources tables render the DISTINCT created dates
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_sources_tables_render_distinct_created_dates(
|
|
page: Page, app_url: str, db_ready: None
|
|
) -> None:
|
|
"""The RAG view's FILE table ``Created`` column (phase 106 D8 —
|
|
between ``Chunks`` and ``Indexed``) renders 2020 for the old file
|
|
and 2024 for the new one, the FOLDER table ``Updated`` column
|
|
(between ``Documents`` and ``Description``) renders 2020 / 2024
|
|
per folder, and the two file cells' visible text differs. Year
|
|
regexes — ``toLocaleString`` is locale/TZ-dependent, the year is
|
|
stable for the mid-year fixture dates; the full ISO rides the
|
|
cells' ``title`` (asserted verbatim — the locale-stable idiom).
|
|
textContent reads only — never set innerHTML."""
|
|
page.set_default_timeout(30_000)
|
|
_sync_in_app(page, app_url)
|
|
|
|
# Top level: one source row; its Updated cell carries the 2024
|
|
# subtree max.
|
|
page.locator("#folders-tbody .folder-link").first.wait_for(state="visible")
|
|
source_row = page.locator(
|
|
f'#folders-tbody tr:has(a.folder-link:text-is("{REPO_NAME}"))'
|
|
)
|
|
expect(source_row).to_have_count(1)
|
|
expect(source_row.locator("td:nth-child(3)")).to_have_text(re.compile("2024"))
|
|
|
|
# Drill into the source: the two folders, each Updated = its own
|
|
# year (D9 through the UI).
|
|
page.click(f'#folders-tbody a.folder-link:text-is("{REPO_NAME}")')
|
|
expect(page.locator("#folders-tbody .folder-link")).to_have_count(2)
|
|
old_row = page.locator('#folders-tbody tr:has(a.folder-link:text-is("old"))')
|
|
recent_row = page.locator('#folders-tbody tr:has(a.folder-link:text-is("recent"))')
|
|
expect(old_row).to_have_count(1)
|
|
expect(recent_row).to_have_count(1)
|
|
expect(old_row.locator("td:nth-child(3)")).to_have_text(re.compile("2020"))
|
|
expect(recent_row.locator("td:nth-child(3)")).to_have_text(re.compile("2024"))
|
|
|
|
# The FILE table: the old file's Created cell (the column between
|
|
# Chunks and Indexed) carries the 2020 year in text + the FULL ISO
|
|
# on the cell's title.
|
|
page.click('#folders-tbody a.folder-link:text-is("old")')
|
|
old_file_row = page.locator("#docs-tbody tr", has_text=OLD_PATH)
|
|
expect(old_file_row).to_have_count(1)
|
|
old_created_cell = old_file_row.locator("td:nth-child(5)")
|
|
expect(old_created_cell).to_have_text(re.compile("2020"), timeout=15_000)
|
|
assert old_created_cell.get_attribute("title", timeout=15_000) == OLD_ISO
|
|
old_created_text = (old_created_cell.text_content() or "").strip()
|
|
|
|
# Back to the source level, then the new file's Created cell: the
|
|
# 2024 year + its full ISO.
|
|
_back_to_source_level(page)
|
|
page.click('#folders-tbody a.folder-link:text-is("recent")')
|
|
recent_file_row = page.locator("#docs-tbody tr", has_text=TIP_PATH)
|
|
expect(recent_file_row).to_have_count(1)
|
|
recent_created_cell = recent_file_row.locator("td:nth-child(5)")
|
|
expect(recent_created_cell).to_have_text(re.compile("2024"), timeout=15_000)
|
|
assert recent_created_cell.get_attribute("title", timeout=15_000) == TIP_ISO
|
|
recent_created_text = (recent_created_cell.text_content() or "").strip()
|
|
|
|
# The two cells' visible text differs (the bug made them identical
|
|
# — both the tip date).
|
|
assert old_created_text and recent_created_text
|
|
assert old_created_text != recent_created_text, (
|
|
f"the two Created cells must differ, both show {old_created_text!r}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 3. The viewer's Created badge carries the TRUE git date (raw ISO)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_viewer_created_badge_is_the_true_git_date(
|
|
page: Page, app_url: str, db_ready: None
|
|
) -> None:
|
|
"""Opening the OLD document (the phase-26 same-page modal) shows
|
|
the ``.doc-created`` badge (phase 106, before the ``Indexed``
|
|
badge) with the RAW ISO on its ``title`` —
|
|
``2020-06-15T12:00:00+00:00`` (``metaBadge``'s title,
|
|
deterministic under any locale/TZ — the house solution to the L455
|
|
rendering trap) and ``Created …`` in the visible text; Escape
|
|
closes the modal cleanly (focus restored to the row link)."""
|
|
page.set_default_timeout(30_000)
|
|
_sync_in_app(page, app_url)
|
|
|
|
_drill_to_folder(page, "old")
|
|
row = page.locator("#docs-tbody tr", has_text=OLD_PATH)
|
|
expect(row).to_have_count(1)
|
|
before_tabs = len(page.context.pages)
|
|
row.locator("td:nth-child(2) a.doc-link").click()
|
|
assert len(page.context.pages) == before_tabs, "row link must not open a new tab"
|
|
|
|
expect(page.locator(".doc-modal")).to_be_visible()
|
|
expect(page.locator("#doc-modal-title")).to_have_text("Old stable note")
|
|
|
|
created = page.locator("#doc-modal-meta .doc-created")
|
|
expect(created).to_have_count(1)
|
|
# The badge text: "Created <locale date>" (text + format pairing —
|
|
# the date is carried by text, never color alone, B5).
|
|
expect(created).to_have_text(re.compile(r"^Created \S"))
|
|
# THE assertion: the TRUE old commit date, the full raw ISO on the
|
|
# badge's title (hover precision, locale-independent).
|
|
assert created.get_attribute("title", timeout=15_000) == OLD_ISO
|
|
# Phase 106 D8 in the DOM: the Created badge PRECEDES the Indexed
|
|
# badge (unchanged by this phase — it now simply carries the true
|
|
# date).
|
|
assert page.evaluate(
|
|
"""() => {
|
|
const a = document.querySelector('#doc-modal-meta .doc-created');
|
|
const b = document.querySelector('#doc-modal-meta .doc-indexed');
|
|
return a !== null && b !== null &&
|
|
!!(a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING);
|
|
}"""
|
|
)
|
|
# No regression: the source/format/chunks badges are all still there.
|
|
meta = page.locator("#doc-modal-meta")
|
|
expect(meta.locator(".doc-source-badge")).to_have_text(REPO_NAME)
|
|
expect(meta.locator(".format-badge")).to_have_text("md")
|
|
expect(meta.locator(".doc-chunks")).to_have_text(re.compile(r"^\d+ chunk"))
|
|
|
|
# Close: Escape — the viewer reverts cleanly (the modal hides,
|
|
# focus returns to the row link that opened it).
|
|
page.keyboard.press("Escape")
|
|
expect(page.locator(".doc-modal")).to_be_hidden()
|
|
assert page.evaluate(
|
|
"() => String(document.activeElement.className)"
|
|
) == "doc-link"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 4. UI Structure Check (AGENTS.md rule 5) + no CDN (rule 6) — light pass
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_page_a11y_and_no_cdn_basics(
|
|
page: Page, app_url: str, db_ready: None
|
|
) -> None:
|
|
"""The standard light pass (the test_git_sources_admin.py a11y test
|
|
shape): landmarks on the Sources view, the ``Created`` /
|
|
``Updated`` ``<th>`` cells present in both tables (this phase adds
|
|
NO color — the phase-106 columns only, already ≥4.5:1 — so there
|
|
is no new contrast surface to pin), the 3px ``:focus-visible``
|
|
outline on a table row link (a real keyboard Tab walk), and
|
|
same-origin assets only (rule 6)."""
|
|
page.set_default_timeout(30_000)
|
|
_sync_in_app(page, app_url)
|
|
|
|
# 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 date columns' <th> cells (phase 106 D8, verbatim — text_content,
|
|
# not inner_text: the rendered <th>s are CSS-uppercased).
|
|
file_headers = [
|
|
th.text_content() for th in page.locator("#docs-table thead th").all()
|
|
]
|
|
assert file_headers == ["Source", "Path", "Title", "Chunks", "Created", "Indexed"]
|
|
folder_headers = [
|
|
th.text_content() for th in page.locator("#folders-table thead th").all()
|
|
]
|
|
assert folder_headers == ["Folder", "Documents", "Updated", "Description"]
|
|
|
|
# :focus-visible draws the 3px outline on a table row link — a real
|
|
# keyboard Tab walk (the test_responsive_polish.py idiom): reset
|
|
# focus to the top of the document, then Tab until the old file's
|
|
# row link is focused (the page state is deterministic, so the
|
|
# walk order is — the bound is generous, never the contract).
|
|
_drill_to_folder(page, "old")
|
|
expect(page.locator("#docs-tbody tr", has_text=OLD_PATH)).to_have_count(1)
|
|
page.evaluate(
|
|
"() => { if (document.activeElement instanceof HTMLElement)"
|
|
" document.activeElement.blur(); }"
|
|
)
|
|
reached = False
|
|
for _ in range(150):
|
|
page.keyboard.press("Tab")
|
|
if page.evaluate("() => String(document.activeElement.className)") == "doc-link":
|
|
reached = True
|
|
break
|
|
assert reached, "keyboard Tab never reached the table row link"
|
|
outline = page.evaluate("() => getComputedStyle(document.activeElement).outlineWidth")
|
|
assert outline == "3px", f"focus-visible outline missing on the row link: {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}"
|
|
)
|