Files
brain-of-reese/tests/e2e/test_sticky_navbar.py
T
ducoterra b0c7aed613
Build and Push Containers / build-and-push-app (push) Successful in 1m44s
Build and Push Containers / build-and-push-db (push) Successful in 11s
phase: 165_prose_word_wrap
All verification complete. Final report:

**Phase 165 — Prose word wrap (final verification pass)**
- Verified: `PROSE_FORMATS={txt,text}` + `isProseDoc` (single-site, image arm wins); `renderDocument` three-way branch (md → `.doc-md`, prose → `.doc-prose` text node, else `pre.doc-raw` byte-identical); `.doc-prose` CSS = 72rem column, `pre-wrap` + `overflow-wrap: anywhere`, tokens only, zero literals; `[data-view="pages"]` hide list includes `.doc-prose` as one grouped declaration; phase-116 scrollbar rules intact and modal-scoped; no `innerHTML` in the prose path.
- Tests/lint (all 100% green):
  - `uv run pytest` → **3854 passed** (unit + integration)
  - `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (>90%)
  - `uv run ruff check .` → All checks passed; `uv run pyright` → **0 errors**
  - `uv run pytest tests/e2e/test_prose_word_wrap.py -v --no-cov` → **5 passed** (isolation)
  - Isolation regressions: document_viewer 8, image_documents 4, pdf_pages_in_sources 4, pdf_strip_additive_selfheal 5, edit_summaries 4, summary_in_viewer 4, chat_answer_clusters 4, garbage_pdf 3, pdf_support 6, sticky_navbar 3, smoke 3 — all passed
- Completion criteria: image description wraps, full text, both surfaces ✔; `.txt` wraps same way ✔; `py`/`json`/code keep `pre.doc-raw` + horizontal overflow + themed modal scrollbar (E2E negative control + pins) ✔; no innerHTML, AA `--ink`/`--surface` pair ✔; full gates green ✔; commit + phase-dir move are harness-owned (not executed per pipeline rules).
- Documentation: README already updated by task 03 (viewer section + image-docs section); full README/AGENTS.md review found no further inconsistencies — **verified consistent, no changes needed**.
- Notable deviations: `.doc-prose` omits `font-family: inherit` from 00_phase D2's snippet — task 01's authoritative "exact property set" excludes it and `div` inherits fonts anyway (behavior identical). PDF deferral (A4) intact: `format == "pdf"` stays in `.doc-raw`, owner call recorded.
- Next pending phase: `166_hide_empty_answer_bubble`.
2026-10-07 22:57:40 -04:00

407 lines
17 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. The pin
is POST-boot (phase 88 keeps the cluster static until app.js adds
``#view-chat.chat-booted``), so test 3 waits for that sentinel
before reading it — the house boot-settle pattern.
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.
Phase 156 re-target (owner-locked A1): the catalogue card became the
tables' OWN bounded scroller (``.table-wrap`` max-height
min(70vh, 64rem)), so a Sources file level no longer grows the
DOCUMENT past a full viewport — its scroll is the residual chrome
height. Test 1 therefore proves the stick across the page's REAL
scroll range (mid + bottom, card scroller pinned too); the DEEP-scroll
proof (the audit's ~−1264px repro, scroll far past one viewport) is
test 2's long-document surface, which still grows the document many
viewports.
Test → story mapping (Playwright Mapping Rule):
1. ``test_app_header_stuck_at_top_on_sources`` — stuck mid-page AND at
the page bottom; the 41-row table scrolls inside the phase-156 card
2. ``test_doc_header_stuck_at_top_on_long_document`` — stuck at the
bottom of a many-viewport scroll (the deep-scroll proof)
3. ``test_short_page_stretch_and_resting_composer_intact``
"""
from __future__ import annotations
import asyncio
import re
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)
# Phase 156 (owner-locked A1): the 41-row table scrolls INSIDE the
# bounded card (max-height min(70vh, 64rem)) — the card is the rows'
# scroller, and the DOCUMENT keeps the residual page scroll (header
# / stats / toolbar / padding / footer). Preconditions, both fail
# loudly: the card must actually scroll (every row reachable), and
# the page must still scroll at all.
sh = page.evaluate("() => document.documentElement.scrollHeight")
assert sh > VIEWPORT_H + 200, (
f"the Sources page must be scrollable (scrollHeight={sh}, want > "
f"{VIEWPORT_H + 200} at a {VIEWPORT_W}×{VIEWPORT_H} viewport) — "
"the page collapsed to a fixed layout"
)
card = page.evaluate(
"""() => {
const c = document.querySelector(
".table-wrap[aria-label='Indexed documents']");
return c ? { sh: c.scrollHeight, ch: c.clientHeight } : null;
}"""
)
assert card is not None and card["sh"] > card["ch"], (
f"the phase-156 card must be the table's scroller "
f"(scrollHeight={None if card is None else card['sh']}, "
f"clientHeight={None if card is None else card['ch']})"
)
max_scroll = sh - VIEWPORT_H
# Mid-page scroll (half the document's available scroll): the
# navbar is still stuck at the top. The DEEP-scroll proof — a scroll
# far past one viewport, the audit's ~−1264px repro — is test 2's
# long-document surface: phase 156's bounded card left Sources as
# the residual-scroll page.
page.evaluate(f"() => window.scrollTo(0, {max_scroll // 2})")
assert (
abs(page.evaluate("() => window.scrollY") - max_scroll // 2) <= TOP_TOL_PX
), "the test scroll must land"
_assert_top_is_zero(page, ".app-header")
# And at the very bottom of the page scroll: stuck, and still
# visible.
page.evaluate("() => window.scrollTo(0, 999999)")
assert page.evaluate("() => window.scrollY") >= max_scroll - TOP_TOL_PX, (
"the test scroll must land at the document bottom"
)
_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 _wait_chat_booted(page: Page) -> None:
"""The house boot-settle wait (test_chip_sizing_question_cap.py /
test_mobile_chat_hamburger_boot.py): app.js adds
``#view-chat.chat-booted`` two frames AFTER the boot settles. The
phase-88 pre-boot rule
(``#view-chat:not(.chat-booted) .composer { position: static }``)
keeps the cluster NON-sticky until then — reading the pin before
the sentinel lands races app boot (the flake this removes: the
sticky assertion measured a pre-boot static composer)."""
page.wait_for_function(
"() => document.getElementById('view-chat')?."
"classList.contains('chat-booted')",
timeout=15_000,
)
expect(page.locator("#view-chat")).to_have_class(re.compile(r"\bchat-booted\b"))
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 pin under test only exists POST-boot (phase 88 defers the
# sticky promotion) — settle the boot BEFORE any measurement.
_wait_chat_booted(page)
# 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})"
)