"""Phase 156 E2E (Playwright): the catalogue tables' sticky headers + the ellipsis hint on clipped labels — the owner's UX audit, pinned. Source: the owner UX audit 2026-10-03 (live interactive-browser pass): "sticky table headers (RAG/Sources/History — the tables are long; the header scrolls away)" + "At ~1,009px window width the rightmost table columns start clipped with no ellipsis hint on the 'Hidden'/'Edit' labels (the wrapper scrolls fine — just subtle)". Story: n/a (audit-derived phase; the table machinery is phase 97 / 86 / 63, all complete). Run in isolation (mock LLM; DB up: ``podman compose up -d db``): uv run pytest tests/e2e/test_table_ux_polish.py -v --no-cov The phase design (phase 156, owner-locked A1/A3): the three catalogue tables (the RAG folder table ``#folders-table`` in ``#folders-wrap``, the Sources table ``#git-sources-table`` in ``#git-sources-table-wrap``, the History table in ``#history-table-wrap``) all ride the ONE ``.table-wrap`` card, which is now the bounded vertical scroll region (``max-height: min(70vh, 64rem)`` + ``overflow: auto``) with the OPAQUE sticky ``th`` (``position: sticky; top: 0; z-index: 1``). A clipped action label (the five ``.cell-clip`` families) shows ``…`` instead of a hard cut; the full text stays reachable through the control's ``aria-label``/``title`` naming the row's identity (the source URL / the chat title / the source name — the phase-89/105, 66, and 97 idioms). Test → contract mapping (Playwright Mapping Rule): 1. ``test_sticky_header_on_vertical_scroll`` (×3 — RAG folders / Sources / History, parameterized) — THE audit's sticky pin: at 1280×800 (the panel's ``max-height`` resolves to 560px) a 16-row fixture overflows the panel (precondition asserted: the table's ``scrollHeight`` > the panel's ``clientHeight``); after ``scrollTop = 400`` (clamped to the max), ``scrollTop > 0`` AND the ``th``'s top edge is EXACTLY at the panel's content top — the ``boundingBox`` delta (``thTop − wrapTop − borderTop``) is 0 within sub-pixel precision (the standard sticky assertion; WITHOUT the phase-156 rule the ``th`` would sit ~400px above the panel top — the counterfactual guard asserts the ``th`` never goes above the panel top). 2. ``test_horizontal_scroll_contract_unchanged`` — the phase-97/86 contract the ``overflow: auto`` change must keep: at 375×812 each table's ``min-width`` (480 / 560 / 640) still triggers the in-card horizontal scroll (``wrap.scrollWidth > wrap.clientWidth`` with the table wider than the 346px card). 3. ``test_ellipsis_on_clipped_label`` — the audit's ~1,009px case: at the 1,009px viewport the clip is FORCED on the row's host controls (the inline width below the label's natural size — see the "Why the clip is forced at the control" note below), and for EVERY clipped ``.cell-clip`` span: the COMPUTED ``text-overflow`` is ``"ellipsis"``, ``scrollWidth > clientWidth`` (the label is actually clipped, not merely styled), and the control's ``aria-label`` names the ROW's identity (the source URL / the chat title / the source name — the audit's additions). At least one span per table must clip (the determinism guard). 4. ``test_mobile_contract`` — the PLAN §7.1 mobile ≤640px regression guard (the phase-86 suite's idiom): at 375×812 a REAL ``#nav-toggle`` click opens the hamburger (``aria-expanded="true"``), a menu row navigates (the menu closes on navigation — the phase-85/88 contract), and in EVERY catalogue view the table still scrolls horizontally inside the bounded panel (test 2's contract) while the ``thead`` still pins (test 1's contract — the ``max-height`` must not break the mobile scroll model). Why the clip is forced at the control (test 3): Chromium's auto table layout renders the table at its MAX-content width whenever the min-content sum exceeds the panel (measured: at the 1,009px 967px card, with this suite's 45-char source URLs, 45-char chat titles, and 8-char folder names, the tables keep 950–968px of columns and hard-cut nothing), so a viewport alone cannot deterministically engage the ``.cell-clip`` clamp (the engagement width is font-metric dependent). The test instead narrows the row's host controls to below the label's natural size (40px ``.git-source-hidden`` label, 60px ``.git-source-ignore`` / ``.git-source-remove``, 30px ``.kb-summary-edit``, 60px ``.history-share-create`` / ``.history-unshare``, 50px ``.history-delete``): the clamp chain a squeezed cell engages is the EXACT same one (the control's ``max-width: 100%`` + ``min-width: 0`` — the phase-156 task-02 support rules — plus the span's ``min-width: 0`` + ``overflow: hidden`` + ``text-overflow: ellipsis`` + ``white-space: nowrap``), so the assertion is deterministic and metric-independent (measured clip margins 16–53px under the E2E Chromium). The five ``.cell-clip`` families under test (phase 156 task 02): the Sources ``Hidden`` checkbox label + ``Ignore paths`` + ``Remove``, the RAG folder table's ``Edit``, the History ``Create link`` (the ``Copy``/``Unshare`` shared-state variants share the class family and aria idiom) + ``Delete``. House conventions: a fresh page per test via the session ``browser`` fixture (the conftest ``page`` is 1280×800), ``e2e.auth_helpers.login`` for the real form login, ``_wait_settled_admin`` copied from ``test_mobile_hamburger_nav.py`` (suites stay self-contained — helpers copied, only ``auth_helpers`` imported), ``_cookies`` copied from ``test_api_tokens.py``. Data: each test seeds a FIXED 16-row fixture (16 git sources with deterministic ``e2e.example.com`` URLs — the folder table lists registered sources even with 0 documents, the tree endpoint's "always present" contract — plus 16 saved chats with deterministic 45-char titles) and deletes it in ``finally`` (the idempotent seed/delete keeps re-runs green; no KB documents are imported, so no house KB reset is needed). The 45-char chat title and the source's deterministic URL are what the aria assertions read back. """ from __future__ import annotations import httpx import pytest from playwright.sync_api import Browser, Page, ViewportSize, expect from e2e.auth_helpers import login DESKTOP: ViewportSize = {"width": 1280, "height": 800} # the conftest page size AUDIT: ViewportSize = {"width": 1009, "height": 800} # the audit's ~1,009px case MOBILE: ViewportSize = {"width": 375, "height": 812} # the phase-86 phone viewport #: The fixed fixture size — 16 rows overflow the 560px desktop panel #: (measured table heights 835–1178px) and the 568px mobile panel. N_ROWS = 16 SOURCE_PREFIX = "https://e2e.example.com/table-polish/" CHAT_PREFIX = "e2e polish chat " #: The three catalogue tables: view path, the ``.table-wrap`` card, #: the row signal (admin settle + render), the table ``min-width``, #: and the mobile nav link (PLAN §7.1). TABLES = { "rag": { "path": "/sources.html", "wrap": "#folders-wrap", "row": "#folders-tbody tr", "min_width": 480, # .kb-folders-table (the phase-97 override of the 640px .docs-table base) "nav": "#nav-sources", }, "sources": { "path": "/git-sources.html", "wrap": "#git-sources-table-wrap", "row": "#git-sources-tbody tr", "min_width": 560, "nav": "#nav-git-sources", }, "history": { "path": "/history.html", "wrap": "#history-table-wrap", "row": "#history-table-wrap .history-title-link", "min_width": 640, "nav": "#nav-history", }, } def _chat_title(i: int) -> str: """The deterministic 45-char chat title (16 prefix + 2 index + 27 pad).""" return (CHAT_PREFIX + f"{i:02d}" + "y" * 27)[:45] def _source_url(i: int) -> str: return f"{SOURCE_PREFIX}{i:02d}-repo.git" def _cookies(page: Page) -> dict[str, str]: """The session cookies the browser context holds (copied from ``test_api_tokens.py``).""" return { c["name"]: c["value"] for c in page.context.cookies() if "name" in c and "value" in c } def _wait_settled_admin(page: Page) -> None: """Wait until whoami has resolved for the admin: the whoami reveal has un-hidden the admin-only nav links (copied from ``test_mobile_hamburger_nav.py``).""" page.wait_for_function( "() => !document.querySelector('#nav-sources').hasAttribute('hidden')", timeout=10_000, ) def _seed(page: Page, app_url: str) -> dict[str, dict[str, str]]: """The fixed 16-row fixture (idempotent — a half-finished previous run's rows are reused, not duplicated): 16 git sources (the Sources table rows + the RAG folder-table rows — the tree lists registered sources even with 0 documents) + 16 saved chats (the History rows).""" ck = _cookies(page) sources: dict[str, str] = { s["url"]: s["id"] for s in httpx.get(f"{app_url}/api/git-sources", cookies=ck, timeout=10).json()["sources"] if s["url"].startswith(SOURCE_PREFIX) } for i in range(1, N_ROWS + 1): url = _source_url(i) if url not in sources: r = httpx.post( f"{app_url}/api/git-sources", json={"kind": "git", "url": url}, cookies=ck, timeout=10, ) assert r.status_code == 201, r.text sources[url] = r.json()["id"] chats: dict[str, str] = { c["title"]: c["id"] for c in httpx.get(f"{app_url}/api/chats", cookies=ck, timeout=10).json()["chats"] if c["title"].startswith(CHAT_PREFIX) } for i in range(1, N_ROWS + 1): title = _chat_title(i) if title not in chats: r = httpx.post( f"{app_url}/api/chats", json={ "title": title, "messages": [ {"who": "user", "text": f"question {i}"}, {"who": "brain", "text": f"answer {i}"}, ], }, cookies=ck, timeout=10, ) assert r.status_code == 201, r.text chats[title] = r.json()["id"] return {"sources": sources, "chats": chats} def _cleanup(page: Page, app_url: str, data: dict[str, dict[str, str]] | None) -> None: """Delete the fixture rows (a row another cleanup pass already removed is a 404, not a failure).""" if data is None: return ck = _cookies(page) for sid in data["sources"].values(): r = httpx.delete(f"{app_url}/api/git-sources/{sid}", cookies=ck, timeout=30) assert r.status_code in (204, 404), r.text for cid in data["chats"].values(): r = httpx.delete(f"{app_url}/api/chats/{cid}", cookies=ck, timeout=10) assert r.status_code in (204, 404), r.text def _assert_sticky(page: Page, wrap_sel: str, label: str) -> None: """THE sticky pin: the panel is overflowed (precondition), the scroll moved (``scrollTop > 0``), and the ``th``'s top edge is EXACTLY at the panel's content top — the boundingBox delta (``thTop − wrapTop − borderTop``) is 0 within sub-pixel precision. The counterfactual guard: without the sticky rule the ``th`` would sit the whole ``scrollTop`` above the panel top — a delta below −0.5 IS "scrolled away".""" rep = page.evaluate( """(ws) => { const w = document.querySelector(ws); const t = w.querySelector('table'); const th = w.querySelector('table thead th'); w.scrollTop = 400; const thTop = th.getBoundingClientRect().top; const wTop = w.getBoundingClientRect().top; const border = parseFloat(getComputedStyle(w).borderTopWidth); return {top: w.scrollTop, clientH: w.clientHeight, tableH: t.scrollHeight, delta: thTop - wTop - border}; }""", wrap_sel, ) assert rep["tableH"] > rep["clientH"], ( f"{label}: the 16-row fixture must overflow the bounded panel " f"(table {rep['tableH']}px in a {rep['clientH']}px panel) — " "the scroll precondition does not hold" ) assert rep["top"] > 0, ( f"{label}: the panel's vertical scroll did not move " f"(scrollTop {rep['top']}) — the bounded-panel contract is not in effect" ) assert abs(rep["delta"]) < 0.5, ( f"{label}: the sticky header must sit EXACTLY at the panel's content top " f"(th top − panel content top = {rep['delta']}px while scrollTop is " f"{rep['top']}px) — phase 156 task 01's sticky rule; a large negative " "delta means the header scrolled away with the page (the audit's defect)" ) def _assert_hscroll(page: Page, wrap_sel: str, min_width: int, label: str) -> None: """The in-card horizontal contract: the table keeps its ``min-width`` and scrolls INSIDE the (bounded) card (``overflow: auto``).""" rep = page.evaluate( """(ws) => { const w = document.querySelector(ws); return {scroll: w.scrollWidth, client: w.clientWidth, table: w.querySelector('table').scrollWidth}; }""", wrap_sel, ) assert rep["table"] >= min_width, ( f"{label}: the table must keep its {min_width}px min-width, " f"got {rep['table']}px" ) assert rep["scroll"] > rep["client"], ( f"{label}: the {min_width}px table must scroll INSIDE the card " f"(card {rep['client']}px < table {rep['table']}px)" ) @pytest.mark.parametrize("view", ["rag", "sources", "history"]) def test_sticky_header_on_vertical_scroll( view: str, browser: Browser, app_url: str, db_ready: None ) -> None: """THE audit's sticky pin, per catalogue table (1280×800 — the panel's ``max-height: min(70vh, 64rem)`` resolves to 560px): the 16-row fixture overflows the panel (asserted), the scroll moves (``scrollTop > 0`` after a 400px request — clamped to the max), and the ``thead th``'s top edge is EXACTLY at the panel's content top (the boundingBox delta is 0 within sub-pixel precision — WITHOUT phase 156's rule it would be ~−400px: the header scrolls away with the page, the audit's defect).""" spec = TABLES[view] page = browser.new_page(viewport=DESKTOP) data: dict[str, dict[str, str]] | None = None try: login(page, app_url, next="/") _wait_settled_admin(page) # Seed BEFORE the view loads: the views fetch once on load and # do not refetch — arriving before the fixture would render # the pre-seed state (the rows the pins need never appear). data = _seed(page, app_url) page.goto(app_url + spec["path"]) _wait_settled_admin(page) page.wait_for_selector(spec["row"], timeout=15_000) _assert_sticky(page, spec["wrap"], f"{spec['path']} (desktop)") finally: if data is not None: _cleanup(page, app_url, data) page.close() def test_horizontal_scroll_contract_unchanged( browser: Browser, app_url: str, db_ready: None ) -> None: """The phase-97/86 contract (the ``min-width`` → in-card scroll) survives phase 156's ``overflow: auto``: at 375×812 (the 346px card) every catalogue table's ``min-width`` (480 / 560 / 640) is wider than the card and scrolls INSIDE it (``wrap.scrollWidth > wrap.clientWidth``) — the ``overflow: auto`` change (superseding ``overflow-x: auto``) must keep the horizontal axis intact.""" page = browser.new_page(viewport=MOBILE) data: dict[str, dict[str, str]] | None = None try: login(page, app_url, next="/") _wait_settled_admin(page) data = _seed(page, app_url) page.goto(app_url + TABLES["history"]["path"]) _wait_settled_admin(page) page.wait_for_selector(TABLES["history"]["row"], timeout=15_000) _assert_hscroll(page, TABLES["history"]["wrap"], 640, "History (375px)") page.goto(app_url + TABLES["sources"]["path"]) _wait_settled_admin(page) page.wait_for_selector(TABLES["sources"]["row"], timeout=15_000) _assert_hscroll(page, TABLES["sources"]["wrap"], 560, "Sources (375px)") page.goto(app_url + TABLES["rag"]["path"]) _wait_settled_admin(page) page.wait_for_selector(TABLES["rag"]["row"], timeout=15_000) _assert_hscroll(page, TABLES["rag"]["wrap"], 480, "RAG (375px)") finally: if data is not None: _cleanup(page, app_url, data) page.close() # --------------------------------------------------------------------------- # 3. The ellipsis hint: computed style + the actual clip + the full # text reachable (the audit's ~1,009px case) # --------------------------------------------------------------------------- #: Force the clip: every row's host controls narrowed to below the #: label's natural size (the measured E2E-Chromium naturals: Hidden #: 44 / Ignore paths 82 / Remove 51+icon / Edit 24+padding / Create #: link 69 / Delete 42 — the forced widths leave 16–53px of clip #: margin, so the clip is deterministic; the exact chain a squeezed #: cell engages — the module docstring's "Why the clip is forced at #: the control" note). FORCE_CLIP_JS = { "sources": """ () => { for (const row of document.querySelectorAll('#git-sources-tbody tr')) { const h = row.querySelector('.git-source-hidden'); const i = row.querySelector('.git-source-ignore'); const r = row.querySelector('.git-source-remove'); if (h) h.style.width = '40px'; if (i) i.style.width = '60px'; if (r) r.style.width = '60px'; } } """, "rag": """ () => { for (const row of document.querySelectorAll('#folders-tbody tr')) { const b = row.querySelector('.kb-summary-edit'); if (b) b.style.width = '30px'; } } """, "history": """ () => { for (const row of document.querySelectorAll('#history-table-wrap tbody tr')) { const c = row.querySelector('.history-share-create'); const u = row.querySelector('.history-unshare'); const d = row.querySelector('.history-delete'); if (c) c.style.width = '60px'; if (u) u.style.width = '60px'; if (d) d.style.width = '50px'; } } """, } #: The per-table span audits — (the clipped span, the row-identity #: element whose text the aria-label must contain, the control #: carrying the aria-label). The sources' "Hidden" label's identity #: rides the CHECKBOX's aria-label (the phase-105 idiom — the label #: itself carries the explanatory ``title``, the phase-156 audit #: note); the RAG's identity is the row's folder link (the phase-97 #: idiom); the History's identity is the chat title (the phase-66 #: idiom). SPAN_AUDITS: dict[str, list[tuple[str, str, str]]] = { "sources": [ ( ".git-source-hidden .cell-clip", ".git-source-url-cell code", ".git-source-hidden input", ), ( ".git-source-ignore .cell-clip", ".git-source-url-cell code", ".git-source-ignore", ), ( ".git-source-remove .cell-clip", ".git-source-url-cell code", ".git-source-remove", ), ], "rag": [ ( ".kb-summary-edit .cell-clip", "td .folder-link", ".kb-summary-edit", ), ], "history": [ ( ".history-share-create .cell-clip", ".history-title-link", ".history-share-create", ), ( ".history-delete .cell-clip", ".history-title-link", ".history-delete", ), ], } def test_ellipsis_on_clipped_label( browser: Browser, app_url: str, db_ready: None ) -> None: """The audit's ~1,009px case, made deterministic (the module docstring's "Why the clip is forced at the control"): at 1,009×800 the row's host controls are narrowed below the label's natural size, and for EVERY clipped ``.cell-clip`` span: (a) the COMPUTED ``text-overflow`` is ``"ellipsis"`` (the deterministic style assertion — never a pixel probe), (b) ``scrollWidth > clientWidth`` (the label is actually clipped), and (c) the control's ``aria-label`` contains the ROW's identity (the source URL / the chat title / the source name — the audit's additions). At least one span per table must clip (the guard that the fixture engages the mechanism). The five ``.cell-clip`` families are all covered (the module docstring's family list).""" page = browser.new_page(viewport=AUDIT) data: dict[str, dict[str, str]] | None = None try: login(page, app_url, next="/git-sources.html") _wait_settled_admin(page) data = _seed(page, app_url) page.goto(app_url + "/git-sources.html") _wait_settled_admin(page) page.wait_for_selector("#git-sources-tbody tr", timeout=15_000) for view in ("sources", "rag", "history"): spec = TABLES[view] page.goto(app_url + spec["path"]) _wait_settled_admin(page) page.wait_for_selector(spec["row"], timeout=15_000) page.evaluate(FORCE_CLIP_JS[view]) rep = page.evaluate( """(audits) => { const rowsSel = audits.rowsSel; const out = []; for (const row of document.querySelectorAll(rowsSel)) { for (const a of audits.items) { const span = row.querySelector(a.spanSel); if (!span) continue; const ident = row.querySelector(a.identitySel); const ariaEl = row.querySelector(a.ariaSel); out.push({ span: span.textContent, clip: span.scrollWidth > span.clientWidth, sw: span.scrollWidth, cw: span.clientWidth, to: getComputedStyle(span).textOverflow, identity: ident ? ident.textContent : '', aria: ariaEl ? (ariaEl.getAttribute('aria-label') || '') : '', }); } } return out; }""", { "rowsSel": ( "#history-table-wrap tbody tr:not([hidden])" if view == "history" else TABLES[view]["row"] ), "items": [ {"spanSel": s, "identitySel": i, "ariaSel": a} for (s, i, a) in SPAN_AUDITS[view] ], }, ) clips = [r for r in rep if r["clip"]] assert clips, ( f"{spec['path']}: the forced-narrow fixture must clip at " f"least one {view} label (the determinism guard) — " f"got {[(r['span'], r['sw'], r['cw']) for r in rep]}" ) for r in rep: if not r["clip"]: continue assert r["to"] == "ellipsis", ( f"{spec['path']}: the clipped '{r['span']}' label must compute " f"text-overflow: ellipsis (got '{r['to']}') — phase 156 task 02" ) assert r["identity"], ( f"{spec['path']}: the row identity could not be read " f"(the aria assertion is vacuous without it)" ) assert r["identity"] in r["aria"], ( f"{spec['path']}: the clipped '{r['span']}' label's full text " f"must stay reachable — its control's aria-label must name the " f"row's identity ({r['identity']!r}), got {r['aria']!r}" ) finally: if data is not None: _cleanup(page, app_url, data) page.close() # --------------------------------------------------------------------------- # 4. The mobile ≤640px contract: the hamburger + the table scroll # survive the bounded panel # --------------------------------------------------------------------------- def test_mobile_contract( browser: Browser, app_url: str, db_ready: None ) -> None: """The PLAN §7.1 mobile regression guard (the phase-86 suite's idiom): at 375×812 a REAL ``#nav-toggle`` click opens the hamburger (``aria-expanded="true"``), a menu row navigates (the menu closes on navigation — the phase-85/88 contract), and in EVERY catalogue view the table still scrolls horizontally inside the bounded panel (``min-width`` vs the 346px card) while the ``thead`` still pins — phase 156's ``max-height`` must not break the mobile scroll model.""" page = browser.new_page(viewport=MOBILE) data: dict[str, dict[str, str]] | None = None try: login(page, app_url, next="/") _wait_settled_admin(page) data = _seed(page, app_url) page.goto(app_url + TABLES["history"]["path"]) _wait_settled_admin(page) page.wait_for_selector(TABLES["history"]["row"], timeout=15_000) # The hamburger opens (the ≤640px nav contract)… page.click("#nav-toggle") expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "true") # …and a menu row navigates, closing the menu. page.click(TABLES["sources"]["nav"]) expect(page).to_have_url(app_url + TABLES["sources"]["path"], timeout=15_000) expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "false") page.wait_for_selector(TABLES["sources"]["row"], timeout=15_000) _assert_hscroll(page, TABLES["sources"]["wrap"], 560, "Sources (mobile)") _assert_sticky(page, TABLES["sources"]["wrap"], "Sources (mobile)") page.click("#nav-toggle") expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "true") page.click(TABLES["rag"]["nav"]) expect(page).to_have_url(app_url + TABLES["rag"]["path"], timeout=15_000) expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "false") page.wait_for_selector(TABLES["rag"]["row"], timeout=15_000) _assert_hscroll(page, TABLES["rag"]["wrap"], 480, "RAG (mobile)") _assert_sticky(page, TABLES["rag"]["wrap"], "RAG (mobile)") page.click("#nav-toggle") expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "true") page.click(TABLES["history"]["nav"]) expect(page).to_have_url(app_url + TABLES["history"]["path"], timeout=15_000) expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "false") page.wait_for_selector(TABLES["history"]["row"], timeout=15_000) _assert_hscroll(page, TABLES["history"]["wrap"], 640, "History (mobile)") _assert_sticky(page, TABLES["history"]["wrap"], "History (mobile)") finally: if data is not None: _cleanup(page, app_url, data) page.close()