Files
brain-of-reese/tests/e2e/test_sticky_navbar.py
T
ducoterra 5fa620fde5
Build and Push Containers / build-and-push-app (push) Successful in 2m28s
Build and Push Containers / build-and-push-db (push) Successful in 11s
fix(web): keep the navbar stuck to the top — drop the body height cap on the sticky range
2026-09-01 04:20:26 -04:00

340 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Phase 60 E2E (Playwright): the navbar stays stuck to the top of the
screen at every scroll position — and the short-page layout is intact.
Source: ``TODO.md`` L3 — "The navbar disappears when you scroll down,
should stay stuck to the top of the screen" (no user story file —
TODO-derived phase).
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_sticky_navbar.py -v --no-cov
Mechanism under test (owner-locked A1/A2, roadmap confirmation
2026-08-31): the sticky rule was always there — ``.app-header`` (every
page) and the viewer's two-row ``.doc-header`` (``/document.html``)
both carry ``position: sticky; top: 0``. What broke it: ``body {
height: 100% }`` pinned the body box to exactly one viewport, and a
sticky element may only travel inside its containing block — so after
~1 viewport of scroll the header un-pinned and scrolled away with the
body. The CSS-only fix (task 01) dropped ``height`` from the ``body``
rule (``html`` keeps it); the body's ``min-height: 100dvh`` still drives
the short-page stretch. This suite proves the BROWSER behavior:
* on a long page the header's bounding-box top is 0 at a mid-page
scroll AND at the very bottom (the pre-fix value at the bottom was
off-screen negative — ~−1264px in the audit's 2000px repro);
* the document stays the scroll container (phase 52 no-inner-scroller
contract) — the fix added no scroller, it only let the body grow;
* the short-page layout is EXACTLY as it was: body stretched to the
viewport, footer pinned to the viewport bottom, the phase-52
pinned composer (``position: sticky; bottom: env(safe-area-inset-
bottom)``) resting in its in-flow slot above the footer.
Seeding (A3): the long-scroll surfaces are the seeded Sources table
(``tests/fixtures/docs/`` — 13 docs, same harness as the phase-10/26
suites) PLUS a generated scratch dir the module writes (``gen/``: 40
short unique docs + one ``doc-long.md`` with a ~40,000-char body).
``import_sources`` runs in a worker thread against the deterministic
mock embeddings server (the ``test_document_viewer.py`` pattern) — no
real LLM is involved.
Test → story mapping (Playwright Mapping Rule):
1. ``test_app_header_stuck_at_top_on_sources``
2. ``test_doc_header_stuck_at_top_on_long_document``
3. ``test_short_page_stretch_and_resting_composer_intact``
"""
from __future__ import annotations
import asyncio
from collections.abc import Iterator
from pathlib import Path
from threading import Thread
from typing import Any
import httpx
import pytest
from playwright.sync_api import Page, expect
from sqlalchemy import text
from app.config import Settings
from app.db import SessionLocal
from app.rag.importer import ImportSummary, import_sources
from app.rag.llm import LLMClient
from e2e.auth_helpers import login
REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "docs"
#: The conftest ``page`` fixture's viewport (1280×800).
VIEWPORT_W = 1280
VIEWPORT_H = 800
#: Tolerance for "stuck at the top" / "pinned to the viewport edge":
#: the measured positions are sub-pixel stable (0 in the audit), so the
#: slack is rounding headroom only.
TOP_TOL_PX = 1
#: Phase-52 resting-composer constants (test_pinned_composer.py): the
#: band below a resting composer may only be chrome — the footer's own
#: measured height plus the flow padding of the settled slot — and the
#: box must sit in the lower part of the screen.
BOTTOM_SLACK_PX = 56
LOWER_PART = 0.8
#: Seeding: 13 fixture docs (the A9 family, ``.hidden/`` skipped) + the
#: generated scratch docs.
SHORT_DOC_COUNT = 40
LONG_DOC_BODY_CHARS = 40_000
TOTAL_DOCS = 13 + SHORT_DOC_COUNT + 1 # + the one long doc
# ---------------------------------------------------------------------------
# KB seeding (same pattern as the phase 10/26/52 story suites)
# ---------------------------------------------------------------------------
def _write_generated_docs(base: Path) -> Path:
"""The scratch source dir: ``gen/doc-001.md`` … ``gen/doc-040.md``
(one-line bodies, unique titles) plus ``gen/doc-long.md`` with a
~40,000-char body (deterministic numbered paragraphs — real
scrollable content, no injected DOM)."""
gen = base / "gen"
gen.mkdir(parents=True, exist_ok=True)
for i in range(1, SHORT_DOC_COUNT + 1):
(gen / f"doc-{i:03d}.md").write_text(
f"# Sticky probe {i:03d}\n\nShort body line for probe {i:03d}.\n",
encoding="utf-8",
)
paragraphs: list[str] = []
n = 0
while sum(len(p) for p in paragraphs) < LONG_DOC_BODY_CHARS:
n += 1
paragraphs.append(
f"Paragraph {n:04d}: the quick brown fox jumps over the "
"lazy dog near the homelab rack."
)
(gen / "doc-long.md").write_text(
"# Sticky long document\n\n" + "\n\n".join(paragraphs) + "\n",
encoding="utf-8",
)
return gen
async def _import_dirs(mock_port: int, dirs: list[Path]) -> ImportSummary:
kwargs: dict[str, Any] = {"_env_file": None, "llm_base_url": f"http://127.0.0.1:{mock_port}/v1"}
settings = Settings(**kwargs) # pyright: ignore[reportCallIssue]
return await import_sources(dirs, LLMClient(settings))
def _run_in_thread(coro: Any) -> Any:
"""Run a coroutine on a worker thread.
Playwright's sync API keeps an asyncio loop running on the test thread,
so ``asyncio.run`` cannot be called directly from a test body.
"""
box: dict[str, Any] = {}
def runner() -> None:
try:
box["value"] = asyncio.run(coro)
except BaseException as e: # noqa: BLE001 — re-raised on the test thread
box["error"] = e
t = Thread(target=runner)
t.start()
t.join()
if "error" in box:
raise box["error"]
return box["value"]
def _reset_db(mock_port: int, dirs: list[Path] | None) -> ImportSummary | None:
"""Truncate the KB (and query log), then optionally re-import."""
with SessionLocal() as db:
db.execute(text("TRUNCATE chunks, documents, query_log"))
db.commit()
if not dirs:
return None
return _run_in_thread(_import_dirs(mock_port, dirs))
@pytest.fixture(scope="module")
def kb_db_ready(app_url: str) -> None:
"""Module-scoped twin of conftest's ``db_ready`` (conftest's one is
function-scoped and cannot back a module-scoped fixture)."""
body = httpx.get(f"{app_url}/api/health", timeout=5).json()
if body["db"] != "up":
pytest.skip("Postgres not reachable — run `podman compose up -d db` first")
@pytest.fixture(scope="module")
def seeded_kb(
mock_llm: int, kb_db_ready: None, tmp_path_factory: pytest.TempPathFactory
) -> Iterator[None]:
"""A fresh KB: the 13 fixture docs + the generated scratch dir,
imported once for the whole module (truncated again on teardown)."""
gen = _write_generated_docs(tmp_path_factory.mktemp("sticky_navbar"))
summary = _reset_db(mock_llm, [FIXTURES, gen])
assert summary is not None and summary.added == TOTAL_DOCS, (
f"expected {TOTAL_DOCS} added docs, got {summary.added if summary else None}"
)
yield
_reset_db(mock_llm, None)
# ---------------------------------------------------------------------------
# Measurement helpers
# ---------------------------------------------------------------------------
def _assert_top_is_zero(page: Page, selector: str) -> None:
"""The element's bounding-box top is the viewport top (± TOP_TOL_PX).
``bounding_box`` is viewport-relative and never scrolls the page
itself, so this is exactly "where the bar sits where the user left
it" — the TODO's question. Pre-fix, the header's top here was a
large NEGATIVE number (scrolled away with the capped body).
"""
box = page.locator(selector).bounding_box()
assert box is not None, f"{selector} must be rendered (no bounding box)"
# Playwright's bounding box is x/y/width/height — top == y.
assert abs(box["y"]) <= TOP_TOL_PX, (
f"{selector} is not stuck to the top: rect top={box['y']:.2f} "
f"(want 0 ±{TOP_TOL_PX}) at scrollY={page.evaluate('() => window.scrollY'):.0f}"
)
# ---------------------------------------------------------------------------
# 1. The app navbar on a long Sources page: stuck at the top at a
# mid-page scroll AND at the very bottom
# ---------------------------------------------------------------------------
def test_app_header_stuck_at_top_on_sources(
page: Page, app_url: str, seeded_kb: None
) -> None:
page.set_default_timeout(30_000)
login(page, app_url) # → /sources.html (the table is admin-only)
expect(page.locator("#docs-tbody tr")).to_have_count(TOTAL_DOCS)
# The precondition the story needs: the page must actually scroll —
# fail loudly if the table ever stops being long enough.
sh = page.evaluate("() => document.documentElement.scrollHeight")
assert sh > 1.5 * VIEWPORT_H, (
f"the Sources table must be long enough to scroll "
f"(scrollHeight={sh}, want > {1.5 * VIEWPORT_H:.0f} at a "
f"{VIEWPORT_W}×{VIEWPORT_H} viewport) — add more generated docs"
)
# Mid-page scroll (~1 viewport down — the pre-fix un-pin point): the
# navbar is still stuck at the top.
page.evaluate("() => window.scrollTo(0, 800)")
assert (
page.evaluate("() => window.scrollY") >= VIEWPORT_H - TOP_TOL_PX
), "the test scroll must land"
_assert_top_is_zero(page, ".app-header")
# And at the very bottom (the audit's −1264px repro): stuck, and
# still visible.
page.evaluate("() => window.scrollTo(0, 999999)")
_assert_top_is_zero(page, ".app-header")
expect(page.locator(".app-header")).to_be_visible()
# ---------------------------------------------------------------------------
# 2. The document viewer's two-row header on a long doc: stuck at the top
# at the bottom of the scroll
# ---------------------------------------------------------------------------
def test_doc_header_stuck_at_top_on_long_document(
page: Page, app_url: str, seeded_kb: None
) -> None:
page.set_default_timeout(30_000)
# The dedicated viewer page (phase 10/26 contract) on the generated
# long doc — source ``gen``, path ``doc-long.md``.
page.goto(f"{app_url}/document.html?source=gen&path=doc-long.md")
expect(page.locator("#doc-title")).to_have_text("Sticky long document")
expect(page.locator("#doc-content .doc-md")).not_to_be_empty()
# The doc must actually scroll (the ~40,000-char body fills many
# viewports in the 46rem column).
sh = page.evaluate("() => document.documentElement.scrollHeight")
assert sh > VIEWPORT_H, (
f"the long document must make the page scroll "
f"(scrollHeight={sh}, want > {VIEWPORT_H})"
)
# To the very bottom: BOTH header rows stay pinned (the header
# element is content-sized + sticky — phase 34).
page.evaluate("() => window.scrollTo(0, document.documentElement.scrollHeight)")
assert (
page.evaluate("() => window.scrollY") >= sh - VIEWPORT_H - TOP_TOL_PX
), "the test scroll must land at the document bottom"
_assert_top_is_zero(page, ".doc-header")
# ---------------------------------------------------------------------------
# 3. Short-page regression: the phase-52 layout is EXACTLY as it was —
# body stretched to the viewport, footer pinned to the viewport
# bottom, the pinned composer resting in its in-flow slot above the
# footer (the drop of the body's height cap must not have moved a
# single pixel of the non-scrolling layout)
# ---------------------------------------------------------------------------
def test_short_page_stretch_and_resting_composer_intact(
page: Page, app_url: str
) -> None:
page.set_default_timeout(30_000)
page.goto(app_url)
expect(page.locator("#empty-state")).to_be_visible()
# The stretch holds: the body is exactly one viewport tall, and the
# grow must not invent scrollable space.
sh = page.evaluate("() => document.documentElement.scrollHeight")
assert sh <= VIEWPORT_H + TOP_TOL_PX, (
f"an empty chat must not be scrollable (scrollHeight={sh})"
)
body = page.locator("body").bounding_box()
assert body is not None
assert abs(body["height"] - VIEWPORT_H) <= TOP_TOL_PX, (
f"the body must stretch to the viewport (height={body['height']:.2f}, "
f"want {VIEWPORT_H}) — the min-height: 100dvh driver is intact"
)
# The footer is pinned to the viewport bottom (the audit's 800/800).
footer = page.locator(".app-footer").bounding_box()
assert footer is not None
footer_bottom = footer["y"] + footer["height"]
assert abs(footer_bottom - VIEWPORT_H) <= TOP_TOL_PX, (
f"the footer must sit at the viewport bottom "
f"(bottom={footer_bottom:.2f}, want {VIEWPORT_H})"
)
# The phase-52 pinned composer holds: the pin itself (position:
# sticky) and its RESTING geometry — in-flow, at the bottom of the
# screen, with only chrome (the footer + the settled slot's flow
# padding) in the band below it, never overlapping the footer.
assert (
page.evaluate(
"() => getComputedStyle(document.querySelector('#composer')).position"
)
== "sticky"
), "the composer must keep its phase-52 sticky pin"
composer = page.locator("#composer").bounding_box()
assert composer is not None
composer_bottom = composer["y"] + composer["height"]
deadband = VIEWPORT_H - composer_bottom
assert deadband <= footer["height"] + BOTTOM_SLACK_PX, (
f"dead band under the composer: {deadband:.0f}px below it with a "
f"{footer['height']:.0f}px footer — the resting slot moved"
)
assert composer_bottom >= VIEWPORT_H * LOWER_PART, (
f"the composer rests at {composer_bottom / VIEWPORT_H:.0%} of the "
"viewport — it has to sit at the bottom, not under the empty state"
)
assert composer_bottom <= footer["y"] + TOP_TOL_PX, (
f"the composer overlaps the footer (composer bottom="
f"{composer_bottom:.2f}, footer top={footer['y']:.2f})"
)