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`.
407 lines
17 KiB
Python
407 lines
17 KiB
Python
"""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})"
|
||
)
|