"""Phase 50 E2E (Playwright): save & view chat history. TODO.md L5 (owner 2026-08-29): "Need a way to save and view chat history in a new page, then return to that history with a click" Run in isolation (DB must be up: ``podman compose up -d db``): uv run pytest tests/e2e/test_chat_history.py -v --no-cov The owner-locked loop under test (A10 extension, 2026-08-29; phase 55 replaced the Save pill with auto-save — the tests below wait for the auto-saved row via the admin list, since auto-saves are SILENT, A2): * **Auto-save** — on the chat page, no control (the pill is GONE, phase 55): the current conversation upserts itself at the save points — create on the first user message (auto-title = the first question, whitespace-collapsed, 120-char cap) and update on each brain-done; the SAME row updates (upsert — the conversation never spawns a second row, the link survives reloads); "New chat" unlinks, so the next conversation creates a fresh row; * **History** — ``/history.html`` lists the saved chats in a full-width table (Title | Messages | Updated | Actions); the Title cell IS the Open link (``/?chat=`` — "return to that history with a click"), and Delete is the inline two-step confirm (owner-locked: no native confirm dialog — a real ``window.confirm`` would hang Playwright, so the inline pair appearing is itself pinned); * **Open** — ``/?chat=`` (valid uuid + admin) boots into the saved conversation through the SAME restore path as the phase-14 local session (pixel-identical), links it, and a subsequent Save updates that row; a deleted/unknown id degrades to the local restore with the error banner; * **Anonymous** — no Save control (the element is absent from the DOM at every width — phase 55), but the Share pill IS visible (phase 55 task 03 — the write surface is public, the pill is static markup), no History nav link, the History page shows the gated state WITHOUT ever fetching ``/api/chats`` (the router 403s them — pinned via the request log), and the API 403s. DB isolation: the shared e2e Postgres keeps ``saved_chats`` rows across suites, so every test here uses a DISTINCTIVE question text (its auto-title is therefore unique), never asserts on absolute row counts, and deletes the rows it creates in a ``finally`` (admin cookie). The KB tables are truncated + re-seeded the house way (deterministic mock embeddings); ``saved_chats`` is never touched by the reset. """ from __future__ import annotations import asyncio import time from pathlib import Path from threading import Thread from typing import Any import httpx 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 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" 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 _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 _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 _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})") # --------------------------------------------------------------------------- # 1. Auto-save on the chat page (no Save control) → the row exists # (API is the proof — auto-saves are silent, A2) # --------------------------------------------------------------------------- def test_save_and_see_history( 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? (hist-save)" _ask(page, q) # Phase 55: there is NO Save control — the conversation auto-saved # at the save points (create on the first question, update on the # brain-done). There is nothing to click and no status line to wait # on (A2 silent): the API is the proof. # Also: the pill is gone from the DOM at every width. expect(page.locator("#save-chat-btn")).to_have_count(0) cookies = _admin_cookies(page) created: str | None = None try: # The API agrees: the row exists, auto-titled from the first # question (whitespace-collapsed, <=120 chars), two messages. row = _wait_saved_row(app_url, cookies, " ".join(q.split())[:120]) assert row["message_count"] == 2 created = row["id"] # The History page shows it: the row for THIS chat carries the # auto-title and the message count. page.goto(app_url + "/history.html") link = page.locator(f"#history-tbody a[href='/?chat={created}']") expect(link).to_be_visible(timeout=15_000) expect(link).to_have_text(" ".join(q.split())[:120]) row_tr = page.locator( "#history-tbody tr", has=page.locator(f"a[href='/?chat={created}']") ) expect(row_tr.locator(".history-count-cell")).to_have_text("2") finally: if created is not None: _delete_chat(app_url, cookies, created) # --------------------------------------------------------------------------- # 2. History → click the title → back in the saved conversation; the # conversation continues and a re-Save updates the SAME row # --------------------------------------------------------------------------- def test_open_chat_returns_to_history( 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? (hist-open)" _ask(page, q) # The answer text the History session saw (rendered bubble). answer_before = page.locator(".msg.brain .bubble").first.inner_text() # Phase 55: the conversation auto-saved (no Save pill) — wait for # the row via the admin list. cookies = _admin_cookies(page) row = _wait_saved_row(app_url, cookies, q) chat_id = row["id"] try: # From the History page, the title IS the Open link… page.goto(app_url + "/history.html") link = page.locator(f"#history-tbody a[href='/?chat={chat_id}']") expect(link).to_be_visible(timeout=15_000) doc_requests: list[str] = [] page.on( "request", lambda r: doc_requests.append(r.url) if r.resource_type == "document" else None, ) link.click() # …and the click navigates to /?chat= ("return to that # history with a click"). app.js then normalizes the one-shot # ?chat= param back to /, so the navigation target itself is # what gets pinned here. assert any(u == app_url + "/?chat=" + chat_id for u in doc_requests), ( f"the title link must navigate to /?chat={chat_id}: {doc_requests}" ) # The chat rendered the saved conversation… expect(page.locator(".msg.user .bubble")).to_have_count(1) expect(page.locator(".msg.user .bubble")).to_contain_text(q) bubble = page.locator(".msg.brain .bubble").first expect(bubble).to_contain_text(q) expect(bubble).to_contain_text(MOCK_ANSWER_MARKER) # …the SAME answer text the History session saw (pixel-identical # restore through renderStoredMessage)… assert bubble.inner_text() == answer_before # …with ZERO citation chips restored — the saved turn read # nothing, so its sources list is empty (phase 119, LOCKED A1; # the retired phase-118 A4 suggested-chip pin is gone). # The conversation continues: a new turn streams fine… _ask(page, "How is my Kubernetes cluster set up? (hist-open-2)") expect(page.locator(".msg.user .bubble")).to_have_count(2) # …and the next brain-done auto-save UPSERTS: the same single # row, count grown to 4 (phase 55 — no Save pill). row2 = _wait_saved_row(app_url, cookies, q, messages=4) mine = [c for c in _chats(app_url, cookies) if c["title"] == q] assert len(mine) == 1, "the auto-save must not spawn a second row" assert mine[0]["id"] == chat_id, "the auto-save updates the SAME row" assert row2["message_count"] == 4 finally: _delete_chat(app_url, cookies, chat_id) # --------------------------------------------------------------------------- # 3. "New chat" unlinks: the next Save is a fresh create, not an update # of the previously saved conversation # --------------------------------------------------------------------------- def test_new_chat_unlinks( 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) q1 = "How is my Kubernetes cluster set up? (hist-unlink)" _ask(page, q1) cookies = _admin_cookies(page) cleanup: list[str] = [] try: row1 = _wait_saved_row(app_url, cookies, q1) # auto-saved (phase 55) cleanup.append(row1["id"]) # New chat clears the conversation AND unlinks it from the row. page.locator("#new-chat-btn").click() expect(page.locator("#send-status")).to_contain_text("New chat started") expect(page.locator(".msg")).to_have_count(0) # A fresh conversation, auto-saved (phase 55): a NEW row (a # create, not the previous row's update) — the list now carries # two of ours. q2 = "How is my Kubernetes cluster set up? (hist-unlink-2)" _ask(page, q2) _wait_saved_row(app_url, cookies, q2) # wait for the fresh auto-save rows = _chats(app_url, cookies) mine = [c for c in rows if c["title"] in (q1, q2)] assert len(mine) == 2, "Save after New chat must create a second row" a = _find_row(rows, q1) b = _find_row(rows, q2) assert a is not None and b is not None assert a["id"] != b["id"], "the fresh Save must not reuse the old row" assert a["message_count"] == 2, "the unlinked conversation was untouched" cleanup.append(b["id"]) finally: for chat_id in cleanup: _delete_chat(app_url, cookies, chat_id) # --------------------------------------------------------------------------- # 4. Delete: the inline two-step confirm (no native dialog), the row is # gone (the API 404s), and /?chat= degrades to the local restore # --------------------------------------------------------------------------- def test_delete_two_step( 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? (hist-delete)" _ask(page, q) cookies = _admin_cookies(page) row = _wait_saved_row(app_url, cookies, q) # auto-saved (phase 55) chat_id = row["id"] try: page.goto(app_url + "/history.html") tr = page.locator( "#history-tbody tr", has=page.locator(f"a[href='/?chat={chat_id}']") ) expect(tr.locator("a.history-title-link")).to_be_visible(timeout=15_000) # Step 1: the Delete button swaps, IN PLACE, for the "Delete? # [Yes] [No]" pair — a real window.confirm would hang Playwright # here, so the pair appearing is the pinned contract. tr.locator("button.history-delete").click() expect(tr.locator(".history-confirm-yes")).to_be_visible() expect(tr.locator(".history-confirm-no")).to_be_visible() # "No" cancels: the pair is gone, the Delete button returns, the # row stays. tr.locator(".history-confirm-no").click() expect(tr.locator(".history-confirm-yes")).to_have_count(0) expect(tr.locator("button.history-delete")).to_be_visible() expect(page.locator(f"#history-tbody a[href='/?chat={chat_id}']")).to_have_count(1) # Step 2: Delete → "Yes" removes the row + the live-region line. tr.locator("button.history-delete").click() tr.locator(".history-confirm-yes").click() expect(page.locator(f"#history-tbody a[href='/?chat={chat_id}']")).to_have_count(0) expect(page.locator("#history-status")).to_contain_text(f'Deleted "{q}".') # The API agrees: the id is unknown now. r = httpx.get(f"{app_url}/api/chats/{chat_id}", timeout=10, cookies=cookies) assert r.status_code == 404 # Degradation: /?chat= shows the error banner and # falls through to the local restore (this context's # localStorage still holds the conversation from the ask above). page.goto(app_url + f"/?chat={chat_id}") banner = page.locator("#kb-banner") expect(banner).to_be_visible(timeout=15_000) expect(banner).to_contain_text("That saved chat isn't available") expect(page.locator(".msg.user .bubble")).to_contain_text(q) expect(page.locator(".msg.brain .bubble").first).to_contain_text( MOCK_ANSWER_MARKER, timeout=15_000 ) finally: # No-op when the delete above succeeded (the 404 is handled). _delete_chat(app_url, cookies, chat_id) # --------------------------------------------------------------------------- # 5. Anonymous: no Save control (absent), the Share pill visible # (phase 55 task 03), no History nav link, the History page is # gated WITHOUT fetching /api/chats, and the API 403s # --------------------------------------------------------------------------- def test_anonymous_cannot( page: Page, app_url: str, mock_llm: int, db_ready: None ) -> None: _reset_db(mock_llm, seed=True) page.set_default_timeout(30_000) # A fresh context is anonymous by construction (no login). requests: list[str] = [] page.on("request", lambda r: requests.append(r.url)) page.goto(app_url + "/") # Settled anonymous state (the whoami round-trip has landed)… expect(page.locator("#sign-in-link")).to_be_visible(timeout=15_000) # …and the phase-50 surface is absent for anonymous: no Save control # (the element is GONE from the DOM — phase 55 — there is nothing # to hide) and no History nav link (ships hidden and stays hidden) # — but the Share pill IS visible (phase 55 task 03: the write # surface is public, the pill is static markup). expect(page.locator("#save-chat-btn")).to_have_count(0) expect(page.locator("#share-chat-btn")).to_be_visible() expect(page.locator("#nav-history")).to_be_hidden() # Direct visit to the History page: it loads and shows the gated # state (the table wrapped away) — and NEVER calls /api/chats (the # router 403s anonymous, so the page must not even try). page.goto(app_url + "/history.html") expect(page.locator("#history-gate")).to_be_visible(timeout=15_000) expect(page.locator("#history-table-wrap")).to_be_hidden() assert not any("/api/chats" in u for u in requests), ( f"the anonymous History page must not fetch /api/chats: {requests}" ) # And the API gate itself: 403 without the admin cookie. r = httpx.get(f"{app_url}/api/chats", timeout=10) assert r.status_code == 403