"""Phase 65 E2E (Playwright): the chat action cluster is the pinned bottom. Source: ``TODO.md`` L3 — "Move the new chat and share button to the tune/retry/save doc cluster area so it's always at the bottom of the screen and easily accessble. Make the button clusters look better, neater, more aligned" (no user story file — TODO-derived phase). Run in isolation (DB must be up: ``podman compose up -d db``): uv run pytest tests/e2e/test_bottom_chat_actions.py -v --no-cov Mock-only, no admin login (chat is public). The owner-locked contracts (2026-09-01, roadmap confirmation) under test — observed in a real browser: * **A1 — the pinned bottom cluster:** the ``.chat-bottom`` wrapper (the ``.chat-actions`` row + the ``#composer``, ONE sticky unit — the LAST child of ``.chat-shell``; ``position: sticky; bottom: env(safe-area-inset-bottom, 0)``, NO z-index) is fully inside the viewport with its bottom flush with the viewport bottom at EVERY scroll position inside the sticky range — the New chat + Share pills are literally always at the bottom of the screen, even while the reader is scrolled up through a long conversation — and at the document bottom the whole unit settles back into normal flow above the ``.app-footer`` (never floating over it); * **A2 — the right-aligned bottom row:** on desktop the row hugs the column's RIGHT edge (mirroring the right-aligned "Save as doc" corner of every brain bubble above — the bottom-right of the chat reads as one aligned action column); at ≤640px the row is a full-width vertical stack (alignment is moot when stretched); * **the click-through survived the move:** ``#new-chat-btn`` (header.js → the ``bor:new-chat`` window event → the app.js reset) clears the conversation (``.msg`` count 0, ``#empty-state`` back, ``bor.chat.v1`` gone from localStorage, "New chat started" in ``#send-status``); ``#share-chat-btn`` on the now-empty chat is the guarded no-op (app.js ``shareCurrentChat``: "Nothing to share yet.", no toast, no navigation). Determinism: the mock's grounded answers quote the question and end in the ``Deterministic mock answer for E2E`` marker; overflow is built from real UI turns (6 short grounded turns — the house ``SHORT_QUESTIONS`` phrasing, the same overflow recipe as ``test_pinned_composer.py``); all scrolling is done by the tests via ``window.scrollTo`` (the phase-42 never-auto-scroll contract — the app, and the phase-65 sticky wrapper, add no scroll call site). Test → contract mapping (Playwright Mapping Rule): 1. ``test_empty_chat_row_rests_at_screen_bottom`` — the resting A1/A2 geometry on a fresh page (static markup, no KB seed) 2. ``test_bottom_cluster_pinned_at_every_scroll_position`` — A1 across the whole sticky range + the settle into flow 3. ``test_row_geometry_and_alignment`` — A2 geometry + the A5 DOM order + the mobile stack + the 360px overflow bound + the 44px touch targets 4. ``test_buttons_still_work_from_the_bottom`` — the click-through contracts (New chat reset, Share no-op) """ from __future__ import annotations import asyncio from collections.abc import Iterator from pathlib import Path from threading import Thread from typing import Any import pytest from playwright.sync_api import Page, expect from sqlalchemy import text from app.config import Settings from app.db import SessionLocal from app.rag.importer import ImportSummary, import_sources from app.rag.llm import LLMClient REPO = Path(__file__).resolve().parents[2] FIXTURES = REPO / "tests" / "fixtures" / "docs" MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E" STORAGE_KEY = "bor.chat.v1" #: Six short grounded questions (the house phrasing + on-topic variants #: — the honesty gate is HIGH for all of them, so every turn renders an #: answer bubble + source chips). Six turns overflow the 800px viewport #: by a wide margin; the tests assert the overflow, so the count can #: never silently stop being enough. SHORT_QUESTIONS = ( "How is my Kubernetes cluster set up?", "How do my backups work?", "How did I install gitlab?", "How does my homelab networking work?", "What is in the new-service deployment?", "What scripts do I have in the homelab?", ) #: Tolerance for "flush with the viewport bottom": `env(safe-area-inset- #: bottom)` resolves to 0 on a desktop/notched-free viewport, so the only #: slack is sub-pixel rounding (the same headroom `test_pinned_composer.py` #: allows the composer). FLUSH_PX = 4 #: The resting row must sit in the LOWER part of the screen — the pre-phase #: 65 geometry had it at the very top of the column (the exact defect this #: phase removes). LOWER_PART = 0.75 # The typing indicator is itself a .msg.brain — exclude its bubble. ANSWER = ".msg.brain .bubble:not(.typing)" # --------------------------------------------------------------------------- # KB seeding (same pattern as the phase 42/52/55 story suites) # --------------------------------------------------------------------------- async def _import_fixtures(mock_port: int) -> ImportSummary: kwargs: dict[str, Any] = {"_env_file": None, "llm_base_url": f"http://127.0.0.1:{mock_port}/v1"} settings = Settings(**kwargs) # pyright: ignore[reportCallIssue] return await import_sources([FIXTURES], LLMClient(settings)) def _run_in_thread(coro: Any) -> Any: """Run a coroutine on a worker thread. Playwright's sync API keeps an asyncio loop running on the test thread, so ``asyncio.run`` cannot be called directly from a test body. """ box: dict[str, Any] = {} def runner() -> None: try: box["value"] = asyncio.run(coro) except BaseException as e: # noqa: BLE001 — re-raised on the test thread box["error"] = e t = Thread(target=runner) t.start() t.join() if "error" in box: raise box["error"] return box["value"] def _reset_db(mock_port: int, seed: bool) -> ImportSummary | None: """Truncate the KB (and query log, steering notes, saved chats — fully deterministic per test), then optionally re-import fixtures. ``saved_chats`` IS truncated here (unlike the phase-55 suite, which keeps rows across its tests): the click-through tests below touch the auto-save path, so each test starts from an empty table.""" with SessionLocal() as db: db.execute(text("TRUNCATE chunks, documents, query_log, steering_notes, saved_chats")) db.commit() if not seed: return None return _run_in_thread(_import_fixtures(mock_port)) @pytest.fixture() def seeded_kb(mock_llm: int, db_ready: None) -> Iterator[None]: """A fresh KB seeded from ``tests/fixtures/docs`` (13 docs, A9 formats), truncated again on teardown. ``db_ready`` (conftest) skips with clear instructions when Postgres is down.""" summary = _reset_db(mock_llm, seed=True) assert summary is not None and summary.added == 13 yield _reset_db(mock_llm, seed=False) # --------------------------------------------------------------------------- # Measurement + flow helpers # --------------------------------------------------------------------------- def scroll_state(page: Page) -> dict[str, float]: """The document scroller's state (there is no inner scroll container).""" return page.evaluate( "() => ({ y: window.scrollY, " "sh: document.documentElement.scrollHeight, " "ch: window.innerHeight })" ) def wait_settled(page: Page, timeout: int = 30_000) -> None: """The turn is over: the label is back to "Send" (phase 48 — the in-flight state is the enabled Stop control, so the label carries the wait).""" expect(page.locator("#send-btn")).to_be_enabled(timeout=timeout) expect(page.locator("#send-label")).to_have_text("Send", timeout=timeout) def submit(page: Page, question: str) -> None: """Submit through the composer (the real-user flow).""" page.fill("#message-input", question) page.click("#send-btn") expect(page.locator(".msg.user .bubble").last).to_contain_text(question) def build_conversation(page: Page, n: int = 6) -> None: """Settle ``n`` short grounded turns through the UI (the overflow is real conversation content, not injected DOM).""" for question in SHORT_QUESTIONS[:n]: submit(page, question) wait_settled(page) def box_of(page: Page, selector: str) -> dict[str, float]: """The selector's viewport-relative box as a plain dict. Measured WITHOUT scrolling (``bounding_box`` never scrolls the page), so the answer is "where the box sits where the user left it" — exactly the question this phase asks (always at the bottom). """ rect = page.locator(selector).bounding_box() assert rect is not None, f"{selector} must be rendered (no bounding box)" return { "x": rect["x"], "y": rect["y"], "width": rect["width"], "height": rect["height"], } def column_right_edge(page: Page) -> float: """The right edge of the chat column's CONTENT (A2's reference edge). ``.chat-shell`` is a ``.container`` (``padding-inline`` 1.25rem desktop / 0.9rem ≤640px), so its bounding box includes the padding — but the column's visible content (every bubble, the meta rows, the bottom row) ends at the padding edge. Measured live so the breakpoint's padding value is never hard-coded.""" return page.evaluate( "() => {" " const s = document.querySelector('.chat-shell');" " const r = s.getBoundingClientRect();" " return r.right - parseFloat(getComputedStyle(s).paddingRight);" " }" ) def assert_cluster_flush_with_viewport_bottom(page: Page) -> dict[str, float]: """The ``.chat-bottom`` unit is FULLY inside the viewport with its bottom edge at the viewport bottom (± ``FLUSH_PX``), and the row AND the composer are each fully visible — the pills are literally always at the bottom of the screen (A1) at every reading position. This is the whole story: nothing in the cluster hides behind the fold while the reader is scrolled up.""" box = box_of(page, ".chat-bottom") ch = scroll_state(page)["ch"] assert box["y"] >= -FLUSH_PX, ( f"the pinned cluster is clipped at the TOP of the viewport " f"(y={box['y']:.1f}, viewport={ch:.0f})" ) bottom = box["y"] + box["height"] assert bottom <= ch + FLUSH_PX, ( f"the cluster runs BELOW the viewport bottom " f"(bottom={bottom:.1f}, viewport={ch:.0f})" ) assert bottom >= ch - FLUSH_PX, ( f"the cluster is not pinned to the bottom edge " f"(bottom={bottom:.1f}, viewport={ch:.0f})" ) # The unit's two members are each fully visible too — the row is # the contract (the pills), the composer rides along with it. row = box_of(page, ".chat-actions") composer = box_of(page, "#composer") for name, b in ((".chat-actions", row), ("#composer", composer)): assert b["y"] >= -FLUSH_PX and b["y"] + b["height"] <= ch + FLUSH_PX, ( f"{name} is not fully inside the viewport at a reading position " f"(box={b}, viewport={ch:.0f})" ) return box # --------------------------------------------------------------------------- # 1. Resting geometry: a fresh chat is not scrollable and the row rests # in the lower part of the screen, directly above the composer, # hugging the column's right edge (A2) # --------------------------------------------------------------------------- def test_empty_chat_row_rests_at_screen_bottom( page: Page, app_url: str, db_ready: None ) -> None: page.set_default_timeout(30_000) page.goto(app_url) # Exactly ONE pinned unit, holding exactly the row + the composer — # the static markup of the new bottom cluster (no KB seed needed). expect(page.locator(".chat-bottom")).to_have_count(1) cluster = page.locator(".chat-bottom") expect(cluster.locator(".chat-actions")).to_have_count(1) expect(cluster.locator("#composer")).to_have_count(1) # A fresh visitor: the empty state, nothing to scroll — the wrapper # must not invent scrollable space (the phase-52 contract, kept: # the wrapper adds no height the row did not already carry). expect(page.locator("#empty-state")).to_be_visible() state = scroll_state(page) assert state["sh"] <= state["ch"] + 1, ( f"an empty chat must not be scrollable (sh={state['sh']:.0f}, " f"ch={state['ch']:.0f}) — the wrapper must not invent scrollable space" ) ch = state["ch"] row = box_of(page, ".chat-actions") composer = box_of(page, "#composer") share = box_of(page, "#share-chat-btn") # The row rests in the LOWER part of the screen ... (pre-phase-65 it # sat at the top of the column and scrolled off-screen immediately). assert row["y"] + row["height"] >= ch * LOWER_PART, ( f"the resting row is at {row['y'] + row['height']:.0f}px of a " f"{ch:.0f}px viewport — it has to sit at the bottom of the screen" ) # ... directly ABOVE the composer (the unit's 0.5rem gap sits # between them — no overlap, no dead band). assert row["y"] + row["height"] <= composer["y"] + FLUSH_PX, ( f"the row must sit directly above the composer (row={row}, " f"composer={composer})" ) # A2: the row hugs the column's right edge — the row box spans the # column's content width, and the rightmost pill (Share) is flush # with that edge (justify-content: flex-end — a left-aligned group # would leave a dead band on the right). right = column_right_edge(page) assert abs(row["x"] + row["width"] - right) <= 2, ( f"the row's right edge ({row['x'] + row['width']:.1f}) does not align " f"with the column's right edge ({right:.1f})" ) assert abs(share["x"] + share["width"] - right) <= 2, ( f"the rightmost pill is not flush with the column's right edge " f"(share right={share['x'] + share['width']:.1f}, column={right:.1f})" ) # The composer is not clipped below the viewport (it rests in flow, # above the footer — never hanging off the bottom of the screen). assert composer["y"] + composer["height"] <= ch + FLUSH_PX, ( f"the resting composer hangs below the viewport bottom " f"(bottom={composer['y'] + composer['height']:.1f}, viewport={ch:.0f})" ) # --------------------------------------------------------------------------- # 2. The A1 pin: an over-viewport conversation keeps the whole cluster # (row + composer) flush with the viewport bottom at every scroll # position, settling into flow above the footer at the document bottom # --------------------------------------------------------------------------- def test_bottom_cluster_pinned_at_every_scroll_position( page: Page, app_url: str, seeded_kb: None ) -> None: page.set_default_timeout(30_000) page.goto(app_url) build_conversation(page, n=len(SHORT_QUESTIONS)) # The phase's precondition: a conversation longer than the viewport # (the reader has content to scroll through — the moment the old # top-of-column row used to leave the screen). state = scroll_state(page) assert state["sh"] > state["ch"] + 200, ( f"the conversation must overflow the viewport (sh={state['sh']:.0f}, " f"ch={state['ch']:.0f}) — more turns are needed" ) # Scrolled to the very top (the "reading the beginning" position): # the WHOLE cluster is inside the viewport, flush with its bottom — # the pills are reachable without any scrolling. page.evaluate("() => window.scrollTo(0, 0)") assert scroll_state(page)["y"] <= 1 assert_cluster_flush_with_viewport_bottom(page) # And at every stop through the sticky range. The range is bounded by # the cluster's CONTAINING BLOCK (`.chat-shell` — the wrapper's # parent): the pin holds while that column's bottom edge sits below # the viewport bottom (the same math as # `test_pinned_composer.py::test_composer_pinned_at_every_scroll_position`). limits = page.evaluate( "() => ({ shellBottom: document.querySelector('.chat-shell')" ".getBoundingClientRect().bottom + window.scrollY, " "sh: document.documentElement.scrollHeight, ch: window.innerHeight })" ) max_scroll = limits["sh"] - limits["ch"] pin_limit = limits["shellBottom"] - limits["ch"] assert pin_limit > 200, ( f"the conversation must keep the cluster inside its sticky range " f"for at least a screen of scrolling (limit={pin_limit:.0f})" ) assert max_scroll > pin_limit, ( "the document bottom must leave the sticky range, so the settle " "back into flow is covered too" ) for y in (0, pin_limit * 0.25, pin_limit * 0.5, pin_limit * 0.75, pin_limit - 1): page.evaluate("y => window.scrollTo(0, y)", y) assert abs(scroll_state(page)["y"] - y) <= 1, f"the test scroll to {y} must land" assert_cluster_flush_with_viewport_bottom(page) # Settled at the document bottom: the unit has returned to its NORMAL # FLOW position — above the footer, not hovering over it, the composer # no longer glued to the viewport edge, the row still directly above it. page.evaluate("() => window.scrollTo(0, document.documentElement.scrollHeight)") assert abs(scroll_state(page)["y"] - max_scroll) <= 1, "must land at the document bottom" ch = scroll_state(page)["ch"] cluster = box_of(page, ".chat-bottom") row = box_of(page, ".chat-actions") composer = box_of(page, "#composer") footer = box_of(page, ".app-footer") # the footer must be rendered assert cluster["y"] + cluster["height"] <= footer["y"] + FLUSH_PX, ( f"the settled cluster overlaps the footer (cluster={cluster}, " f"footer={footer}) — the pin must not float the unit over chrome" ) assert composer["y"] + composer["height"] < ch - FLUSH_PX, ( "at the document bottom the composer settles into flow — it is no " "longer glued to the viewport edge" ) assert row["y"] + row["height"] <= composer["y"] + FLUSH_PX, ( "in its settled slot the row must still sit directly above the " f"composer (row={row}, composer={composer})" ) # The conversation itself is intact (6 turns, all answers landed) — # the pin changed the geometry, not the content. expect(page.locator(".msg.user .bubble")).to_have_count(len(SHORT_QUESTIONS)) expect(page.locator(".msg.brain .bubble")).to_have_count(len(SHORT_QUESTIONS)) expect(page.locator(".msg.brain .bubble").last).to_contain_text(MOCK_ANSWER_MARKER) # --------------------------------------------------------------------------- # 3. Row geometry + alignment (A2): ONE row with the two pills in the A5 # DOM order — horizontal + right-hugging at desktop, full-width stack # at ≤640px, no horizontal overflow at 360px, 44px touch targets # --------------------------------------------------------------------------- def test_row_geometry_and_alignment( page: Page, app_url: str, seeded_kb: None ) -> None: page.set_default_timeout(30_000) # No conversation needed for the row — static markup, always present # (the page fixture's viewport is the 1280×800 desktop). page.goto(app_url) # Exactly ONE .chat-actions, inside the pinned unit, holding exactly # the two pills — and the A5 DOM order: New chat, then Share (the # same compareDocumentPosition check as test_save_share_ux.py). expect(page.locator(".chat-actions")).to_have_count(1) expect(page.locator(".chat-bottom .chat-actions")).to_have_count(1) row_el = page.locator(".chat-actions") assert row_el.locator("#new-chat-btn").count() == 1 assert row_el.locator("#share-chat-btn").count() == 1 expect(page.locator("#new-chat-btn")).to_have_count(1) expect(page.locator("#share-chat-btn")).to_have_count(1) assert page.evaluate( "() => {" " const n = document.getElementById('new-chat-btn');" " const s = document.getElementById('share-chat-btn');" " return !!(n && s && (n.compareDocumentPosition(s) & Node.DOCUMENT_POSITION_FOLLOWING));" " }" ), "the DOM order must be New chat, then Share" new_btn = page.locator("#new-chat-btn") share_btn = page.locator("#share-chat-btn") # Desktop (1280×800): one horizontal row — overlapping y bands, # Share to the right of New chat, each pill at its INTRINSIC width # (never stretched), and the row hugging the column's RIGHT edge (A2). nb = new_btn.bounding_box() sb = share_btn.bounding_box() assert nb is not None and sb is not None assert nb["y"] < sb["y"] + sb["height"] and sb["y"] < nb["y"] + nb["height"], ( f"the pills must share one horizontal row (new={nb}, share={sb})" ) assert sb["x"] > nb["x"] + nb["width"], "Share must sit right of New chat" column_w = page.evaluate( "() => document.querySelector('.chat-shell').getBoundingClientRect().width" ) assert nb["width"] < column_w / 2 and sb["width"] < column_w / 2, ( "each pill must keep its intrinsic width on desktop, not stretch the column" ) row_box = page.locator(".chat-actions").bounding_box() assert row_box is not None right = column_right_edge(page) assert abs(row_box["x"] + row_box["width"] - right) <= 2, ( f"the row's right edge ({row_box['x'] + row_box['width']:.1f}) does not " f"align with the column's right edge ({right:.1f}) — A2" ) assert abs(sb["x"] + sb["width"] - right) <= 2, ( f"the rightmost pill is not flush with the column's right edge " f"(share right={sb['x'] + sb['width']:.1f}, column={right:.1f}) — A2" ) # Mobile (390×844): a vertical stack — Share BELOW New chat, both # pills the SAME width, each stretched to the unit's full content # width (the ≤640px stack rule — alignment is moot when stretched). page.set_viewport_size({"width": 390, "height": 844}) nb = new_btn.bounding_box() sb = share_btn.bounding_box() assert nb is not None and sb is not None assert sb["y"] > nb["y"] + nb["height"], ( f"the pills must stack at 390px, Share below New chat (new={nb}, share={sb})" ) cluster_box = page.locator(".chat-bottom").bounding_box() assert cluster_box is not None assert abs(nb["width"] - sb["width"]) <= 2, "the stacked pills share one full width" # .chat-bottom carries no padding/border — its box width IS the # unit's content width the pills must fill. assert abs(nb["width"] - cluster_box["width"]) <= 2, ( "the stacked New chat pill must stretch the unit's full width" ) assert abs(sb["width"] - cluster_box["width"]) <= 2, ( "the stacked Share pill must stretch the unit's full width" ) # 360px wide: no horizontal page overflow (the two stacked pills + # the container padding must fit). page.set_viewport_size({"width": 360, "height": 800}) scroll_w = page.evaluate("() => document.documentElement.scrollWidth") assert scroll_w <= 360, f"horizontal overflow at 360px: scrollWidth={scroll_w}" # Touch targets: the bottom cluster's pills render ≥44px tall at BOTH # widths — and so does the .retry-btn the app injects on the last # brain bubble (one seeded turn makes it exist; the ghost family # shares the 44px floor with the solid family). page.set_viewport_size({"width": 1280, "height": 800}) submit(page, SHORT_QUESTIONS[0]) wait_settled(page) expect(page.locator(ANSWER).last).to_contain_text(MOCK_ANSWER_MARKER) expect(page.locator(".retry-btn")).to_have_count(1) for selector in ("#new-chat-btn", "#share-chat-btn", ".retry-btn"): assert box_of(page, selector)["height"] >= 44, ( f"{selector} must keep the 44px touch floor at 1280px" ) page.set_viewport_size({"width": 390, "height": 844}) for selector in ("#new-chat-btn", "#share-chat-btn", ".retry-btn"): assert box_of(page, selector)["height"] >= 44, ( f"{selector} must keep the 44px touch floor at 390px" ) # --------------------------------------------------------------------------- # 4. The click-through survived the move: New chat resets the # conversation from the bottom cluster; Share on the empty chat is # the guarded no-op # --------------------------------------------------------------------------- def test_buttons_still_work_from_the_bottom( page: Page, app_url: str, seeded_kb: None ) -> None: page.set_default_timeout(30_000) page.goto(app_url) # One real conversation: the buttons have something to act on (and # the auto-save path fires — the row is cleaned up by _reset_db). submit(page, SHORT_QUESTIONS[0]) wait_settled(page) expect(page.locator(".msg")).to_have_count(2) expect(page.locator(ANSWER).last).to_contain_text(MOCK_ANSWER_MARKER) # New chat (header.js → the `bor:new-chat` window event → the app.js # reset): the conversation is cleared from the DOM, from localStorage, # and the a11y announcer reports the reset — from its new home in the # bottom cluster (the binding is id-based and position-independent). page.click("#new-chat-btn") expect(page.locator(".msg")).to_have_count(0) expect(page.locator("#empty-state")).to_be_visible() assert page.evaluate(f"() => localStorage.getItem('{STORAGE_KEY}')") is None, ( "the cleared conversation must be gone from localStorage (bor.chat.v1)" ) expect(page.locator("#send-status")).to_contain_text("New chat started") # Share on the now-empty chat: the guarded no-op (app.js # shareCurrentChat) — the a11y status line, no toast, no navigation. url_before = page.url page.click("#share-chat-btn") expect(page.locator("#send-status")).to_contain_text("Nothing to share yet.") expect(page.locator(".toast")).to_have_count(0) assert page.url == url_before, "the empty-chat share no-op must not navigate"