All completion criteria verified — everything is green, no defects found. Final report: ## Phase 97 final verification pass — ALL GREEN **Verified (no code changes needed):** - `GET /api/docs/tree` (admin), `build_kb_tree` pure builder, `PATCH /api/folders/summary`, migration 0018 (`manually_edited`, head confirmed), generator skip/keep + `kept_manual` stat, RAG tree UI + edit affordance in `sources.js`/`index.html`/`styles.css` - `tests/e2e/test_kb_tree.py`: 8 passed — top level, drill source/folder, edit round-trip, clear, manual-desc-survives-sync, reload fallback, anonymous gate - Integration: tree shape/order/403/empty/indexed-only + PATCH update/create/root/clear/404/403/no-LLM + stat-walk equivalence (in `test_docs_api.py`); 3-field `folder_summaries=` import token preserved **Gates (exact commands):** - `uv run pytest --cov=app --cov-report=term-missing` → **2053 passed**, TOTAL coverage **99%** (>90% ✓) - `uv run ruff check . && uv run pyright` → **All checks passed / 0 errors** - `uv run pytest tests/e2e/test_kb_tree.py -v --no-cov` → **8 passed** in isolation - 30 story/RAG-view E2E suites run **one per process**: all passed, incl. `test_ls_tree_drilldown` (agent `ls` byte-identical ✓), `test_import_documents`, `test_edit_summaries`, `test_admin_auth`, `test_kb_overview` **Completion criteria:** tree view ✓ · edit round-trip + clear ✓ · manual persists/clear resets ✓ · `ls` unchanged ✓ · pytest/coverage/lint ✓ · E2E isolation ✓ · commit — left to harness per protocol (working tree untouched, `git add/commit` not run) **Deviations:** none. **Next pending phase:** none — `todo/` contains only 97 (96 already committed).
634 lines
28 KiB
Python
634 lines
28 KiB
Python
"""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)
|