"""Phase 77 E2E (Playwright): navbar re-shows refresh the view's data — the story suite for the phase-77 refresh hook + the History refresh button. Source: TODO.md L3 — "Clicking navbar icons should refresh the relevant page. For example, clicking 'history' doesn't load new history until I refresh. The history page should also have a refresh button." Since the phase-76 shell, a view's data was fetched exactly once, at mount (mount-once, hide-forever) — a History view opened at 10:00 still showed 10:00's data at 10:30. Phase 77 makes every USER-INITIATED re-show of an already-mounted view re-fetch its list: the router dispatches ``bor:view-refresh`` on the view's section (a switch back onto it, a re-click of its own nav link — NO pushState — or back/ forward onto it; the first show and boot never), the four data views (History, RAG, Sources, Tuning) listen and re-run their loads, and the History view gains an explicit Refresh button in its page-head (the owner's second half of L3). The Chat view is deliberately excluded (the in-flight stream and local conversation persist — the phase-76 contract; the unchanged ``test_nav_switch_keeps_stream.py`` suite is the additional control). Run in isolation (DB must be up: ``podman compose up -d db``): uv run pytest tests/e2e/test_navbar_refresh.py -v --no-cov The "freshness" proof (the house pattern from the phase overview): create new backing data via the API AFTER a view has loaded, nav back (or re-click / press Refresh), assert the new row — with the phase-76 canonical same-document sentinel (a ``window`` global set before the nav clicks is still readable after — no document load). Test → story mapping (Playwright Mapping Rule): 1. ``test_reshow_refetches_history`` — THE L3 REPRO: saved chat A visible on History; chat B created via the API while Chat is up; Chat → History: row B appears (replaced list — no duplicates), the window sentinel survives every click (same document). 2. ``test_active_view_reclick_refetches`` — a re-click of the ACTIVE view's own nav link re-fetches (today a no-op): chat C created via the API while History is up; click History AGAIN → C appears, the URL is still /history.html and ``history.length`` is unchanged (no pushState). 3. ``test_history_refresh_button`` — the explicit Refresh control (TODO.md L3's second half): chat D created via the API; click #history-refresh → D appears, #history-status announces "Saved chats refreshed.", the button is disabled while the request is in flight (the house hold-the-request pattern — deterministic, not a race) and re-enabled after (accessible name + keyboard focus pinned too). 4. ``test_popstate_refetches_history`` — back/forward onto an already-mounted view re-fetches: Chat → History (loads) → Chat → create E via the API → ``page.go_back()`` (popstate) → History → E present. 5. ``test_all_four_data_views_refetch_on_reshow`` — one assertion per data view (History, RAG, Sources, Tuning): the GET to the view's list endpoint is exactly +1 on a re-show (the request-log pattern from task 02) — the first show (the mount) is the baseline. 6. ``test_stream_surval_control`` — the phase-76 CONTRACT control with the hook in place: send a question (the mock LLM's ~8 s stream) → mid-stream nav to RAG → back to Chat → the FULL answer completes and the turn settles (one query_log row). The unchanged ``test_nav_switch_keeps_stream.py`` run in isolation is the additional control. """ from __future__ import annotations import asyncio import re import time from collections.abc import Callable from pathlib import Path from threading import Thread from typing import Any import httpx from playwright.sync_api import Locator, Page, Request, 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 from e2e.mock_llm import LONG_ANSWER_END, LONG_ANSWER_LINES REPO = Path(__file__).resolve().parents[2] FIXTURES = REPO / "tests" / "fixtures" / "docs" #: The phase-11 on-topic long-answer phrasing (house pattern, #: test_nav_switch_keeps_stream.py): the honesty gate is HIGH and the #: ~900-word answer streams for ~8–9 s — the guaranteed mid-stream #: window. LONG_QUESTION = "How is my Kubernetes cluster set up? write a long answer" #: The typing indicator is itself a .msg.brain — exclude its bubble. ANSWER = ".msg.brain .bubble:not(.typing)" #: Distinct saved-chat titles per test (the DB is truncated at each #: test's start, so fixed titles stay unambiguous within a test). TITLE_A = "Phase77 re-show A" TITLE_B = "Phase77 re-show B" TITLE_C = "Phase77 re-click C" TITLE_D = "Phase77 refresh button D" TITLE_E = "Phase77 popstate E" # The four data views (task 02's refresh joiners): the nav link, the # view's URL (the pushState target), and the list endpoint the view's # load hits (the request-log assertion's target — the sync/upload # pollers hit OTHER paths and only run while a job is in flight). DATA_VIEWS: tuple[tuple[str, str, str], ...] = ( ("#nav-history", "/history.html", "/api/chats"), # Phase 97: the RAG view's list endpoint is the drill-down tree # (GET /api/docs/tree — the flat /api/docs is the unchanged API # surface, no longer the view's fetch). ("#nav-sources", "/sources.html", "/api/docs/tree"), ("#nav-git-sources", "/git-sources.html", "/api/git-sources"), ("#nav-tuning", "/tuning.html", "/api/steering"), ) # --------------------------------------------------------------------------- # DB reset + API helpers (the house pattern from test_nav_switch_keeps_stream) # --------------------------------------------------------------------------- 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) -> ImportSummary: """House reset + the prompt-shaping tables: steering notes and the KB overview would otherwise append deterministic suffixes to every mock answer and break the exact-text assertions; saved_chats is cleared too (each test creates its own rows via the API).""" with SessionLocal() as db: db.execute( text( "TRUNCATE chunks, documents, query_log, " "steering_notes, kb_overview, saved_chats" ) ) db.commit() return _run_in_thread(_import_fixtures(mock_port)) def _query_log_count() -> int: """The settled-row count over the whole (truncated) log — the house pattern: a row finalizes ONLY when the LLM finished AND the persistence succeeded, so 0 = cancelled, 1 = settled.""" with SessionLocal() as db: return db.execute(text("SELECT count(*) FROM query_log")).scalar_one() def _admin_cookies(page: Page) -> dict[str, str]: """The signed session cookies the browser holds after a form login — 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 _create_chat(app_url: str, cookies: dict[str, str], title: str) -> dict[str, Any]: """POST /api/chats (the public save surface) — the API-side mutation the freshness proofs hinge on: the row is created while the view is NOT showing, so only a re-fetch can surface it.""" r = httpx.post( f"{app_url}/api/chats", timeout=10, cookies=cookies, json={ "title": title, "messages": [ {"who": "user", "text": f"Saved via the API for the phase-77 suite: {title}"}, {"who": "brain", "text": "Saved."}, ], }, ) assert r.status_code == 201, f"POST /api/chats for {title!r}: {r.status_code} {r.text}" return r.json() def _row(page: Page, title: str) -> Locator: """The saved chat's row (the Title cell's Open link text is the title — textContent only, so an exact-text locator is stable).""" return page.locator("#history-tbody a.history-title-link", has_text=title) def _wait_until(page: Page, pred: Callable[[], bool], timeout: float = 15.0) -> None: """Poll ``pred`` until it holds — the tick is ``page.wait_for_timeout`` (NOT a raw ``time.sleep``): the sync API delivers ``page.on`` events only while a Playwright call is in flight, so the request log the predicate reads is drained on every tick (a raw sleep starves the event queue).""" deadline = time.monotonic() + timeout while time.monotonic() < deadline: if pred(): return page.wait_for_timeout(50) raise AssertionError("timeout waiting for the condition") def _get_logger(page: Page) -> list[str]: """Every GET the browser issues (the request-log pattern from task 02 — the re-fetch proof where a cheap row-delta is not available). POST/PUT (auto-saves, form posts) are deliberately not logged: the assertion is about the view's LIST fetch.""" seen: list[str] = [] def on_request(req: Request) -> None: if req.method == "GET": seen.append(req.url) page.on("request", on_request) return seen # --------------------------------------------------------------------------- # Shared stream helpers (mirrored from test_nav_switch_keeps_stream.py — # the phase-76 suite must stay unchanged; this file is the story gate) # --------------------------------------------------------------------------- def _no_error_banner(page: Page) -> None: expect(page.locator('[role="alert"]:visible')).to_have_count(0) def _ask_long(page: Page) -> None: page.fill("#message-input", LONG_QUESTION) page.click("#send-btn") expect(page.locator(".msg.user .bubble").last).to_contain_text(LONG_QUESTION) def _wait_streaming(page: Page, answer: Locator) -> Locator: """Wait until answer text is visibly streaming (a few delta frames rendered — the mid-stream moment, well inside the ~8–9 s stream).""" answer.wait_for(state="visible", timeout=30_000) partial = "" deadline = time.monotonic() + 15 while time.monotonic() < deadline: partial = answer.inner_text() if len(partial.split()) >= 8: break time.sleep(0.05) assert len(partial.split()) >= 8, "no answer deltas before the view switch" # In flight at the switch: the button IS the enabled Stop control. expect(page.locator("#send-label")).to_have_text("Stop") expect(page.locator("#send-btn")).to_have_class(re.compile(r"is-stop")) return answer def _wait_done(page: Page, answer: Locator) -> str: """Wait for the ``done`` settle: the Send button is back and the bubble carries the unique final line — no error banner on the way.""" expect(page.locator("#send-label")).to_have_text("Send", timeout=30_000) expect(page.locator("#send-btn")).not_to_have_class(re.compile(r"is-stop")) expect(answer).to_contain_text(LONG_ANSWER_END, timeout=30_000) _no_error_banner(page) return answer.inner_text() def _assert_full_answer(text: str) -> None: """The bubble carries the FULL mock answer — every one of the 40 numbered steps plus the unique final line (a truncated stream would be missing its tail).""" for i in range(1, LONG_ANSWER_LINES + 1): assert f"Step {i}: configure node-{i}" in text, f"step {i} missing from the answer" assert LONG_ANSWER_END in text # --------------------------------------------------------------------------- # 1. THE L3 REPRO: a switch back onto History re-fetches — the new row # appears (list replaced, no duplicates), same document throughout # --------------------------------------------------------------------------- def test_reshow_refetches_history(page: Page, app_url: str, mock_llm: int, db_ready: None) -> None: """Sign in → create saved chat A via POST /api/chats → nav to History (row A visible) → create chat B via the API while Chat is up → nav to Chat → nav back to History: row B is visible (the re-show re-fetched — before phase 77 the 10:00 list stayed), row A appears EXACTLY ONCE (the re-load replaces the list, never duplicates it), and the window sentinel set before the nav clicks is still readable after (the phase-76 canonical no-document-load proof).""" summary = _reset_db(mock_llm) assert summary is not None and summary.added == 13 page.set_default_timeout(30_000) login(page, app_url, next="/") expect(page.locator("#nav-history")).to_be_visible() cookies = _admin_cookies(page) _create_chat(app_url, cookies, TITLE_A) page.click("#nav-history") expect(page).to_have_url(app_url + "/history.html") expect(_row(page, TITLE_A)).to_be_visible(timeout=15_000) # THE REPRO: B is created while History is NOT showing… page.evaluate("() => { window.__shell_boot = 'phase77'; }") _create_chat(app_url, cookies, TITLE_B) page.click('a.nav-link[href="/"]') expect(page).to_have_url(app_url + "/") # …and the switch back re-fetches: B is visible without a reload. page.click("#nav-history") expect(page).to_have_url(app_url + "/history.html") expect(_row(page, TITLE_B)).to_be_visible(timeout=15_000) # Replaced list, never duplicated: each title EXACTLY ONCE. expect(_row(page, TITLE_A)).to_have_count(1) expect(_row(page, TITLE_B)).to_have_count(1) # Same document through every click (a real navigation would wipe # the window sentinel). assert page.evaluate("() => window.__shell_boot") == "phase77", ( "a real navigation would have wiped the window sentinel — " "the switches must be same-document" ) # --------------------------------------------------------------------------- # 2. A re-click of the ACTIVE view's own nav link re-fetches (no-op # before phase 77) — NO pushState (the URL is already the path) # --------------------------------------------------------------------------- def test_active_view_reclick_refetches( page: Page, app_url: str, mock_llm: int, db_ready: None ) -> None: """With History visible (rows A, B): create C via the API; click the History nav link AGAIN (the active view's own link) → C appears. The URL is still /history.html and history.length is UNCHANGED — the re-click dispatches the refresh event instead of a bare return, and it must not pushState (the URL already IS this view's path).""" summary = _reset_db(mock_llm) assert summary is not None and summary.added == 13 page.set_default_timeout(30_000) login(page, app_url, next="/") cookies = _admin_cookies(page) _create_chat(app_url, cookies, TITLE_A) _create_chat(app_url, cookies, TITLE_B) page.click("#nav-history") expect(page).to_have_url(app_url + "/history.html") expect(_row(page, TITLE_B)).to_be_visible(timeout=15_000) length_before = page.evaluate("() => window.history.length") _create_chat(app_url, cookies, TITLE_C) page.click("#nav-history") # re-click the ACTIVE link expect(page).to_have_url(app_url + "/history.html") expect(_row(page, TITLE_C)).to_be_visible(timeout=15_000) expect(_row(page, TITLE_A)).to_have_count(1) length_after = page.evaluate("() => window.history.length") assert length_after == length_before, ( "the active re-click must NOT pushState — the URL is already " f"this view's path ({length_before} → {length_after})" ) # --------------------------------------------------------------------------- # 3. The explicit Refresh button (TODO.md L3's second half): the # visible, keyboard-reachable control with its in-flight lifecycle # --------------------------------------------------------------------------- def test_history_refresh_button(page: Page, app_url: str, mock_llm: int, db_ready: None) -> None: """The Refresh control (TODO.md L3: "The history page should also have a refresh button."): visible for admin in the page-head, keyboard-reachable (Tab-able, focusable, accessible name), and the in-flight lifecycle — create D via the API; click #history-refresh → D appears, #history-status announces "Saved chats refreshed.", the button is disabled DURING the in-flight request (asserted deterministically: the request is held in the browser via page.route — the house pattern from test_archive_upload_sources) and re-enabled after.""" summary = _reset_db(mock_llm) assert summary is not None and summary.added == 13 page.set_default_timeout(30_000) login(page, app_url, next="/") cookies = _admin_cookies(page) _create_chat(app_url, cookies, TITLE_A) page.click("#nav-history") expect(page).to_have_url(app_url + "/history.html") expect(_row(page, TITLE_A)).to_be_visible(timeout=15_000) btn = page.locator("#history-refresh") expect(btn).to_be_visible() expect(btn).to_be_enabled() # WCAG 2.1 AA basics: the accessible name (icon-only below 640px — # the aria-label carries it in both) + keyboard-reachable. assert btn.get_attribute("aria-label") == "Refresh saved chats" btn.focus() active = page.evaluate("() => document.activeElement && document.activeElement.id") assert active == "history-refresh", "the Refresh button must be keyboard-focusable" # Hold the refresh's GET in the browser so the in-flight state is # observable deterministically instead of racing the fast # localhost round-trip (the house pattern): the flag is armed only # around the button's click. hold = {"on": False} def handle(route: Any) -> None: if hold["on"]: time.sleep(1.0) route.continue_() page.route("**/api/chats", handle) _create_chat(app_url, cookies, TITLE_D) hold["on"] = True btn.click() # In flight (§7.4): the button is disabled — no double-fire. expect(btn).to_be_disabled() hold["on"] = False # Settled: D is visible (the re-fetch replaced the list), the live # region carries the exact success line, the button is re-enabled. expect(_row(page, TITLE_D)).to_be_visible(timeout=15_000) expect(_row(page, TITLE_A)).to_have_count(1) expect(page.locator("#history-status")).to_have_text("Saved chats refreshed.") expect(btn).to_be_enabled() # --------------------------------------------------------------------------- # 4. Back/forward (popstate) onto an already-mounted view re-fetches # --------------------------------------------------------------------------- def test_popstate_refetches_history( page: Page, app_url: str, mock_llm: int, db_ready: None ) -> None: """Chat → History (the mount's own load) → back to Chat → create E via the API → ``page.go_back()`` (popstate onto the already-mounted History view) → E is present: the popstate path flows through switchTo, so it inherits the wasMounted gating — a re-show dispatches bor:view-refresh, the first show never did (E was not visible on the original show).""" summary = _reset_db(mock_llm) assert summary is not None and summary.added == 13 page.set_default_timeout(30_000) login(page, app_url, next="/") cookies = _admin_cookies(page) page.click("#nav-history") # Chat → History (mount + first load) expect(page).to_have_url(app_url + "/history.html") expect(page.locator("#history-table-wrap")).to_be_visible(timeout=15_000) page.click('a.nav-link[href="/"]') # → Chat (pushState) expect(page).to_have_url(app_url + "/") _create_chat(app_url, cookies, TITLE_E) page.go_back() # popstate → /history.html (the already-mounted view) expect(page).to_have_url(app_url + "/history.html") expect(page.locator("#view-history")).not_to_be_hidden() expect(_row(page, TITLE_E)).to_be_visible(timeout=15_000) # --------------------------------------------------------------------------- # 5. All four data views re-fetch on a re-show (the request-log pattern # from task 02 — one assertion per view) # --------------------------------------------------------------------------- def test_all_four_data_views_refetch_on_reshow( page: Page, app_url: str, mock_llm: int, db_ready: None ) -> None: """One assertion per data view (History, RAG, Sources, Tuning): after the first show (the mount — its load is the baseline), a switch back onto the view issues EXACTLY ONE more GET to the view's list endpoint — the re-fetch the phase is about. The windowed counts (before/after the re-show click) make the assertion immune to the boot-time fetches (the header's steering panel loads once at shell boot) and to the sync/upload pollers (other paths, and only while a job is in flight). For History the row-delta rides along: a chat created before the re-show appears after it.""" summary = _reset_db(mock_llm) assert summary is not None and summary.added == 13 page.set_default_timeout(30_000) login(page, app_url, next="/") expect(page.locator("#nav-sources")).to_be_visible() # admin: every link revealed expect(page.locator("#nav-history")).to_be_visible() expect(page.locator("#nav-git-sources")).to_be_visible() expect(page.locator("#nav-tuning")).to_be_visible() cookies = _admin_cookies(page) log = _get_logger(page) def count(endpoint: str) -> int: return sum(1 for u in log if u.endswith(endpoint)) for nav_sel, view_path, endpoint in DATA_VIEWS: # First show (the mount) — wait until the mount's own load # lands in the log (the baseline). page.click(nav_sel) expect(page).to_have_url(app_url + view_path) _wait_until(page, lambda ep=endpoint: count(ep) >= 1) before = count(endpoint) # History: create the row-delta's backing data while hidden. if endpoint == "/api/chats": _create_chat(app_url, cookies, "Phase77 four-view row") # Back to Chat, then a re-show of the view: the refresh event # must issue exactly one more list GET. page.click('a.nav-link[href="/"]') expect(page).to_have_url(app_url + "/") page.click(nav_sel) expect(page).to_have_url(app_url + view_path) _wait_until(page, lambda b=before, ep=endpoint: count(ep) >= b + 1) assert count(endpoint) == before + 1, ( f"{nav_sel}: a re-show must re-fetch exactly once " f"({before} → {count(endpoint)} GETs to {endpoint})" ) if endpoint == "/api/chats": expect(_row(page, "Phase77 four-view row")).to_be_visible(timeout=15_000) # --------------------------------------------------------------------------- # 6. The phase-76 CONTRACT control: with the refresh hook in place, an # in-flight stream still survives a mid-stream nav switch # --------------------------------------------------------------------------- def test_stream_survival_control(page: Page, app_url: str, mock_llm: int, db_ready: None) -> None: """The phase-76 contract holds with the hook in place: send a question (the mock LLM's ~8 s stream) → mid-stream nav to RAG (a re-show-capable, LISTENING view — the hook is live) → back to Chat → the FULL answer completes and the turn settles (one query_log row). The Chat view never listens for bor:view-refresh (the negative unit pin) — its in-flight SSE reader and local conversation persist through every switch. The unchanged tests/e2e/test_nav_switch_keeps_stream.py run in isolation is the additional control.""" summary = _reset_db(mock_llm) assert summary is not None and summary.added == 13 page.set_default_timeout(30_000) login(page, app_url, next="/") expect(page.locator("#nav-sources")).to_be_visible() _ask_long(page) answer = _wait_streaming(page, page.locator(ANSWER)) # THE SWITCH (mid-stream): the window sentinel set BEFORE the click # is still readable AFTER it — same document. page.evaluate("() => { window.__shell_boot = 'phase77'; }") page.click("#nav-sources") expect(page).to_have_url(app_url + "/sources.html") assert page.evaluate("() => window.__shell_boot") == "phase77", ( "a real navigation would have wiped the window sentinel" ) # The RAG view actually showed (the source rows are listed — phase # 97: the top level lists the sources; the file table is per-level # and hidden at the top) and the chat view is hidden (the stream # fills it in the background — that persistence IS the phase-76 fix). expect(page.locator("#folders-tbody tr").first).to_be_visible(timeout=15_000) expect(page.locator("#view-chat")).to_be_hidden() # Stay on RAG while the stream keeps running, then return to Chat. page.wait_for_timeout(2000) page.click('a.nav-link[href="/"]') expect(page).to_have_url(app_url + "/") assert page.evaluate("() => window.__shell_boot") == "phase77" # The answer COMPLETED — the FULL mock answer, no error banner — # and the turn SETTLED (one query_log row; a cancelled turn would # leave none). done_text = _wait_done(page, answer) _assert_full_answer(done_text) assert _query_log_count() == 1, "the completed turn must finalize exactly one settled row" # --------------------------------------------------------------------------- # 7. Sanity: the API-side rows the suite creates carry the bor.chat.v1 # record shape (guards _create_chat against a schema drift that # would silently change what History renders) # --------------------------------------------------------------------------- def test_api_created_chats_carry_the_bor_chat_v1_shape( page: Page, app_url: str, mock_llm: int, db_ready: None ) -> None: """The suite's API-side mutation helper (POST /api/chats) produces rows in the exact bor.chat.v1 record shape the History table and the ?chat= restore path render: who/text messages, the message count, and no stray keys (the schema's extra=forbid rejects them at the boundary with a 422 — a 201 here proves the shape) — and History renders the row (the Open link's text IS the title).""" summary = _reset_db(mock_llm) assert summary is not None and summary.added == 13 page.set_default_timeout(30_000) login(page, app_url, next="/") cookies = _admin_cookies(page) created = _create_chat(app_url, cookies, "Phase77 shape check") r = httpx.get(f"{app_url}/api/chats/{created['id']}", timeout=10, cookies=cookies) assert r.status_code == 200 body = r.json() assert body["title"] == "Phase77 shape check" assert [m["who"] for m in body["messages"]] == ["user", "brain"] assert body["message_count"] == 2 # The bor.chat.v1 record shape: who/text present; only the schema's # optional keys may accompany them (explicit nulls are preserved — # the plain model_dump round-trips byte-identical). allowed = {"who", "text", "sources", "deflected", "suggestions", "thinking", "tools", "stopped"} assert all({"who", "text"} <= set(m) <= allowed for m in body["messages"]) # The History row renders it (the Open link's text IS the title). page.click("#nav-history") expect(page).to_have_url(app_url + "/history.html") expect(_row(page, "Phase77 shape check")).to_be_visible(timeout=15_000)