"""Phase 165 E2E (Playwright): prose word wrap — the TODO.md L3 claim ("Need word wrap for image explanations and other long text rather than scrolling horizontally") proven end-to-end, with ZERO horizontal overflow, on BOTH viewer surfaces: the ``/document.html`` page and the in-page modal opened from a Sources row. Run in isolation (DB must be up: ``podman compose up -d db``): uv run pytest tests/e2e/test_prose_word_wrap.py -v --no-cov Seeding is the direct-DB idiom of ``tests/e2e/test_document_viewer.py`` (``_seed_long_line_doc`` — the viewer is database-only: no chunks, no fixture import, no mock LLM turn) + the image-document shape of ``tests/e2e/test_image_documents.py``: an ``is_image`` row whose ``content`` AND ``summary`` are the vision description (the importer invariant, phase 122 A3) and whose ``image_path`` points at a real PNG written into the module's scratch dir — so the ``image_url`` bytes route serves REAL pixels (probed over HTTP at seed time and again through the rendered ``.naturalWidth``). Three seeded docs, one per rendering arm (phase 165 D1): * ``notes/landscape.png`` — image doc (prose by construction, whatever its extension): a ~860-char SINGLE-PARAGRAPH vision description; * ``notes/prose-long.txt`` — the ``txt`` prose arm: one ~860-char line PLUS a second paragraph (proves BOTH intra-line wrap and the paragraph break — ``white-space: pre-wrap`` carries the ``\\n\\n`` as a blank rendered line); * ``notes/code-long.py`` — the NEGATIVE control (locked A1/A3): one ~900-char line that MUST keep overflowing horizontally in ``pre.doc-raw`` — the wrapping fix must never wrap code. Per prose doc the suite asserts, on BOTH surfaces: the prose block exists exactly once and carries the FULL text (nothing clamped), ``scrollWidth <= clientWidth + 1`` (the TODO's literal defect — no horizontal scrollbar), the block is taller than 2.2 line-heights (the text really wrapped), the computed ``white-space`` is ``pre-wrap``, and the block rides the 72rem reading column (``max-width: 1152px``, never wider than its content host). The image doc additionally keeps the phase-122 ordering (the ```` ABOVE the prose) and the suppressed Summary panel (``summary === content``). Test → observable mapping (Playwright Mapping Rule): 1. ``test_image_doc_description_wraps_on_the_page`` — the standalone viewer page: image above the wrapping ``.doc-prose``, full text, zero horizontal overflow, summary suppressed, no ``pre.doc-raw``. 2. ``test_image_doc_description_wraps_in_the_modal`` — the same doc through the Sources-row modal: identical contract on ``#doc-modal-content``. 3. ``test_txt_doc_wraps_on_the_page`` — the ``.txt`` body on the page: full raw text, the ``\\n\\n`` preserved AND rendered as a blank line, no horizontal overflow, 72rem column. 4. ``test_txt_doc_wraps_in_the_modal`` — the same through the modal. 5. ``test_code_doc_still_scrolls_horizontally`` — the locked code path on both surfaces: ``pre.doc-raw``, ``white-space: pre``, ``scrollWidth > clientWidth`` (phase 116 A1 alive E2E, not just in the CSS pins). """ from __future__ import annotations import base64 from collections.abc import Iterator from datetime import UTC, datetime from urllib.parse import quote import httpx import pytest from playwright.sync_api import Page, expect from sqlalchemy import text from app.db import SessionLocal from app.models import Document from e2e.auth_helpers import login SOURCE = "docs" IMAGE_PATH = "notes/landscape.png" TXT_PATH = "notes/prose-long.txt" CODE_PATH = "notes/code-long.py" #: The mock-free stand-in for a vision description: ONE single-line #: paragraph (no ``\\n`` at all — exactly the shape that used to become #: one unbreakable line in the old ``pre.doc-raw``), long enough that #: rendering it unwrapped would overflow any viewport. DESCRIPTION = ( "A wide screenshot of the homelab dashboard on the wall display: the top row shows four " "gauge panels, CPU load at eighteen percent, memory at six of thirty-two gigabytes, disk at " "forty-one percent across the merged pool, and network throughput peaking at four hundred " "megabits during the nightly backup window, while the middle band lists every service as a " "green pill (proxmox, pihole, nginx proxy manager, gitea, immich, postgres, valkey, and the " "rag indexer), and the bottom strip is a time-series graph of requests per second whose " "legend reads ingress, api, and embeddings; the sidebar carries the clock, a weather " "widget, and a small map pin marking the rack location beside the hall closet. Every label " "on the capture stays legible at full width, from the uptime counter in the corner to the " "footnote that names the exporter version and the scrape interval." ) #: The ``.txt`` body: ONE ~860-char line (the intra-line wrap proof) #: plus a second paragraph (the paragraph-break proof). _FOX = "the quick brown fox jumps over the lazy dog " TXT_LINE = "notes/prose-long.txt reads: " + _FOX * 18 TXT_PARA2 = ( "The second paragraph exists to prove the paragraph BREAK survives the prose block: " "pre-wrap carries it." ) TXT_CONTENT = TXT_LINE + "\n\n" + TXT_PARA2 #: The negative control: one ~900-char code line — locked A1 (phase #: 116): code NEVER wraps, it keeps the horizontal scroller. CODE_LINE = "result = render_report(" + '"' + _FOX * 20 + '"' + ")" assert len(DESCRIPTION) >= 800, "the vision description must be a long single line" assert "\n" not in DESCRIPTION, "the description must be ONE line (the un-wrapped defect shape)" assert len(TXT_LINE) >= 800 and "\n" not in TXT_LINE assert len(CODE_LINE) >= 800 and "\n" not in CODE_LINE #: A real 1×1 transparent PNG (the phase-122 fixture bytes — the route #: serves the real bytes; naturalWidth 1 is the served-proof probe). PNG_1X1 = base64.b64decode( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ" "AAAAC0lEQVR4nGP4DwQACfsD/fteaysAAAAASUVORK5CYII=" ) # --------------------------------------------------------------------------- # DB helpers (the test_document_viewer direct-seed idiom) # --------------------------------------------------------------------------- def _truncate_kb() -> None: """Fresh KB per module run (the E2E isolation pattern of tests/e2e/test_image_documents.py — every catalogue table the seeded rows could touch resets, so leftovers from an earlier suite can never corrupt this one's counts or rows).""" with SessionLocal() as db: db.execute(text( "TRUNCATE chunks, documents, query_log, kb_overview, " "git_sources, sources_meta, folder_summaries" )) db.commit() @pytest.fixture(scope="module") def seeded(app_url: str, tmp_path_factory: pytest.TempPathFactory) -> Iterator[dict[str, str]]: """Seed the three docs (image / txt / code) straight into the KB — the viewer is database-only, so no chunks and no LLM are involved (the ``_seed_long_line_doc`` pattern) — and write the image PNG into the module's scratch dir so ``image_path`` points at bytes the PUBLIC bytes route actually serves (probed here over HTTP with the right Content-Type). Yields the identity map for the assertions; truncates on teardown.""" try: body = httpx.get(f"{app_url}/api/health", timeout=5).json() except Exception: # noqa: BLE001 — unreachable is the skip case body = {"db": "down"} if body.get("db") != "up": pytest.skip("Postgres not reachable — run `podman compose up -d db` first") _truncate_kb() scratch = tmp_path_factory.mktemp("bor_prose_wrap") png = scratch / "landscape.png" png.write_bytes(PNG_1X1) with SessionLocal() as db: image_doc = Document( source=SOURCE, path=IMAGE_PATH, full_path=str(png), title="Landscape Screenshot", content=DESCRIPTION, summary=DESCRIPTION, # the importer invariant: summary IS the description is_image=True, image_path=str(png), content_hash="a1" * 32, indexed_at=datetime.now(UTC), ) db.add(image_doc) db.add( Document( source=SOURCE, path=TXT_PATH, full_path="/tmp/prose-long.txt", title="Prose Long Txt", content=TXT_CONTENT, content_hash="b2" * 32, indexed_at=datetime.now(UTC), ) ) db.add( Document( source=SOURCE, path=CODE_PATH, full_path="/tmp/code-long.py", title="Code Long Py", content=CODE_LINE + "\n", content_hash="c3" * 32, indexed_at=datetime.now(UTC), ) ) db.commit() image_url = f"/api/documents/{image_doc.id}/image" # The seeded image_url serves REAL bytes (phase 122's route is # public — phase 124 A4): the render assertions below are about a # genuinely delivered image, never a broken one. served = httpx.get(f"{app_url}{image_url}", timeout=10) assert served.status_code == 200, "the seeded image bytes must serve through the route" assert served.headers["content-type"] == "image/png" yield { "image_url": image_url, "description": DESCRIPTION, } _truncate_kb() # --------------------------------------------------------------------------- # Surface helpers # --------------------------------------------------------------------------- def _drill(page: Page, *names: str) -> None: """Phase 97: drill the catalogue tree (exact name match, one per name) to the level that holds the asserted file (the test_document_viewer idiom).""" for name in names: page.click(f'#folders-tbody a.folder-link:text-is("{name}")') def _open_doc_page(page: Page, app_url: str, doc_path: str) -> None: """The STANDALONE surface: sign in, then the direct /document.html?source=…&path=… URL (the same percent-encoding the chips and row links carry).""" login(page, app_url, next="/") page.goto( f"{app_url}/document.html?source={SOURCE}&path={quote(doc_path, safe='')}", timeout=30_000, ) def _open_doc_modal(page: Page, app_url: str, path_needle: str) -> None: """The IN-PAGE modal surface: the Sources row's path link (phase 26 — no navigation, the modal opens over the catalogue).""" login(page, app_url) _drill(page, "docs", "notes") row = page.locator("#docs-tbody tr", has_text=path_needle) expect(row).to_have_count(1) row.locator("td:nth-child(2) a.doc-link").click() expect(page.locator("#doc-modal")).to_be_visible(timeout=30_000) def _assert_wrapping_prose(page: Page, content_sel: str, full_text: str) -> dict[str, object]: """The phase-165 prose contract on ONE surface's content host: * exactly ONE ``.doc-prose`` block, NO ``pre.doc-raw`` sibling; * the FULL text — to_have_text (whitespace-normalized) AND the raw ``textContent`` byte-for-byte (nothing clamped, nothing dropped); * ``scrollWidth <= clientWidth + 1`` — THE TODO's literal defect: no horizontal overflow, no sideways scrollbar (an 858-char unwrapped line would report ~6000px here — this bar alone proves the wrap once the full raw text is present); * ``height > 2.2 × line-height`` — the block stands several line boxes tall (the task's proof bar; with the two bars above it means the FULL text occupies real wrapped lines, not a clipped or scrolled single line); * computed ``white-space == "pre-wrap"`` and ``max-width`` resolving to the 72rem reading column (1152px at the 16px root), never wider than the content host. """ prose = page.locator(f"{content_sel} .doc-prose") expect(prose).to_have_count(1) expect(prose).to_have_text(full_text, timeout=30_000) expect(page.locator(f"{content_sel} pre.doc-raw")).to_have_count(0) m = prose.evaluate( """(el) => { const host = el.parentElement; const cs = getComputedStyle(el); return { raw: el.textContent, inner: el.innerText, scrollWidth: el.scrollWidth, clientWidth: el.clientWidth, height: el.getBoundingClientRect().height, width: el.getBoundingClientRect().width, hostWidth: host.getBoundingClientRect().width, whiteSpace: cs.whiteSpace, maxWidth: cs.maxWidth, lineHeight: parseFloat(cs.lineHeight), }; }""" ) assert m["raw"] == full_text, "the prose block must hold the FULL content (raw equality)" assert m["scrollWidth"] <= m["clientWidth"] + 1, ( f"horizontal overflow — the TODO defect is back: {m}" ) assert m["lineHeight"] > 0, f"line-height did not resolve to px: {m}" assert m["height"] > 2.2 * m["lineHeight"], ( f"the block is {m['height']}px tall, want > 2.2 line-heights " f"({2.2 * m['lineHeight']:.0f}px) — the text did not wrap" ) assert m["whiteSpace"] == "pre-wrap", f"white-space is {m['whiteSpace']!r}, not pre-wrap" assert m["maxWidth"] == "1152px", ( f"max-width is {m['maxWidth']!r}, want 1152px (the 72rem --chat-column)" ) assert m["width"] <= m["hostWidth"] + 1, ( f"the block ({m['width']}px) is wider than its content host ({m['hostWidth']}px)" ) return m def _assert_code_scrolls(page: Page, content_sel: str) -> None: """The locked code contract (phase 116 A1 / phase 165 A3) on ONE surface: the code doc renders ``pre.doc-raw`` with ``white-space: pre`` + ``overflow-x: auto`` and REAL horizontal overflow — no prose block anywhere.""" pre = page.locator(f"{content_sel} pre.doc-raw") expect(pre).to_have_count(1, timeout=30_000) expect(pre).to_have_text(CODE_LINE + "\n", timeout=30_000) expect(page.locator(f"{content_sel} .doc-prose")).to_have_count(0) dims = pre.evaluate( """(el) => { const cs = getComputedStyle(el); return { sw: el.scrollWidth, cw: el.clientWidth, ws: cs.whiteSpace, ox: cs.overflowX, }; }""" ) assert dims["sw"] > dims["cw"], f"the code line stopped overflowing: {dims}" assert dims["ws"] == "pre", f"code white-space is {dims['ws']!r}, not pre (A3 broken)" assert dims["ox"] == "auto", f"overflow-x is {dims['ox']!r}, not auto" # --------------------------------------------------------------------------- # 1–2. The image doc: the vision description wraps on both surfaces # --------------------------------------------------------------------------- def test_image_doc_description_wraps_on_the_page( page: Page, app_url: str, seeded: dict[str, str] ) -> None: """The standalone /document.html page: the ``is_image`` doc renders the ```` (from the served bytes route) ABOVE the prose block (the phase-122 ordering), the labeled Summary panel stays SUPPRESSED (summary === content), and the full ~860-char description reads as wrapping prose — zero horizontal overflow, pre-wrap, the 72rem column, no ``pre.doc-raw`` anywhere.""" page.set_default_timeout(30_000) _open_doc_page(page, app_url, IMAGE_PATH) expect(page.locator("#doc-title")).to_have_text("Landscape Screenshot") _assert_wrapping_prose(page, "#doc-content", seeded["description"]) # The image is served AND rendered, and it sits ABOVE the prose # (phase 122's ordering contract — the image first, the wrapping # description below it). img = page.locator("#doc-content .doc-image-img") expect(img).to_have_count(1) expect(img).to_have_attribute("src", seeded["image_url"]) expect(img).to_have_attribute("alt", seeded["description"]) expect(img).to_be_visible() assert img.evaluate("el => el.naturalWidth") == 1, "the bytes route must deliver real PNG bytes" assert page.evaluate( """() => { const host = document.querySelector('#doc-content'); const img = host.querySelector('.doc-image-img'); const prose = host.querySelector('.doc-prose'); return (img.compareDocumentPosition(prose) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0; }""" ), "the must render ABOVE the prose block (phase 122 ordering)" # The verbatim-description case keeps the Summary panel suppressed # (the panel would duplicate the text right below the image). expect(page.locator("#doc-content .doc-summary")).to_have_count(0) expect(page.locator("#doc-content .doc-image-unavailable")).to_have_count(0) def test_image_doc_description_wraps_in_the_modal( page: Page, app_url: str, seeded: dict[str, str] ) -> None: """The SAME doc through the Sources-row modal: the identical prose contract on ``#doc-modal-content`` — one shared renderDocument core means the modal wraps exactly like the page (no per-surface drift), inside the narrower (1100px) panel.""" page.set_default_timeout(30_000) _open_doc_modal(page, app_url, "landscape.png") expect(page.locator("#doc-modal-title")).to_have_text("Landscape Screenshot") _assert_wrapping_prose(page, "#doc-modal-content", seeded["description"]) img = page.locator("#doc-modal-content .doc-image-img") expect(img).to_have_count(1) expect(img).to_have_attribute("src", seeded["image_url"]) assert img.evaluate("el => el.naturalWidth") == 1 assert page.evaluate( """() => { const host = document.querySelector('#doc-modal-content'); const img = host.querySelector('.doc-image-img'); const prose = host.querySelector('.doc-prose'); return (img.compareDocumentPosition(prose) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0; }""" ), "the modal must keep the ABOVE the prose block too" expect(page.locator("#doc-modal-content .doc-summary")).to_have_count(0) # --------------------------------------------------------------------------- # 3–4. The .txt doc: a 860-char line wraps, the paragraph break survives # --------------------------------------------------------------------------- def _assert_paragraph_break(m: dict[str, object]) -> None: """The SECOND paragraph proof (beyond the wrap proof in _assert_wrapping_prose): the raw text node keeps the ``\\n\\n`` verbatim, and the presentation text (``innerText``, which in a pre-wrap context carries the SOURCE hard breaks — soft wraps are rendered line boxes, not innerText newlines) still shows the three-paragraph source shape: long line, BLANK line, second paragraph. The intra-line wrap itself is the scrollWidth/height proof above — the 858-char source line is one innerText line that measurably does NOT overflow horizontally.""" raw = str(m["raw"]) inner = str(m["inner"]) assert "\n\n" in raw, "the raw text node must keep the paragraph break" assert "\n\n" in inner, ( "the pre-wrap presentation text must keep the blank paragraph line" ) lines = inner.splitlines() assert len(lines) == 3 and lines[1] == "", ( f"the paragraph shape is not (line, blank, paragraph): {lines}" ) def test_txt_doc_wraps_on_the_page( page: Page, app_url: str, seeded: dict[str, str] ) -> None: # `seeded` is the module's seed+truncate barrier (this test reads # the txt row it creates, not its return value). """The standalone page, the ``txt`` prose arm: the ~860-char line wraps inside the reading column (zero horizontal overflow — the TODO's literal ask), the full body is present raw, the paragraph break renders as a blank line, ``pre-wrap`` + the 72rem column are in effect, and no ``pre.doc-raw`` remains for a prose format.""" page.set_default_timeout(30_000) _open_doc_page(page, app_url, TXT_PATH) expect(page.locator("#doc-title")).to_have_text("Prose Long Txt") expect(page.locator("#doc-meta .format-badge")).to_have_text("txt") m = _assert_wrapping_prose(page, "#doc-content", TXT_CONTENT) _assert_paragraph_break(m) def test_txt_doc_wraps_in_the_modal( page: Page, app_url: str, seeded: dict[str, str] ) -> None: # `seeded`: the module's seed barrier (see test 3). """The same ``.txt`` doc through the modal: identical wrapping on ``#doc-modal-content`` (the shared core, the shared CSS).""" page.set_default_timeout(30_000) _open_doc_modal(page, app_url, "prose-long.txt") expect(page.locator("#doc-modal-title")).to_have_text("Prose Long Txt") m = _assert_wrapping_prose(page, "#doc-modal-content", TXT_CONTENT) _assert_paragraph_break(m) # --------------------------------------------------------------------------- # 5. The negative control: code keeps the horizontal scroller (locked A1/A3) # --------------------------------------------------------------------------- def test_code_doc_still_scrolls_horizontally( page: Page, app_url: str, seeded: dict[str, str] ) -> None: # `seeded`: the module's seed barrier (see test 3). """The wrapping fix is provably SCOPED: the ~900-char code line still lands in ``pre.doc-raw`` and STILL overflows horizontally with ``white-space: pre`` + ``overflow-x: auto`` on BOTH surfaces — phase 116's locked A1 ("wrapping breaks code readability") alive end-to-end, not just in the CSS pins (phase 165 D3).""" page.set_default_timeout(30_000) # Surface 1: the standalone page. _open_doc_page(page, app_url, CODE_PATH) expect(page.locator("#doc-title")).to_have_text("Code Long Py") expect(page.locator("#doc-meta .format-badge")).to_have_text("py") _assert_code_scrolls(page, "#doc-content") # Surface 2: the in-page modal (the phase-116 themed scrollbar # owner — tests/e2e/test_document_viewer.py pins the bar itself; # here the overflow contract is what must not have drifted). _open_doc_modal(page, app_url, "code-long.py") expect(page.locator("#doc-modal-title")).to_have_text("Code Long Py") _assert_code_scrolls(page, "#doc-modal-content")