"""Phase 55 E2E (Playwright): save by default + anonymous share + action-row layout. TODO.md L3–L6 (owner 2026-08-31, roadmap confirmation — the four items this suite verifies, in the browser): L3 "Share chat should work anonymously without login" L4 "Save shouldn't be a button, every chat should be saved by default" L5 "Need feedback (probably dropdown notification toast) to show share worked" L6 "New Chat and Share buttons should only be vertically stacked when in mobile, otherwise they should be horizontally next to each other" Run in isolation (DB must be up: ``podman compose up -d db``): uv run pytest tests/e2e/test_save_share_ux.py -v --no-cov The owner-locked loop under test (2026-08-31). Phase 79 note: chat is now ``require_user``-gated (only shared chats stay open), so the "signed-out visitor" of the L3/L4 pins signs in first — the mechanism pins (no Save control, silent auto-save, exactly one row, share toast, unshare, layout) are unchanged. * **Auto-save (L4 / A2)** — there is NO Save control in the DOM at any width; the visitor's first question auto-upserts EXACTLY ONE ``saved_chats`` row (the auto-title, both messages) — verified through the admin's ``GET /api/chats`` (the management surface stays admin-only, phase 55 task 01) with no button press anywhere; * **No duplicate across reload (L4)** — a plain reload restores the conversation from localStorage AND the row link (``chatId`` in the ``bor.chat.v1`` record, task 02): a second question updates the SAME row — the title's row count stays one, the message count grows 2 → 4; * **Anonymous share + toast (L3 / L5 / A4)** — the Share pill is visible WITHOUT login; clicking it on a non-empty conversation mints the public link (clipboard path, the inline field on non-secure origins) and raises the top-right toast ("Share link copied.", ``aria-hidden`` visual-only, a single instance, auto-dismiss ~4s); a FRESH incognito context opens ``/shared/`` read-only (title + both bubbles, the phase-51 zero-controls surface); the admin's History Unshare (inline two-step) revokes — the SAME URL then shows the "invalid or revoked" state; * **Layout (L6 / A5)** — desktop 1280×800: ``#new-chat-btn`` and ``#share-chat-btn`` share one horizontal row inside ``.chat-actions`` (overlapping y bands, Share's x beyond New chat's x + width, each pill at intrinsic width — never the full 46rem column); mobile 390×844: stacked vertically (Share below New chat, full-width); 360px wide: no horizontal page overflow; * **Admin still works (A1 sanity)** — the same auto-save machinery fires for a signed-in admin (the write surface is public either way; the row lands in the admin's History). DB isolation: the shared e2e Postgres keeps ``saved_chats`` rows across suites, so every test uses a DISTINCTIVE question (its auto-title is therefore unique), selects rows by auto-title (never absolute counts), ``_reset_db``s first (the KB tables are truncated + re-seeded the house way — deterministic mock embeddings; ``saved_chats`` is never touched by the reset), and deletes the rows it creates in a ``finally`` (admin cookie). """ from __future__ import annotations import asyncio import re import time from pathlib import Path from threading import Thread from typing import Any import httpx from playwright.sync_api import Browser, BrowserContext, 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 from e2e.auth_helpers import login REPO = Path(__file__).resolve().parents[2] FIXTURES = REPO / "tests" / "fixtures" / "docs" MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E" SHARE_URL_RE = re.compile(r"^/shared/[0-9a-f-]{36}$") #: The invalid/revoked card's line (frontend/shared.html, phase 51). INVALID_TEXT = "This share link is invalid or was revoked." #: The toast's clipboard-path line (app.js shareCurrentChat success). TOAST_CLIP_TEXT = "Share link copied." 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 — deterministic mock answers), then optionally re-import fixtures. ``saved_chats`` is deliberately NOT touched: rows persist across suites and every test here cleans up after itself.""" with SessionLocal() as db: db.execute(text("TRUNCATE chunks, documents, query_log, steering_notes")) db.commit() if not seed: return None return _run_in_thread(_import_fixtures(mock_port)) def _ask(page: Page, question: str) -> None: """Send one turn and wait until the grounded answer has fully landed (the ``done`` event restored the Send button).""" page.fill("#message-input", question) page.click("#send-btn") expect(page.locator(".msg.user .bubble").last).to_contain_text(question) expect(page.locator(".msg.brain .bubble").last).to_contain_text( MOCK_ANSWER_MARKER, timeout=30_000 ) expect(page.locator("#send-btn")).to_be_enabled() expect(page.locator("#send-label")).to_have_text("Send") def _admin_cookies(page: Page) -> dict[str, str]: """The signed session cookies the browser holds after a form login — used to call the admin API with plain httpx (the test's API side sees exactly what the signed-in browser sees).""" return { c["name"]: c["value"] for c in page.context.cookies() if "name" in c and "value" in c } def _open_admin(browser: Browser, app_url: str) -> tuple[BrowserContext, Page, dict[str, str]]: """A SECOND, logged-in context — the admin's eyes for the management surface (``GET /api/chats`` list/detail, delete, the History Unshare), which stays admin-only since phase 55 task 01.""" ctx = browser.new_context(viewport={"width": 1280, "height": 800}) pg = ctx.new_page() pg.set_default_timeout(30_000) login(pg, app_url, next="/") return ctx, pg, _admin_cookies(pg) def _chats(app_url: str, cookies: dict[str, str]) -> list[dict[str, Any]]: r = httpx.get(f"{app_url}/api/chats", timeout=10, cookies=cookies) assert r.status_code == 200 return r.json()["chats"] def _auto_title(question: str) -> str: """The phase-50 auto-title convention: the first question, whitespace-collapsed, capped at 120 chars.""" return " ".join(question.split())[:120] def _find_row(rows: list[dict[str, Any]], title: str) -> dict[str, Any] | None: return next((c for c in rows if c["title"] == title), None) def _title_rows(app_url: str, cookies: dict[str, str], title: str) -> list[dict[str, Any]]: """Every row carrying ``title`` — the "exactly one row" pin (never an absolute count: the shared DB keeps other suites' rows).""" return [c for c in _chats(app_url, cookies) if c["title"] == title] def _delete_chat(app_url: str, cookies: dict[str, str], chat_id: str) -> None: """Best-effort row cleanup (a 404 — already deleted — is fine).""" httpx.delete(f"{app_url}/api/chats/{chat_id}", timeout=10, cookies=cookies) def _wait_saved_row( app_url: str, cookies: dict[str, str], title: str, messages: int = 2, ) -> dict[str, Any]: """Wait for the auto-saved row (phase 55: auto-saves are SILENT — A2 — so there is no status line to wait on). The upsert is fire-and-forget from the UI's point of view, so poll the admin list until the row with the conversation's auto-title appears with the expected message count.""" deadline = time.monotonic() + 15 last: dict[str, Any] | None = None while time.monotonic() < deadline: last = _find_row(_chats(app_url, cookies), title) if last is not None and last["message_count"] >= messages: return last time.sleep(0.2) raise AssertionError(f"no auto-saved row for {title!r} (last: {last!r})") def _grant_clipboard(page: Page, app_url: str) -> None: """Grant the async-clipboard permissions on the context. ``http://127.0.0.1`` is a secure context, so ``navigator.clipboard`` exists — but headless Chromium still requires the permission grant before ``writeText``/``readText`` resolve (without it the owner-locked inline-link fallback fires). The assertion below branches on the API's availability, so a non-secure origin still passes through the fallback branch deterministically. """ page.context.grant_permissions( ["clipboard-read", "clipboard-write"], origin=app_url ) def _wait_toast_dismissed(page: Page, timeout_s: float = 8.0) -> None: """Poll until the toast's ``is-visible`` state class is gone. The auto-dismiss (~4s, A4) drops the class (opacity/transform stay — opacity is not part of Playwright's visibility model, so the class is the honest state pin). The ~8s deadline leaves headroom over the 4s contract without masking a stuck toast.""" deadline = time.monotonic() + timeout_s while time.monotonic() < deadline: visible = page.evaluate( "() => { const t = document.querySelector('.toast');" " return !!(t && t.classList.contains('is-visible')); }" ) if not visible: return time.sleep(0.2) raise AssertionError("the share toast did not auto-dismiss (~4s contract)") # --------------------------------------------------------------------------- # 1. Anonymous auto-save (L4 / A2): no Save control anywhere, one # question → exactly one saved_chats row (auto-title, both messages), # verified through the admin's list — no button press in this test # --------------------------------------------------------------------------- def test_anonymous_auto_save( page: Page, browser: Browser, app_url: str, mock_llm: int, db_ready: None ) -> None: _reset_db(mock_llm, seed=True) page.set_default_timeout(30_000) # Phase 79: chat is require_user-gated — the phase-55 "signed-out # visitor" no longer exists (shared chats are the only open # surface), so the default visitor signs in first; every other pin # (no Save control, silent auto-save, exactly one row) is unchanged. login(page, app_url, next="/") # L4: there is NO Save control at any width — the element is gone # from the DOM (phase 55, task 02), the pills are New chat + Share. expect(page.locator("#save-chat-btn")).to_have_count(0) expect(page.locator("#new-chat-btn")).to_be_visible() expect(page.locator("#share-chat-btn")).to_be_visible() q = "How is my Kubernetes cluster set up? (save-ux-auto)" _ask(page, q) # the ONLY click in this test — no Save press exists admin_ctx, _admin, cookies = _open_admin(browser, app_url) created: str | None = None try: # The admin's management list sees the auto-saved row: the # auto-title, both messages, created by the anonymous visitor. row = _wait_saved_row(app_url, cookies, _auto_title(q), messages=2) assert row["message_count"] == 2, "the auto-saved row holds both messages" assert len(_title_rows(app_url, cookies, _auto_title(q))) == 1, ( "exactly ONE row for the auto-title — the auto-save must not duplicate" ) created = row["id"] # A2 quiet contract: a successful auto-save is SILENT — no toast # (reserved for share) and no error banner on the anonymous page. expect(page.locator(".toast")).to_have_count(0) expect(page.locator("#kb-banner")).to_be_hidden() finally: if created is not None: _delete_chat(app_url, cookies, created) admin_ctx.close() # --------------------------------------------------------------------------- # 2. No duplicate across reload (L4): the row link (chatId in # bor.chat.v1, task 02) survives a plain reload — the second # question updates the SAME row (count one, messages 2 → 4) # --------------------------------------------------------------------------- def test_no_duplicate_row_across_reload( page: Page, browser: Browser, app_url: str, mock_llm: int, db_ready: None ) -> None: _reset_db(mock_llm, seed=True) page.set_default_timeout(30_000) login(page, app_url, next="/") # phase 79: chat is require_user-gated q1 = "How is my Kubernetes cluster set up? (save-ux-reload)" _ask(page, q1) admin_ctx, _admin, cookies = _open_admin(browser, app_url) created: str | None = None try: row = _wait_saved_row(app_url, cookies, _auto_title(q1), messages=2) chat_id: str = row["id"] created = chat_id # Plain reload: the conversation restores from localStorage # (both bubbles) — and the row link restores with it. page.reload() expect(page.locator(".msg.user .bubble").last).to_contain_text(q1) expect(page.locator(".msg.brain .bubble").last).to_contain_text( MOCK_ANSWER_MARKER, timeout=30_000 ) # The second question must UPDATE the same row — not spawn a # second one (the unlinked-upgrade path would create a fresh # row; the chatId persistence is what prevents that). q2 = "Which node runs the Kubernetes control plane? (save-ux-reload)" _ask(page, q2) row = _wait_saved_row(app_url, cookies, _auto_title(q1), messages=4) assert row["id"] == chat_id, "the reloaded conversation kept its row link" assert row["message_count"] == 4, "both turns' four messages landed on the row" assert len(_title_rows(app_url, cookies, _auto_title(q1))) == 1, ( "still exactly ONE row after the reload — no duplicate" ) finally: if created is not None: _delete_chat(app_url, cookies, created) admin_ctx.close() # --------------------------------------------------------------------------- # 3. Anonymous share + toast (L3 / L5 / A4): the Share pill is visible # without login; the click mints the link + the top-right toast; a # fresh incognito context reads it read-only; the admin's History # Unshare revokes the same URL # --------------------------------------------------------------------------- def test_anonymous_share_toast_and_unshare( page: Page, browser: Browser, app_url: str, mock_llm: int, db_ready: None ) -> None: _reset_db(mock_llm, seed=True) page.set_default_timeout(30_000) # Phase 79: the phase-55 "no login anywhere" visitor no longer # exists (chat is gated) — the visitor signs in first; the FRESH # incognito context below (the read-only shared view) stays # anonymous, which is the open surface this story pins. login(page, app_url, next="/") # L3: the Share pill is visible (static markup — the phase-51 # admin-only reveal gate is gone, task 03). share = page.locator("#share-chat-btn") expect(share).to_be_visible() expect(share).to_have_attribute("aria-label", "Share chat") q = "How is my Kubernetes cluster set up? (save-ux-share)" _ask(page, q) # A4: the toast is reserved for share — nothing before the click # (the auto-save that just fired is silent). expect(page.locator(".toast")).to_have_count(0) _grant_clipboard(page, app_url) share.click() # The top-right toast: a SINGLE instance, the visible state class, # the success text, aria-hidden (visual only — #send-status is the # a11y announcer and keeps its phase-51 line). toast = page.locator(".toast") expect(toast).to_have_count(1) expect(toast).to_have_class(re.compile(r"\bis-visible\b"), timeout=15_000) expect(toast).to_have_text(TOAST_CLIP_TEXT) expect(toast).to_have_attribute("aria-hidden", "true") expect(page.locator("#send-status")).to_have_text(TOAST_CLIP_TEXT, timeout=15_000) # A4: it auto-dismisses in ~4s (the class drops; the node stays). _wait_toast_dismissed(page) # Read the link — the clipboard when the origin allows it, else the # inline fallback field (the phase-51 owner-locked branch). if page.evaluate("() => !!navigator.clipboard"): link: str | None = page.evaluate("() => navigator.clipboard.readText()") expect(page.locator(".share-link-fallback")).to_have_count(0) else: field = page.locator(".share-link-fallback") expect(field).to_be_visible() link = field.get_attribute("href") assert link is not None and link.startswith(app_url), f"bad share link: {link!r}" path = link.removeprefix(app_url) assert SHARE_URL_RE.fullmatch(path), f"bad share path shape: {path!r}" admin_ctx, admin, cookies = _open_admin(browser, app_url) anon_ctx: BrowserContext | None = None created: str | None = None try: # A FRESH incognito context: the guest's only credential is the # token in the URL — the conversation renders read-only. anon_ctx = browser.new_context() anon = anon_ctx.new_page() anon.set_default_timeout(30_000) anon.goto(app_url + path) expect(anon.locator("#shared-title")).to_have_text(_auto_title(q)) expect(anon.locator(".msg.user .bubble")).to_have_count(1) expect(anon.locator(".msg.user .bubble")).to_contain_text(q) expect(anon.locator(".msg.brain .bubble")).to_have_count(1) expect(anon.locator(".msg.brain .bubble").first).to_contain_text( MOCK_ANSWER_MARKER, timeout=30_000 ) # The phase-51 zero-controls surface: no composer, no pills, # no Tune/Retry, no button chips — and the guest header. expect(anon.locator("#composer")).to_have_count(0) expect(anon.locator("#save-chat-btn, #share-chat-btn")).to_have_count(0) expect(anon.locator(".tune-btn")).to_have_count(0) expect(anon.locator(".retry-btn")).to_have_count(0) expect(anon.locator("button.suggestion-chip")).to_have_count(0) expect(anon.locator("#sign-in-link")).to_be_visible(timeout=15_000) # The API agrees (the admin's eyes): the guest's auto-saved row # carries the SAME /shared/ link. row = _find_row(_chats(app_url, cookies), _auto_title(q)) assert row is not None, "the guest's auto-saved row must exist" created = row["id"] assert row["share_url"] == path, "the shared row carries the link just used" # The admin revokes from the History page (inline two-step — # no native dialog). admin.goto(app_url + "/history.html") tr = admin.locator( "#history-tbody tr", has=admin.locator(f"a[href='/?chat={created}']") ) expect(tr.locator("button.history-unshare")).to_be_visible(timeout=15_000) tr.locator("button.history-unshare").click() expect(tr.locator(".history-confirm-text")).to_have_text("Unshare?") expect(tr.locator(".history-confirm-yes")).to_be_visible() tr.locator(".history-confirm-yes").click() expect(tr.locator("button.history-share-create")).to_be_visible(timeout=15_000) # The SAME URL is revoked now — in the SAME fresh context. anon.goto(app_url + path) expect(anon.locator("#shared-invalid")).to_be_visible(timeout=15_000) expect(anon.locator("#shared-invalid")).to_contain_text(INVALID_TEXT) expect(anon.locator(".msg")).to_have_count(0) # The API agrees: share_url is ABSENT (the omission rule). r = httpx.get(f"{app_url}/api/chats/{created}", timeout=10, cookies=cookies) assert r.status_code == 200 assert "share_url" not in r.json(), "unshared → share_url must be absent" finally: if anon_ctx is not None: anon_ctx.close() if created is not None: _delete_chat(app_url, cookies, created) admin_ctx.close() # --------------------------------------------------------------------------- # 4. Action-row layout (L6 / A5): horizontal at desktop, stacked at # ≤640px, no horizontal overflow at 360px # --------------------------------------------------------------------------- def test_action_row_layout(page: Page, app_url: str, mock_llm: int, db_ready: None) -> None: _reset_db(mock_llm, seed=True) page.set_default_timeout(30_000) # No conversation needed — the pills are static markup, always # present (the page fixture's viewport is the 1280×800 desktop). page.goto(app_url + "/") new_btn = page.locator("#new-chat-btn") share_btn = page.locator("#share-chat-btn") expect(new_btn).to_be_visible() expect(share_btn).to_be_visible() # Both pills live in ONE .chat-actions row, New chat before Share. expect(page.locator(".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 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" # Desktop (1280×800): one horizontal row — overlapping y bands, # Share to the right of New chat, each pill at its INTRINSIC width # (never the full 46rem chat column). 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 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" ) # Mobile (390×844): stacked vertically — Share BELOW New chat, both # full-width (the ≤640px stretch rule). 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})" ) # Full-width within the column: the stacked pills stretch to the # .chat-actions row (the column's content box — .chat-shell is a # .container, whose getBoundingClientRect includes its padding). row_box = page.locator(".chat-actions").bounding_box() assert row_box is not None assert abs(nb["width"] - sb["width"]) < 2, "the stacked pills share one full width" assert abs(nb["width"] - row_box["width"]) < 2, "the stacked pills stretch the column" # 360px wide: no horizontal page overflow (the two stacked pills + # 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}" # --------------------------------------------------------------------------- # 5. Admin still works (A1 sanity): the same auto-save machinery fires # for a signed-in admin — session or not, the row lands # --------------------------------------------------------------------------- def test_admin_auto_save_still_works( page: Page, app_url: str, mock_llm: int, db_ready: None ) -> None: _reset_db(mock_llm, seed=True) page.set_default_timeout(30_000) login(page, app_url, next="/") expect(page).to_have_url(app_url + "/", timeout=30_000) q = "How is my Kubernetes cluster set up? (save-ux-admin)" _ask(page, q) cookies = _admin_cookies(page) created: str | None = None try: row = _wait_saved_row(app_url, cookies, _auto_title(q), messages=2) assert row["message_count"] == 2, "the admin's auto-saved row holds both messages" created = row["id"] expect(page.locator(".toast")).to_have_count(0) # auto-save is silent (A2) finally: if created is not None: _delete_chat(app_url, cookies, created)