"""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")