Files
brain-of-reese/tests/e2e/test_navbar_refresh.py
T
ducoterra 7fce6572d0
Build and Push Containers / build-and-push-app (push) Successful in 1m45s
Build and Push Containers / build-and-push-db (push) Successful in 13s
feat: phases 77–80 — navbar view refresh, static background, API tokens, history suggestion chips
Single consolidated commit for four completed, validated phases (77, 78,
79, 80). The pipeline run left all work uncommitted because the harness
commits only with PHASE_COMMIT=1 while child executors are forbidden from
committing; the phases themselves all passed validation and moved to
.agents/phases/complete/.

Phase 77 — navbar view refresh
- router.js dispatches bor:view-refresh on re-show / active re-click /
  popstate (gated on wasMounted; first show and boot exempt)
- History / RAG / Sources / Tuning re-fetch on refresh (admin branch);
  Chat deliberately excluded (stream survival)
- History "Refresh" button (admin-only, in-flight disable + status line)
- New story suite tests/e2e/test_navbar_refresh.py (7 tests)

Phase 78 — static background
- Removed the animated glow layers; static 44px grid over the flat --bg
  canvas; default and reduced-motion renders byte-identical
- Updated background/theme E2E suites; removed bg-glow test pins

Phase 79 — API tokens
- api_tokens model + migration 0012; hash-only token service
- Admin tokens API + Tokens admin view; POST /api/token-auth;
  live-revoking require_user on chat / suggestions / document content
- Frontend token gate with localStorage cache; anonymous E2E suites
  migrated to token login
- New story suite tests/e2e/test_api_tokens.py (9 tests)

Phase 80 — history suggestion chips
- last_questions() endpoint with SEED fallback; startNewChat() refetch
- Seed-semantics docs (config.py, .env.example, README)
- Integration state matrix + E2E suite rewritten to the 4 chip states

Also included: phase-76 report artifacts and the repo restore-test-db
skill (previously untracked), scripts/* ruff fixes from phase 77.

Final gate state (phase 80 final pass, covers everything above):
- uv run pytest --cov=app → 1637 passed, 0 failed, app/ coverage 99%
- uv run ruff check . && uv run pyright → clean, 0 errors
- Per-phase story E2E suites green in isolation
2026-09-07 12:39:01 -04:00

630 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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"),
("#nav-sources", "/sources.html", "/api/docs"),
("#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 fixture docs' rows are listed)
# and the chat view is hidden (the stream fills it in the
# background — that persistence IS the phase-76 fix).
expect(page.locator("#docs-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)