"""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): * **Save** — on the chat page, admin-only (the pill ships hidden and whoami reveals it): the current conversation POSTs to ``/api/chats`` (auto-title = the first question, whitespace-collapsed, 120-char cap) and links to the created row; a re-Save PUTs the SAME row (upsert); "New chat" unlinks, so the next Save creates again; * **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 button, 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 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" #: Phase 10 viewer URL + phase 13 back=/ (the restored chip must be #: byte-identical to the live-rendered one). CHIP_HREF = "/document.html?source=docs&path=homelab%2Fkubernetes.md&back=%2F" 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 _save(page: Page) -> None: """Press Save and wait for the live-region confirmation (the never-stale contract: the status line is the success feedback).""" page.locator("#save-chat-btn").click() expect(page.locator("#send-status")).to_have_text("Conversation saved.") # --------------------------------------------------------------------------- # 1. Save on the chat page → the row exists (UI + API agree) # --------------------------------------------------------------------------- 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) # Admin: the Save pill is revealed (ship-hidden, whoami reveals it). save = page.locator("#save-chat-btn") expect(save).to_be_visible() expect(save).to_have_attribute("aria-label", "Save chat") save.click() expect(page.locator("#send-status")).to_have_text("Conversation saved.") 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 = _find_row(_chats(app_url, cookies), " ".join(q.split())[:120]) assert row is not None, "the saved chat row must exist" 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() _save(page) cookies = _admin_cookies(page) row = _find_row(_chats(app_url, cookies), q) assert row is not None 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 its source chip restored byte-identically. chip = page.locator(".msg.brain .source-chip", has_text="kubernetes.md") expect(chip).to_have_count(1) expect(chip.first).to_have_attribute("href", CHIP_HREF) # 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 a re-Save UPSERTS: the same single row, count grown to 4. _save(page) mine = [c for c in _chats(app_url, cookies) if c["title"] == q] assert len(mine) == 1, "the re-Save must not spawn a second row" assert mine[0]["id"] == chat_id, "the re-Save updates the SAME row" assert mine[0]["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) _save(page) cookies = _admin_cookies(page) cleanup: list[str] = [] try: row1 = _find_row(_chats(app_url, cookies), q1) assert row1 is not None 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, saved: 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) _save(page) 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) _save(page) cookies = _admin_cookies(page) row = _find_row(_chats(app_url, cookies), q) assert row is not None 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 button, 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 pill, # no History nav link (both ship hidden and stay hidden). expect(page.locator("#save-chat-btn")).to_be_hidden() 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