phase: 165_prose_word_wrap
Build and Push Containers / build-and-push-app (push) Successful in 1m44s
Build and Push Containers / build-and-push-db (push) Successful in 11s

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`.
This commit is contained in:
2026-10-07 22:57:40 -04:00
parent 8d70aa946c
commit b0c7aed613
36 changed files with 2198 additions and 64 deletions
+32 -12
View File
@@ -52,8 +52,11 @@ Test → observable mapping (Playwright Mapping Rule):
BOTH docs — the read text doc first (read order), then the
auto-cited image ref (``image_url`` present; the text ref's key is
ABSENT); the last brain bubble's ``.msg-body`` direct children are
exactly ``[tool-calls-disclosure, bubble, msg-meta, msg-meta
msg-images, msg-meta msg-actions]`` — the chips row (EXACTLY the
exactly ``[reasoning, bubble, msg-meta, msg-meta msg-images,
msg-meta msg-actions]`` — the ONE phase-161 reasoning container
ABOVE the answer (this turn's tool record lives inside its
``.reasoning-body`` as a ``.tool-calls-disclosure`` block, the
phase-130 timeline re-targeted by phase 161 D1) then the chips row (EXACTLY the
text chip — NO chip for the image doc, phase 130 task 01) before
the images row (the figure, ``<img>`` loaded from the bytes route,
alt + caption settling to the canned description), no related row
@@ -205,11 +208,14 @@ assert all(len(q) <= 120 for q in (Q_ORDER, Q_FIGURE, Q_RESTORE, Q_NONADMIN)), (
)
#: The .msg-body child sequence of a completed scripted-read bubble
#: (phase 130's fix, DOM-asserted): the tool record (the scripted read),
#: the answer bubble, the chips row (the bare .msg-meta), the images
#: row, the actions row LAST. The live and restore paths share it.
#: (phase 130's fix, DOM-asserted; re-targeted by phase 161 D1): the
#: ONE reasoning container (the scripted read's tool record rides
#: INSIDE it — .reasoning .tool-calls-disclosure, asserted beside the
#: sequence), the answer bubble, the chips row (the bare .msg-meta),
#: the images row, the actions row LAST. The live and restore paths
#: share it.
EXPECTED_CHILD_CLASSES = [
"tool-calls-disclosure",
"reasoning",
"bubble",
"msg-meta",
"msg-meta msg-images",
@@ -759,13 +765,20 @@ def test_answer_clusters_render_in_order(
expect(bubble).to_contain_text(TEXT_TITLE)
expect(bubble).to_contain_text(READ_BODY_QUOTE)
# The DOM ORDER (the fix): tool record, answer, chips row, images
# row, actions row LAST — asserted exactly (this sequence fails
# against the pre-phase soup div, where the buttons shared the
# chips row and the figure interleaved with it).
# The DOM ORDER (the fix): reasoning container (tool record
# inside), answer, chips row, images row, actions row LAST —
# asserted exactly (this sequence fails against the pre-phase
# soup div, where the buttons shared the chips row and the figure
# interleaved with it).
assert _body_child_classes(page) == EXPECTED_CHILD_CLASSES, (
_body_child_classes(page)
)
# The tool record lives INSIDE the ONE reasoning container above
# the answer (phase 161 D1/D5 re-target of the phase-130
# timeline) — the read line carries the read document's path.
tool_record = _last_brain(page).locator(".reasoning .tool-calls-disclosure")
expect(tool_record).to_have_count(1)
expect(tool_record.locator(".tool-call").last).to_contain_text(TEXT_SP)
# The clusters + the actions row's contents (all three buttons —
# admin, docs repo configured) + nothing after the actions row.
@@ -817,7 +830,9 @@ def test_image_without_chip_keeps_its_citation_via_figure(
img = page.locator("#doc-modal .doc-image-img")
expect(img).to_have_count(1)
expect(img).to_have_attribute("src", seeded["image_url"])
expect(page.locator("#doc-modal pre.doc-raw")).to_have_text(
# Phase 165: the description renders in the wrapping .doc-prose
# block (an image description is prose — no scrolling pre).
expect(page.locator("#doc-modal .doc-prose")).to_have_text(
seeded["description"]
)
page.keyboard.press("Escape") # the phase-26 close affordance
@@ -877,10 +892,15 @@ def test_restore_renders_the_same_clusters(
expect(restored).to_contain_text(READ_ANSWER_PREFIX, timeout=SETTLE_TIMEOUT_MS)
# The IDENTICAL structure (the restore renders through the same
# appenders — the child sequence is the live one, exactly).
# appenders — the child sequence is the live one, exactly) — the
# segments path re-renders the reasoning container with the tool
# record inside (phase 161 D4).
assert _body_child_classes(page) == EXPECTED_CHILD_CLASSES == live_children, (
_body_child_classes(page)
)
tool_record = _last_brain(page).locator(".reasoning .tool-calls-disclosure")
expect(tool_record).to_have_count(1)
expect(tool_record.locator(".tool-call").last).to_contain_text(TEXT_SP)
_assert_cluster_rows(
page, seeded,
action_buttons=["tune-btn", "retry-btn", "save-as-doc-btn"],
+17 -8
View File
@@ -38,10 +38,13 @@ Test → story mapping (Playwright Mapping Rule):
inner content).
7. ``test_modal_theme_and_no_cdn`` — dark page background, the panel on
the Phase-08 surface colour, every asset same-origin or ``data:``.
8. ``test_raw_code_block_themed_scrollbar`` — phase 116: the raw code
block (``pre.doc-raw``) overflows horizontally, a scroll action moves
it, the themed scrollbar tokens are in effect via computed style, and
a screenshot records the themed (not native light-gray) bar.
8. ``test_raw_code_block_themed_scrollbar`` — phase 116: the raw CODE
block (``pre.doc-raw`` — a seeded ``.json`` doc since phase 165;
prose — image descriptions and ``txt``/``text`` — now WRAPS in
``.doc-prose`` and never lands here) overflows horizontally, a
scroll action moves it, the themed scrollbar tokens are in effect
via computed style, and a screenshot records the themed (not native
light-gray) bar.
"""
from __future__ import annotations
@@ -153,14 +156,20 @@ def _seed_long_line_doc() -> None:
a non-markdown doc whose one long line is deliberately longer than
the modal width — the shape of the TODO's quest .pl ``quest::say``
line. Direct DB seed, the ``test_modal_xss_safe`` pattern (the
viewer is database-only; no chunks needed)."""
viewer is database-only; no chunks needed). Phase 165: the path is
``.json`` — txt/text joined the PROSE set (wrapped in .doc-prose),
so the code-scroll fixture is a CODE doc, keeping the locked A1
proof on the branch that still scrolls."""
long_line = "quest::say(\"" + ("the quick brown fox jumps over the lazy dog " * 20) + "\")"
with SessionLocal() as db:
db.add(
Document(
source="docs",
path="notes/long-line.txt",
full_path="/tmp/long-line.txt",
# Phase 165: .json, NOT .txt — txt/text joined the PROSE
# set (wrapped in .doc-prose); the phase-116 scroll
# contract binds CODE, and json stays code-ish (D1).
path="notes/long-line.json",
full_path="/tmp/long-line.json",
title="Long Line Fixture",
content=(
"# long-line-fixture: the line below is deliberately\n"
@@ -551,7 +560,7 @@ def test_raw_code_block_themed_scrollbar(
login(page, app_url)
_drill(page, "docs", "notes")
row = page.locator("#docs-tbody tr", has_text="long-line.txt")
row = page.locator("#docs-tbody tr", has_text="long-line.json")
expect(row).to_have_count(1)
row.locator("td:nth-child(2) a.doc-link").click()
+5 -3
View File
@@ -387,7 +387,8 @@ def test_document_viewer_renders_image_and_description(
(phase 26): the ``is_image`` content block renders the PERSISTENT
bytes first (``.doc-image-img`` from the bytes route, ``alt`` =
the summary), and the description follows in the normal content
slot (``pre.doc-raw`` — the plain-content path). The labeled
slot (``.doc-prose`` — the phase-165 wrapping prose arm: an image
description is prose, it WRAPS, never a scrolling pre). The labeled
Summary panel is SUPPRESSED for the verbatim-description case
(summary === content — the importer invariant; the panel would
duplicate the text right below the image)."""
@@ -406,8 +407,9 @@ def test_document_viewer_renders_image_and_description(
expect(img).to_have_attribute("alt", seeded["description"])
# The description below the image (the doc's readable content IS
# the vision description).
expect(modal.locator("pre.doc-raw")).to_have_text(seeded["description"])
# the vision description) — prose (phase 165), never the pre.
expect(modal.locator(".doc-prose")).to_have_text(seeded["description"])
expect(modal.locator("pre.doc-raw")).to_have_count(0)
# The verbatim case: no duplicate Summary panel.
expect(modal.locator(".doc-summary")).to_have_count(0)
# No "Image unavailable" note — the copy is intact.
+4 -2
View File
@@ -546,7 +546,8 @@ def test_document_viewer_renders_the_embedded_image_doc(
"""The embedded image's row (the synthetic ``#page=2#img=0.png``
path) opens the modal: the PERSISTENT copy renders first (the bytes
route, alt = the description), the description follows in the
content slot, and the labeled Summary panel is SUPPRESSED for the
WRAPPING prose block (phase 165: .doc-prose — an image description
is prose, never a scrolling pre), and the labeled Summary panel is SUPPRESSED for the
verbatim-description case (summary === content — the importer
invariant; the panel would duplicate the text right below the
image)."""
@@ -563,7 +564,8 @@ def test_document_viewer_renders_the_embedded_image_doc(
expect(img).to_have_attribute("src", seeded["image_url"])
expect(img).to_have_attribute("alt", seeded["description"])
expect(modal.locator("pre.doc-raw")).to_have_text(seeded["description"])
expect(modal.locator(".doc-prose")).to_have_text(seeded["description"])
expect(modal.locator("pre.doc-raw")).to_have_count(0)
# The verbatim case: no duplicate Summary panel.
expect(modal.locator(".doc-summary")).to_have_count(0)
# No "Image unavailable" note — the copy is intact.
+494
View File
@@ -0,0 +1,494 @@
"""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 ``<img>.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 ``<img>`` 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 ``<img>`` (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 <img> 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 <img> 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")
+74 -15
View File
@@ -27,7 +27,10 @@ the short-page stretch. This suite proves the BROWSER behavior:
* 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.
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
@@ -37,14 +40,27 @@ short unique docs + one ``doc-long.md`` with a ~40,000-char body).
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``
2. ``test_doc_header_stuck_at_top_on_long_document``
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
@@ -223,26 +239,49 @@ def test_app_header_stuck_at_top_on_sources(
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.
# 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 > 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"
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 (~1 viewport down — the pre-fix un-pin point): the
# navbar is still stuck at the top.
page.evaluate("() => window.scrollTo(0, 800)")
# 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 (
page.evaluate("() => window.scrollY") >= VIEWPORT_H - TOP_TOL_PX
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 (the audit's −1264px repro): stuck, and
# still visible.
# 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()
@@ -290,12 +329,32 @@ def test_doc_header_stuck_at_top_on_long_document(
# ---------------------------------------------------------------------------
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.