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
526 lines
23 KiB
Python
526 lines
23 KiB
Python
"""Phase 76 E2E (Playwright): in-app view switches never halt a
|
||
generating answer — the owner repro, pinned against the deterministic
|
||
mock LLM.
|
||
|
||
Source: owner repro, verified in a real browser 2026-09-06 — send a
|
||
question → click **RAG** in the navbar mid-stream → click **Chat** →
|
||
the answer never finished (every navbar view was a separate document,
|
||
so the navbar click was a REAL cross-document navigation: the chat
|
||
page unloaded, the in-flight fetch was aborted, and the phase-48
|
||
teardown cancelled the turn — no ``query_log`` row, a dangling
|
||
question on return). Phase 76 folded the five navbar views into ONE
|
||
HTML shell: a navbar click is a CLIENT-SIDE view switch
|
||
(``history.pushState`` + show/hide), so the in-flight SSE reader in
|
||
the hidden chat view keeps streaming and the answer COMPLETES when
|
||
the user returns.
|
||
|
||
Run in isolation (DB must be up: ``podman compose up -d db``):
|
||
|
||
uv run pytest tests/e2e/test_nav_switch_keeps_stream.py -v --no-cov
|
||
|
||
Timing is deterministic by construction: the mock's ``write a long
|
||
answer`` trigger streams a ~5400-char answer at 12 chars / 0.02 s
|
||
(~8–9 s of content), so the mid-stream switch window is wide.
|
||
|
||
The "same document" proof (the canonical pattern from the phase
|
||
overview): a ``window`` sentinel set before the nav click is still
|
||
readable after the switch — a real document load would wipe ``window``
|
||
globals. The ``performance.getEntriesByType("navigation")`` length is
|
||
deliberately NOT used: a real load resets that counter to 1 in the
|
||
fresh document, so it cannot distinguish pushState from a reload.
|
||
|
||
Test → story mapping (Playwright Mapping Rule):
|
||
1. ``test_rag_switch_mid_stream_completes`` — THE OWNER REPRO: send →
|
||
RAG mid-stream → Chat; the FULL answer completes, ``bor.chat.v1``
|
||
holds EXACTLY ONE brain turn, the server SETTLED the turn (one
|
||
``query_log`` row — no phase-48 ``turn cancelled``), and the
|
||
auto-saved row (admin, ``persistConversation``) carries the same
|
||
single full turn.
|
||
2. ``test_every_nav_view_keeps_stream`` — the same mid-stream switch
|
||
against the other three views (Sources/git-sources, Tuning,
|
||
History): one send, one switch, one return, full answer + settled
|
||
``query_log`` row each time.
|
||
3. ``test_real_departure_still_cancels`` — the phase-48 CONTROL (the
|
||
locked contract survives the phase): a genuine cross-document
|
||
departure (``/shared.html`` — a stable document for a signed-in
|
||
session; ``/login.html`` is deliberately avoided because it
|
||
auto-redirects a signed-in admin straight back into the shell)
|
||
still aborts the fetch, leaves NO ``query_log`` row, and the
|
||
page-20/73 leave-save lands the partial in the EXACT shape pinned
|
||
by ``tests/e2e/test_sources_midstream_bug.py::test_partial_answer_
|
||
survives_real_departure_midstream`` (mirrored, not re-invented).
|
||
The overlap with that suite is on purpose — different stories:
|
||
phase 20 pins the partial shape, this phase pins that the
|
||
navbar-switch path no longer cancels while real departures still
|
||
do.
|
||
4. ``test_baseline_no_switch_still_completes`` — the long question
|
||
with NO navigation completes identically (guards against the
|
||
shell fold changing the ordinary path).
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import json
|
||
import re
|
||
import time
|
||
from pathlib import Path
|
||
from threading import Thread
|
||
from typing import Any
|
||
|
||
import httpx
|
||
from playwright.sync_api import Locator, 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
|
||
from e2e.mock_llm import LONG_ANSWER_END, LONG_ANSWER_LINES, long_answer
|
||
|
||
REPO = Path(__file__).resolve().parents[2]
|
||
FIXTURES = REPO / "tests" / "fixtures" / "docs"
|
||
|
||
#: The phase-11 on-topic long-answer phrasing (house pattern,
|
||
#: ``test_hidden_tab_stream.py`` / ``test_stop_generation.py``): the
|
||
#: honesty gate is HIGH and the ~900-word answer streams for ~8–9 s
|
||
#: (12 chars / 0.02 s) — the guaranteed mid-stream window.
|
||
LONG_QUESTION = "How is my Kubernetes cluster set up? write a long answer"
|
||
#: The mock's byte-stable long answer — the EXACT string the stream
|
||
#: delivers, so "the full answer" is an exact comparison, not a
|
||
#: contains check.
|
||
LONG_ANSWER = long_answer()
|
||
|
||
#: The mock's first 12-char content slice (the same cut ``_sse_stream``
|
||
#: makes) — the real-departure partial must START with it (raw text,
|
||
#: pre-render); the rendered first line keeps the list-item form (the
|
||
#: markdown renderer converts the "1. " marker into a list item,
|
||
#: pinned by test_long_answers).
|
||
FIRST_CHUNK_RAW = re.findall(r".{1,12}", LONG_ANSWER, re.S)[0]
|
||
FIRST_LINE_DOM = "Step 1: configure node-1"
|
||
|
||
STORAGE_KEY = "bor.chat.v1"
|
||
#: The typing indicator is itself a .msg.brain — exclude its bubble.
|
||
ANSWER = ".msg.brain .bubble:not(.typing)"
|
||
|
||
#: The other three navbar views (test 2): the nav link, the view's
|
||
#: URL (pushState target), and an admin-visible content marker inside
|
||
#: the view (proof the view actually showed — the RAG view gets the
|
||
#: same treatment with ``#docs-tbody tr`` in test 1).
|
||
OTHER_VIEWS: tuple[tuple[str, str, str], ...] = (
|
||
("#nav-git-sources", "/git-sources.html", "#git-sources-content"),
|
||
("#nav-tuning", "/tuning.html", "#tune-save"),
|
||
("#nav-history", "/history.html", "#history-table-wrap"),
|
||
)
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# KB seeding (house pattern: TRUNCATE-then-import)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
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."""
|
||
with SessionLocal() as db:
|
||
db.execute(
|
||
text("TRUNCATE chunks, documents, query_log, steering_notes, kb_overview")
|
||
)
|
||
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 query log finalizes a row ONLY when the LLM finished AND the
|
||
persistence succeeded (phase 48); a cancelled turn — a real
|
||
departure mid-stream, or one before the first token — leaves no
|
||
settled row, so the count IS the settled-row signal (0 = cancelled,
|
||
1 = settled; the house pattern from test_hidden_tab_stream.py).
|
||
"""
|
||
with SessionLocal() as db:
|
||
return db.execute(text("SELECT count(*) FROM query_log")).scalar_one()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Shared flows
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _stored_parsed(page: Page) -> dict[str, Any]:
|
||
raw = page.evaluate(f"() => localStorage.getItem('{STORAGE_KEY}')")
|
||
assert raw is not None, "the conversation key must exist in localStorage"
|
||
return json.loads(raw)
|
||
|
||
|
||
def _no_error_banner(page: Page) -> None:
|
||
"""The never-stale contract, shell-scoped: the hidden views ship
|
||
their own role=alert surfaces (sync/upload banners, …) that are
|
||
inert while their view is hidden — so the pin is that NO alert is
|
||
VISIBLE, whatever the document carries hidden (the phase-20
|
||
rewrite's shell form)."""
|
||
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
|
||
|
||
|
||
def _assert_one_brain_turn(page: Page) -> dict[str, Any]:
|
||
"""``bor.chat.v1`` holds EXACTLY ONE brain turn for the question,
|
||
and its text is the COMPLETE mock answer byte-for-byte (the
|
||
``done`` settle's record — the settle, not a partial)."""
|
||
stored = _stored_parsed(page)
|
||
assert stored["v"] == 1
|
||
msgs = stored["messages"]
|
||
assert [m["who"] for m in msgs] == ["user", "brain"], (
|
||
"exactly ONE brain turn for the question: "
|
||
f"{[m['who'] for m in msgs]}"
|
||
)
|
||
assert msgs[0]["text"] == LONG_QUESTION
|
||
brain = msgs[1]
|
||
assert brain["text"] == LONG_ANSWER, "the record's text is the FULL answer"
|
||
assert brain.get("deflected") is False, "the done metadata rides the record"
|
||
return brain
|
||
|
||
|
||
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 _delete_rows_by_title(app_url: str, cookies: dict[str, str], title: str) -> None:
|
||
"""Best-effort cleanup of the auto-saved row (a 404 is fine)."""
|
||
r = httpx.get(f"{app_url}/api/chats", timeout=10, cookies=cookies)
|
||
if r.status_code != 200:
|
||
return
|
||
for c in r.json()["chats"]:
|
||
if c["title"] == title:
|
||
httpx.delete(f"{app_url}/api/chats/{c['id']}", timeout=10, cookies=cookies)
|
||
|
||
|
||
def _wait_row_full(app_url: str, cookies: dict[str, str], title: str) -> dict[str, Any]:
|
||
"""Poll the auto-saved row until it carries the full answer as a
|
||
single brain turn (the ``done`` settle's fire-and-forget
|
||
``persistConversation`` PUT is the last writer)."""
|
||
deadline = time.monotonic() + 15
|
||
last: list[dict[str, Any]] = []
|
||
while time.monotonic() < deadline:
|
||
r = httpx.get(f"{app_url}/api/chats", timeout=10, cookies=cookies)
|
||
rows = (
|
||
[c for c in r.json()["chats"] if c["title"] == title]
|
||
if r.status_code == 200
|
||
else []
|
||
)
|
||
for c in rows:
|
||
row = httpx.get(f"{app_url}/api/chats/{c['id']}", timeout=10, cookies=cookies).json()
|
||
brains = [m for m in row["messages"] if m["who"] == "brain"]
|
||
if len(brains) == 1 and brains[0]["text"] == LONG_ANSWER:
|
||
return row
|
||
last = [row]
|
||
time.sleep(0.2)
|
||
raise AssertionError(
|
||
"the auto-saved row never held the full answer as exactly one brain turn; last: "
|
||
f"{last!r}"
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 1. THE OWNER REPRO: send → RAG mid-stream → Chat — the FULL answer
|
||
# completes, one brain turn, one settled query_log row, and the
|
||
# auto-saved row carries the same single full turn
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_rag_switch_mid_stream_completes(
|
||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||
) -> None:
|
||
summary = _reset_db(mock_llm)
|
||
assert summary is not None and summary.added == 13 # A9 formats (phase 47 added quadlet+j2)
|
||
page.set_default_timeout(30_000)
|
||
# Admin login: the admin-only #nav-sources link is revealed, and
|
||
# the auto-save row (the persistConversation path) is reachable,
|
||
# so the saved-chat side gets pinned too.
|
||
login(page, app_url, next="/")
|
||
expect(page.locator("#nav-sources")).to_be_visible()
|
||
cookies = _admin_cookies(page)
|
||
_delete_rows_by_title(app_url, cookies, LONG_QUESTION) # stale rows from crashed runs
|
||
|
||
_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 — the canonical same-document proof
|
||
# (a real navigation would have wiped window globals).
|
||
page.evaluate("() => { window.__shell_boot = 'phase76'; }")
|
||
page.click("#nav-sources")
|
||
expect(page).to_have_url(app_url + "/sources.html")
|
||
assert page.evaluate("() => window.__shell_boot") == "phase76", (
|
||
"a real navigation would have wiped the window sentinel — "
|
||
"the switch must be same-document"
|
||
)
|
||
# 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 fix).
|
||
expect(page.locator("#docs-tbody tr").first).to_be_visible(timeout=15_000)
|
||
expect(page.locator("#view-chat")).to_be_hidden()
|
||
|
||
# Stay on the RAG view while the stream keeps running (the switch
|
||
# is ~t+2 s; the full answer needs ~8–9 s).
|
||
page.wait_for_timeout(2000)
|
||
|
||
# Back to the chat (the header link — a router-intercepted
|
||
# switch, still same-document).
|
||
page.click('a.nav-link[href="/"]')
|
||
expect(page).to_have_url(app_url + "/")
|
||
assert page.evaluate("() => window.__shell_boot") == "phase76"
|
||
|
||
# The answer COMPLETED — the bubble carries the FULL mock answer
|
||
# (every step line + the unique final line), no error banner.
|
||
done_text = _wait_done(page, answer)
|
||
_assert_full_answer(done_text)
|
||
|
||
# Storage: EXACTLY ONE brain turn — the FULL answer with done
|
||
# metadata (the settle, not a partial).
|
||
page.wait_for_timeout(500)
|
||
_assert_one_brain_turn(page)
|
||
|
||
# Server side: the turn SETTLED — exactly one query_log row, so no
|
||
# phase-48 "turn cancelled" teardown fired for an in-app switch.
|
||
assert _query_log_count() == 1, "a completed turn must finalize its query_log row"
|
||
|
||
# Auto-save (admin): the row carries the same single full brain
|
||
# turn (the shared record shape).
|
||
try:
|
||
row = _wait_row_full(app_url, cookies, LONG_QUESTION)
|
||
brains = [m for m in row["messages"] if m["who"] == "brain"]
|
||
assert len(brains) == 1, "the saved row holds exactly one brain turn"
|
||
assert brains[0]["text"] == LONG_ANSWER
|
||
finally:
|
||
_delete_rows_by_title(app_url, cookies, LONG_QUESTION)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 2. The same mid-stream switch against the other three views — one
|
||
# send, one switch, one return, full answer + settled row each time
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_every_nav_view_keeps_stream(
|
||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||
) -> None:
|
||
summary = _reset_db(mock_llm)
|
||
assert summary is not None and summary.added == 13
|
||
page.set_default_timeout(30_000)
|
||
# Admin: every navbar link (incl. the three below) is revealed by
|
||
# the whoami gate.
|
||
login(page, app_url, next="/")
|
||
|
||
for i, (nav_sel, view_path, marker) in enumerate(OTHER_VIEWS, start=1):
|
||
expect(page.locator(nav_sel)).to_be_visible()
|
||
|
||
_ask_long(page)
|
||
# The CURRENT turn's bubble (the conversation accumulates one
|
||
# full turn per iteration — the latest pair is the pin).
|
||
answer = _wait_streaming(page, page.locator(ANSWER).last)
|
||
|
||
# Mid-stream switch to this view — same-document (sentinel).
|
||
page.evaluate("() => { window.__shell_boot = 'phase76'; }")
|
||
page.click(nav_sel)
|
||
expect(page).to_have_url(app_url + view_path)
|
||
assert page.evaluate("() => window.__shell_boot") == "phase76", (
|
||
f"a real navigation to {view_path} would have wiped the sentinel"
|
||
)
|
||
# The view actually showed (its admin content is up) and the
|
||
# chat view is hidden (the stream fills it in the background).
|
||
expect(page.locator(marker)).to_be_visible(timeout=15_000)
|
||
expect(page.locator("#view-chat")).to_be_hidden()
|
||
|
||
# Let the stream run while this view is up, then return to the
|
||
# chat (still same-document).
|
||
page.wait_for_timeout(2000)
|
||
page.click('a.nav-link[href="/"]')
|
||
expect(page).to_have_url(app_url + "/")
|
||
assert page.evaluate("() => window.__shell_boot") == "phase76"
|
||
|
||
# The answer COMPLETED — FULL mock answer, no error banner.
|
||
done_text = _wait_done(page, answer)
|
||
_assert_full_answer(done_text)
|
||
|
||
# Every turn SETTLED: exactly one query_log row per completed
|
||
# turn so far (a cancelled turn would leave no row).
|
||
page.wait_for_timeout(500)
|
||
assert _query_log_count() == i, (
|
||
f"turn {i} must finalize exactly one settled query_log row"
|
||
)
|
||
|
||
# Storage: the latest pair is the question + ONE brain turn
|
||
# carrying the FULL answer (each turn appended, none
|
||
# cancelled, none truncated).
|
||
msgs = _stored_parsed(page)["messages"]
|
||
assert msgs[-2] == {"who": "user", "text": LONG_QUESTION}
|
||
assert msgs[-1]["who"] == "brain"
|
||
assert msgs[-1]["text"] == LONG_ANSWER
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 3. The phase-48 CONTROL: a REAL cross-document departure still
|
||
# cancels the fetch (the locked contract survives the phase) — and
|
||
# the page-20/73 partial persist lands in the exact phase-20 shape
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_real_departure_still_cancels(
|
||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||
) -> None:
|
||
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="/")
|
||
|
||
_ask_long(page)
|
||
_wait_streaming(page, page.locator(ANSWER))
|
||
|
||
# THE DEPARTURE: a REAL cross-document navigation (NOT a navbar
|
||
# link — those are view switches now). The fetch is aborted by the
|
||
# unload, which is the point (phase 48). /shared.html is a plain
|
||
# document with a stable state for a signed-in session — unlike
|
||
# /login.html, which auto-redirects a signed-in admin straight
|
||
# back into the shell.
|
||
page.goto(app_url + "/shared.html")
|
||
expect(page).to_have_url(app_url + "/shared.html")
|
||
expect(page.locator("#shared-title")).to_be_visible(timeout=15_000)
|
||
|
||
# The turn was CANCELLED — the phase-48 query_log row only lands
|
||
# when the LLM finished AND the persistence succeeded, so a
|
||
# cancelled mid-stream turn must leave NO settled row.
|
||
assert _query_log_count() == 0, (
|
||
"a cancelled mid-stream turn must not finalize a query_log row"
|
||
)
|
||
|
||
# Return to the chat — the page-20/73 leave-save is intact: the
|
||
# question AND the already-streamed partial are both rendered.
|
||
page.goto(app_url + "/")
|
||
expect(page.locator("#empty-state")).to_be_hidden()
|
||
expect(page.locator(".msg.user .bubble")).to_have_count(1)
|
||
expect(page.locator(".msg.user .bubble").first).to_contain_text(LONG_QUESTION)
|
||
restored = page.locator(".msg.brain .bubble")
|
||
expect(restored).to_have_count(1)
|
||
expect(restored.first).to_contain_text(FIRST_LINE_DOM)
|
||
_no_error_banner(page)
|
||
|
||
# The EXACT phase-20 partial shape (mirrored from
|
||
# test_sources_midstream_bug.py::test_partial_answer_survives_real_
|
||
# departure_midstream — do not invent a new shape): exactly one
|
||
# brain turn, raw text STARTING with the first streamed chunk,
|
||
# SHORTER than the full answer, and NO done metadata (no
|
||
# sources/deflected/suggestions/thinking — the turn never
|
||
# settled when it was written).
|
||
msgs = _stored_parsed(page)["messages"]
|
||
assert [m["who"] for m in msgs] == ["user", "brain"]
|
||
assert msgs[0]["text"] == LONG_QUESTION
|
||
brain = msgs[1]
|
||
assert brain["text"].startswith(FIRST_CHUNK_RAW)
|
||
assert len(brain["text"]) < len(LONG_ANSWER), "the stored answer must be partial"
|
||
assert brain["text"] != LONG_ANSWER
|
||
assert "sources" not in brain
|
||
assert "deflected" not in brain
|
||
assert "suggestions" not in brain
|
||
assert "thinking" not in brain
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 4. Baseline: the long question with NO navigation completes
|
||
# identically (guards against the shell fold changing the ordinary
|
||
# path)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_baseline_no_switch_still_completes(
|
||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||
) -> None:
|
||
"""The normal path, untouched by the shell: the long answer
|
||
completes identically without any view switch (guards against an
|
||
over-eager router/view change altering the ordinary settle)."""
|
||
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="/")
|
||
|
||
_ask_long(page)
|
||
answer = _wait_streaming(page, page.locator(ANSWER))
|
||
done_text = _wait_done(page, answer)
|
||
_assert_full_answer(done_text)
|
||
_assert_one_brain_turn(page)
|
||
assert _query_log_count() == 1
|