"""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) # Phase 97: the catalog is the drill-down tree — the top level # lists the two sources (the KB total rides the stat cards; the # flat 55-row tbody no longer exists). The LONG page the story # needs is a file level: drill into `gen` (41 direct docs). page.locator("#folders-tbody .folder-link").first.wait_for(state="visible") expect(page.locator("#stat-docs")).to_have_text(str(TOTAL_DOCS)) page.click('#folders-tbody a.folder-link:text-is("gen")') expect(page.locator("#docs-tbody tr")).to_have_count(TOTAL_DOCS - 13) # 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``. login(page, app_url, next="/") # phase 79: the viewer content is gated 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 72rem container column — phase 100). 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})" )