fix(chat): keep in-flight answers alive across in-app view switches
Root cause (owner repro, verified in a real browser 2026-09-06): the five navbar views (Chat, RAG, Sources, Tuning, History) were separate HTML documents, so a navbar click was a REAL cross-document navigation — the chat page unloaded, the in-flight SSE fetch was aborted, and the phase-48 teardown (app/api/chat.py `finally`, "chat: turn cancelled") stopped the model. Observed: send question -> click RAG mid-stream -> click Chat -> the answer never finished: no `query_log` row, and on return a dangling question with no brain record (the pre-token pagehide partial persist skips because `acc` is empty). Phase-48 LOCKED-DECISION REFINEMENT (owner-confirmed 2026-09-06, flagged per AGENTS.md rule 3, not silently deviated): "real navigation cancels the fetch" now means LEAVING THE APP — tab close, external/other-document navigation, the Stop button. In-app navbar switches are client-side view switches and no longer cancel. Fix — Option A (SPA shell), chosen over B (Service Worker owns the stream) and C (server-side turn registry + resume): - frontend/index.html is the shell: ONE `<main id="main">` holds the five `<section class="view">` blocks; hidden views carry BOTH `hidden` and `inert` (WCAG — no focus/keyboard traversal). The shared header, the single `doc-modal-*` skeleton, and the `#app-version` footer each exist exactly once; the per-view copies from the four folded pages are dropped. - New frontend/assets/router.js (vanilla module — no framework, no bundler, No-CDN rule intact): lazy-imports a view module on FIRST show only (mount-once, hide-forever — the chat view's in-flight SSE reader persists across switches; that persistence IS the fix); intercepts same-shell navbar links with preventDefault + history.pushState (never a document load); handles popstate; single writer of `.nav-link` active state (is-active + aria-current), document.title, and the per-view meta description (values carried over from the old pages' heads, brand-resolved at write time). - Each folded page's JS becomes `export async function mount(root)` — root-scoped queries; `initSharedHeader()` dropped (the header boots once in the shell via the chat module; the admin flag comes from the same cached `fetchIsAdmin()` promise — zero extra requests). - app/main.py: a small list-driven route factory serves the shell for /tuning.html, /sources.html, /git-sources.html, /history.html — registered AFTER the API routers and BEFORE the static catch-all (routes-first). The phase-33 caching middleware applies no-cache + `?v=` rewriting unchanged; app/core/caching.py needed NO change (the view paths did not change — pinned by the integration tests). - The four old view .html files are DELETED (one source of truth); deep links to the old URLs keep working (the router picks the view from the pathname); `/?chat=<id>` is unaffected; the Containerfile bundles router.js (inlining the lazy view modules) and drops the folded page files. - app/schemas.py: HistoryTurn.text cap 4000 -> 32000 — the shell keeps long saved answers in the chat, and the old cap (stricter than the 24_000-char total history budget) 422-rejected any second turn in such a chat (found by the phase-42 E2E suite on the shell). Boundaries: login.html, shared.html, doc-edit.html, document.html REMAIN separate documents (flow pages, not navbar tabs); a mid-stream navigation to doc-edit/document.html still cancels per phase 48 (follow-up candidate, out of scope). The SSE API is unchanged. Real departures still cancel the turn — phase 48 intact (pinned by tests/e2e/test_stop_generation.py, unchanged, and by the new suite's real-departure control). Tests: - Phase-20 suite REWRITTEN to the new semantics (tests/e2e/test_sources_midstream_bug.py): a navbar switch no longer cancels — the stream survives the switch and the FULL answer settles; the pagehide partial persist REMAINS for real departures (the partial's exact shape — first streamed chunk prefix, no done metadata — is still pinned there). - NEW story suite tests/e2e/test_nav_switch_keeps_stream.py (mock LLM): the owner repro (send -> RAG mid-stream -> Chat: window sentinel survives = same document, FULL answer, exactly one brain turn in bor.chat.v1, exactly one settled query_log row, auto-saved row matches) + the same mid-stream switch against the other three views + the real-departure-still-cancels control + the no-switch baseline. - tests/unit/test_frontend_router.py: source-level pins of the router invariants (click interceptor targets ONLY same-shell view paths, pushState-only switches, mount-once guard, hidden+inert pair, single-writer active state/title); shell-route integration tests (each folded path serves the shell with no-cache + `?v=` body; a non-view path still 404s); the file-reading unit pins re-pointed at the shell (the four view files are gone — the shell is the source of truth). Verification (this commit): full suite green — 1565 unit+integration tests, app/ coverage 99% (>90% floor); ruff + pyright clean; the phase's E2E suites green in isolation (house protocol, AGENTS.md rule 9). Owner repro verified in a real browser against the real LLM (dev server :8010, headful Chromium): "tell me about everquest" -> RAG mid-stream -> Chat — the answer completed with one brain bubble and no error banner, `query_log` gained exactly one settled row (deflected=True: the dev KB holds no EverQuest docs — the settle, not the topic, is the proof), zero "chat: turn cancelled" lines for that turn; the control (real navigation to /shared.html mid-stream) still cancelled (no settled row, the cancel line logged, the partial persisted on return). Screenshots: .agents/screenshots/76_manual_*. Phase 76 (76_spa_nav_shell) complete — moved to .agents/phases/complete/.
This commit is contained in:
@@ -272,13 +272,15 @@ def test_persists_across_page_navigation(
|
||||
_ask_deflected(page, OFF_TOPIC) # mixed conversation: grounded + deflected
|
||||
|
||||
# A trip to Sources (phase 16: the catalog is admin-only — the trip
|
||||
# starts with a real form login). The New chat button is chat-page
|
||||
# only (owner rework 2026-08-28 — it left the shared bar), so the
|
||||
# sources page carries none of it (pinned in
|
||||
# starts with a real form login). Phase 76 (task 02): the shell
|
||||
# carries the chat view (with its New chat button) in the DOM on
|
||||
# EVERY view — hidden + inert — so the button EXISTS here but must
|
||||
# be HIDDEN (the view-scoped absence pattern; it left the shared
|
||||
# bar at owner request, 2026-08-28 — pinned in
|
||||
# tests/e2e/test_shared_header.py).
|
||||
login(page, app_url, next="/sources.html")
|
||||
expect(page.locator("#docs-tbody tr").first).to_be_visible(timeout=15_000)
|
||||
expect(page.locator("#new-chat-btn")).to_have_count(0)
|
||||
expect(page.locator("#new-chat-btn")).to_be_hidden()
|
||||
|
||||
# Back to the chat: the conversation is exactly as left — both turns,
|
||||
# the source chip, and the amber deflected bubble with its chips.
|
||||
|
||||
@@ -86,7 +86,10 @@ def test_history_tab_shows_auto_save_copy(
|
||||
|
||||
# The .page-sub reads the locked (A3) page-sub string
|
||||
# (whitespace-normalized — the template wraps the line).
|
||||
page_sub = page.locator(".page-sub")
|
||||
# Phase 76 (task 03): the shell carries one .page-sub per view
|
||||
# (the hidden views' copies remain in the DOM — hidden + inert),
|
||||
# so the pin is scoped to the visible History view.
|
||||
page_sub = page.locator("#view-history .page-sub")
|
||||
expect(page_sub).to_have_count(1)
|
||||
assert re.sub(r"\s+", " ", page_sub.inner_text()).strip() == PAGE_SUB
|
||||
|
||||
@@ -98,8 +101,10 @@ def test_history_tab_shows_auto_save_copy(
|
||||
# The no-button contract: the WHOLE rendered page reads neither
|
||||
# "press(ed) Save" nor "Save button" — while the <h1> still reads
|
||||
# "Saved chats" (asserted present, so the scan cannot pass by
|
||||
# deleting the heading).
|
||||
h1 = page.locator("h1")
|
||||
# deleting the heading). Phase 76 (task 03): scoped to the
|
||||
# History view — the shell carries one <h1> per view (the hidden
|
||||
# views' headings remain in the DOM, hidden + inert).
|
||||
h1 = page.locator("#view-history h1")
|
||||
expect(h1).to_have_count(1)
|
||||
expect(h1).to_have_text("Saved chats")
|
||||
body_text = page.locator("body").inner_text()
|
||||
|
||||
@@ -127,7 +127,9 @@ def test_sources_table_layout(
|
||||
login(page, app_url) # phase 16: the catalog is admin-only
|
||||
page.locator("#docs-tbody tr").first.wait_for(state="visible")
|
||||
|
||||
wrap = page.locator(".table-wrap")
|
||||
# Phase 76 (task 02): the shell carries BOTH views' .table-wrap —
|
||||
# scope to the RAG view.
|
||||
wrap = page.locator("#view-rag .table-wrap")
|
||||
expect(wrap).to_be_visible()
|
||||
expect(wrap).to_have_attribute("role", "region")
|
||||
expect(wrap).to_have_attribute("tabindex", "0")
|
||||
@@ -146,7 +148,7 @@ def test_sources_table_layout(
|
||||
login(mobile, app_url) # phase 16: the catalog is admin-only
|
||||
mobile.locator("#docs-tbody tr").first.wait_for(state="visible")
|
||||
scroll_width, client_width = mobile.evaluate(
|
||||
"() => { const el = document.querySelector('.table-wrap');"
|
||||
"() => { const el = document.querySelector('#view-rag .table-wrap');"
|
||||
" return [el.scrollWidth, el.clientWidth]; }"
|
||||
)
|
||||
assert scroll_width > client_width
|
||||
@@ -163,6 +165,6 @@ def test_empty_state_when_no_docs(page: Page, app_url: str, mock_llm: int, db_re
|
||||
expect(page.locator("#sources-empty code")).to_have_text(
|
||||
"uv run python -m scripts.import_docs"
|
||||
)
|
||||
expect(page.locator(".table-wrap")).to_be_hidden()
|
||||
expect(page.locator("#view-rag .table-wrap")).to_be_hidden()
|
||||
expect(page.locator("#stat-docs")).to_have_text("0")
|
||||
expect(page.locator("#stat-chunks")).to_have_text("0")
|
||||
|
||||
@@ -83,7 +83,7 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from playwright.sync_api import Dialog, Page, expect
|
||||
from playwright.sync_api import Page, expect
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.db import SessionLocal
|
||||
@@ -498,7 +498,10 @@ def test_admin_sync_imports_fixture_prunes_after_delete_removes_row(
|
||||
f"fixture doc survived the prune: {docs}"
|
||||
)
|
||||
|
||||
# --- remove the row: accept the confirm → it disappears ------------
|
||||
# --- remove the row: the phase-69 in-app confirmation modal --------
|
||||
# (window.confirm is retired — the row's Remove button opens the
|
||||
# #remove-confirm-dialog, which names the source and states the
|
||||
# full-removal policy; "Remove source" runs the server-side DELETE.)
|
||||
# Back on the manager page (the sync clicks visited the Sources page).
|
||||
page.goto(app_url + GIT_SOURCES_URL)
|
||||
removes: list[str] = []
|
||||
@@ -508,20 +511,15 @@ def test_admin_sync_imports_fixture_prunes_after_delete_removes_row(
|
||||
if r.method == "DELETE" and "/api/git-sources/" in r.url
|
||||
else None,
|
||||
)
|
||||
|
||||
def handle_dialog(dialog: Dialog) -> None:
|
||||
# "Remove this local source…? Its documents stay indexed until
|
||||
# the next sync prunes them." — accept it.
|
||||
dialog.accept()
|
||||
|
||||
page.on("dialog", handle_dialog)
|
||||
try:
|
||||
row = page.locator("#git-sources-tbody tr", has_text=str(local_dir))
|
||||
row.locator(".git-source-remove").click()
|
||||
expect(page.locator("#git-sources-tbody tr")).to_have_count(0, timeout=30_000)
|
||||
expect(page.locator("#git-sources-empty")).to_be_visible()
|
||||
finally:
|
||||
page.remove_listener("dialog", handle_dialog)
|
||||
row = page.locator("#git-sources-tbody tr", has_text=str(local_dir))
|
||||
row.locator(".git-source-remove").click()
|
||||
dialog = page.locator("#remove-confirm-dialog")
|
||||
expect(dialog).to_be_visible()
|
||||
expect(dialog.locator("#remove-confirm-source")).to_contain_text(str(local_dir))
|
||||
# Confirm: the full cleanup runs server-side; the row disappears.
|
||||
dialog.locator("#remove-confirm-remove").click()
|
||||
expect(page.locator("#git-sources-tbody tr")).to_have_count(0, timeout=30_000)
|
||||
expect(page.locator("#git-sources-empty")).to_be_visible()
|
||||
assert len(removes) == 1, f"expected one DELETE, saw: {removes}"
|
||||
# The registry is empty again — and with no env git list, a further
|
||||
# sync would fail loudly ("no sources configured (git or local)").
|
||||
|
||||
@@ -72,6 +72,18 @@ Test → story mapping (Playwright Mapping Rule):
|
||||
4. ``test_viewer_back_link_honors_back_param``
|
||||
5. ``test_steering_surface_off_chat_on_tuning_page``
|
||||
6. ``test_sync_button_present_on_sources_page_without_triggering``
|
||||
7. ``test_viewer_nav_click_full_loads_the_shell_rag_view`` (phase 76
|
||||
task 04 — the header is shell-owned: the surviving standalone
|
||||
documents keep their header copies, and a navbar click on one is a
|
||||
REAL departure that full-loads the shell, whose router renders the
|
||||
target view from the pathname)
|
||||
|
||||
Phase 76 adaptation (tasks 01–03): chat / sources / tuning are VIEWS of
|
||||
ONE shell document (index.html) — the header under test on those URLs is
|
||||
the shell's single one (the router deep-links the view from the
|
||||
pathname on each real goto). The per-URL inventory comparison is the
|
||||
pre-phase-76 probe, kept verbatim; test 7 adds the surviving-document
|
||||
side of the ownership boundary (the viewer's own header copy).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -243,15 +255,16 @@ def _visit(page: Page, app_url: str, name: str, url: str, admin: bool) -> list[s
|
||||
assert page.locator("#steering-toggle").count() == 0, (
|
||||
f"{name}: the steering toggle was removed from the navbar"
|
||||
)
|
||||
# The Sync button is a page-specific control (Sources page only
|
||||
# — owner rework 2026-08-28): visible on sources, absent from
|
||||
# the shared bar everywhere else.
|
||||
# The Sync button is a view-specific control (RAG view only —
|
||||
# owner rework 2026-08-28): visible on sources, not visible
|
||||
# anywhere else. Phase 76 (task 02): in the shell the RAG view
|
||||
# (with the button) is in the DOM on every view — hidden +
|
||||
# inert — so the pin is VISIBLE, not ABSENT (to_be_hidden()
|
||||
# also passes on standalone pages where the button is absent).
|
||||
if name == "sources":
|
||||
expect(page.locator("#sync-btn")).to_be_visible()
|
||||
else:
|
||||
assert page.locator("#sync-btn").count() == 0, (
|
||||
f"{name}: #sync-btn left the shared bar (Sources page only)"
|
||||
)
|
||||
expect(page.locator("#sync-btn")).to_be_hidden()
|
||||
expect(page.locator("#sign-out-btn")).to_be_visible()
|
||||
expect(page.locator("#sign-in-link")).to_be_hidden()
|
||||
else:
|
||||
@@ -277,14 +290,16 @@ def _visit(page: Page, app_url: str, name: str, url: str, admin: bool) -> list[s
|
||||
assert page.locator("#steering-panel").count() == 0, (
|
||||
f"{name}: the steering panel must be absent for anonymous"
|
||||
)
|
||||
# The New chat button is chat-page only (moved from the shared bar
|
||||
# to index.html's .chat-shell at owner request, 2026-08-28).
|
||||
# The New chat button is chat-view only (moved from the shared bar
|
||||
# to the shell's .chat-shell at owner request, 2026-08-28).
|
||||
# Phase 76 (task 02): in the shell the chat view (with the button)
|
||||
# is in the DOM on every view — hidden + inert — so the pin is
|
||||
# VISIBLE, not ABSENT (to_be_hidden() also passes on standalone
|
||||
# pages where the button is absent).
|
||||
if name == "chat":
|
||||
expect(page.locator("#new-chat-btn")).to_be_visible()
|
||||
else:
|
||||
assert page.locator("#new-chat-btn").count() == 0, (
|
||||
f"{name}: #new-chat-btn left the shared bar (chat page only)"
|
||||
)
|
||||
expect(page.locator("#new-chat-btn")).to_be_hidden()
|
||||
|
||||
_assert_landmarks(page, name)
|
||||
return _header_inventory(page)
|
||||
@@ -540,3 +555,61 @@ def test_sync_button_present_on_sources_page_without_triggering(
|
||||
assert btn.get_attribute("aria-busy") is None, "a fresh idle sync must not be busy"
|
||||
expect(page.locator("#sync-label")).to_have_text("Sync sources")
|
||||
# Deliberately NOT clicked.
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. Phase 76 task 04: the surviving documents keep their header copies —
|
||||
# a navbar click on one is a REAL departure that full-loads the shell
|
||||
# (whose router then renders the target view from the pathname)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_viewer_nav_click_full_loads_the_shell_rag_view(
|
||||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||
) -> None:
|
||||
"""Phase 76 task 04: the document viewer is one of the four surviving
|
||||
standalone documents (login, shared, doc-edit, document) — it keeps
|
||||
its own header copy, and the router does NOT run there (the router
|
||||
lives in the shell). Clicking its RAG nav link is therefore a REAL,
|
||||
document-level navigation — not the shell's pushState switch: it
|
||||
full-loads the shell at /sources.html, and the shell's router renders
|
||||
the RAG view from the pathname (the Chat view ships hidden + inert
|
||||
inside that same document).
|
||||
|
||||
The window sentinel proves the departure in the phase-76 canonical
|
||||
form, used in INVERSE: it is set in the viewer document and must be
|
||||
GONE after the click (a real load wipes window globals — exactly
|
||||
what distinguishes a departure from the shell's same-document
|
||||
switches, where the sentinel survives)."""
|
||||
page.set_viewport_size({"width": 1280, "height": 800})
|
||||
_seed_db(mock_llm)
|
||||
|
||||
# Admin: the viewer's RAG nav link is admin-only (revealed by the
|
||||
# whoami pass of its own header copy).
|
||||
login(page, app_url, next=CHAT_URL)
|
||||
expect(page).to_have_url(app_url + CHAT_URL, timeout=30_000)
|
||||
page.goto(app_url + VIEWER_URL)
|
||||
expect(page.locator("#doc-title")).to_have_text(DOC_TITLE, timeout=15_000)
|
||||
_wait_settled(page, admin=True)
|
||||
expect(page.locator("#nav-sources")).to_be_visible()
|
||||
|
||||
# The sentinel lives in the VIEWER document only.
|
||||
page.evaluate("() => { window.__phase76_viewer = 'phase76'; }")
|
||||
page.click("#nav-sources")
|
||||
|
||||
# The arrival is the SHELL at the RAG view's URL: the document loaded
|
||||
# for real (the sentinel is gone), the RAG view is rendered (first
|
||||
# table row visible), the Chat view is hidden AND inert in the same
|
||||
# document, and the RAG link carries the router's single-writer
|
||||
# active stamp.
|
||||
expect(page).to_have_url(app_url + SOURCES_URL, timeout=30_000)
|
||||
assert page.evaluate("() => window.__phase76_viewer") is None, (
|
||||
"a surviving document's nav click must be a real departure "
|
||||
"(a fresh document load wipes window globals)"
|
||||
)
|
||||
expect(page.locator("#docs-tbody tr").first).to_be_visible(timeout=15_000)
|
||||
expect(page.locator("#view-chat")).to_be_hidden()
|
||||
assert page.evaluate("() => document.getElementById('view-chat').inert") is True, (
|
||||
"the chat view ships hidden AND inert in the shell"
|
||||
)
|
||||
expect(page.locator("#nav-sources")).to_have_class(re.compile(r"\bis-active\b"))
|
||||
|
||||
@@ -112,6 +112,11 @@ CURRENT_LINK: dict[str, str | None] = {
|
||||
#: The four primary nav links, in their physical DOM order — the labels
|
||||
#: after the phase-48 swap (ids/hrefs unchanged).
|
||||
NAV_LABELS = ("Chat", "RAG", "Sources", "Tuning")
|
||||
#: The FIFTH a.nav-link in every page header (phase 53 saved-chat
|
||||
#: history — ship-hidden, revealed by header.js for admins). The DOM
|
||||
#: enumeration below therefore always sees it (pre-existing since phase
|
||||
#: 53; the list below now matches the real nav).
|
||||
NAV_TAIL = ("History",)
|
||||
|
||||
#: The login.js script — route pattern for the redirect suppression.
|
||||
LOGIN_JS_ROUTE = re.compile(r"/assets/login\.js(\?.*)?$")
|
||||
@@ -191,12 +196,14 @@ def _assert_renamed_labels(page: Page, name: str) -> None:
|
||||
expect(git).to_have_text("Sources")
|
||||
expect(git).to_have_attribute("href", "/git-sources.html")
|
||||
|
||||
# The four primary nav links (class nav-link) in physical DOM order.
|
||||
# The nav links (class nav-link) in physical DOM order — the four
|
||||
# primaries plus the phase-53 admin-only History link.
|
||||
nav_texts = page.eval_on_selector_all(
|
||||
".app-nav a.nav-link", "els => els.map(e => e.textContent.trim())"
|
||||
)
|
||||
assert nav_texts == list(NAV_LABELS), (
|
||||
f"{name}: nav link order/labels are {nav_texts}, expected {list(NAV_LABELS)}"
|
||||
assert nav_texts == [*NAV_LABELS, *NAV_TAIL], (
|
||||
f"{name}: nav link order/labels are {nav_texts}, expected "
|
||||
f"{[*NAV_LABELS, *NAV_TAIL]}"
|
||||
)
|
||||
# …and the full anchor sequence of the nav (it also carries the
|
||||
# phase-46 mobile sign-in copy) opens with the same four, in order.
|
||||
|
||||
@@ -0,0 +1,525 @@
|
||||
"""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)
|
||||
page.goto(app_url)
|
||||
|
||||
_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
|
||||
@@ -277,7 +277,9 @@ def test_sources_table_full_width(
|
||||
try:
|
||||
login(page, app_url, next="/sources.html") # phase 16: admin-only
|
||||
page.locator("#docs-tbody tr").first.wait_for(state="visible", timeout=10_000)
|
||||
wrap_box = page.locator(".table-wrap").bounding_box()
|
||||
# Phase 76 (task 02): the shell carries BOTH views' .table-wrap
|
||||
# (the git-sources one ships hidden) — scope to the RAG view.
|
||||
wrap_box = page.locator("#view-rag .table-wrap").bounding_box()
|
||||
shell_box = page.locator(".sources-shell").bounding_box()
|
||||
assert wrap_box is not None and shell_box is not None
|
||||
assert wrap_box["width"] >= 0.80 * shell_box["width"], (
|
||||
@@ -292,7 +294,7 @@ def test_sources_table_full_width(
|
||||
login(mobile, app_url, next="/sources.html") # phase 16: admin-only
|
||||
mobile.locator("#docs-tbody tr").first.wait_for(state="visible", timeout=10_000)
|
||||
scroll, client = mobile.evaluate(
|
||||
"() => { const el = document.querySelector('.table-wrap');"
|
||||
"() => { const el = document.querySelector('#view-rag .table-wrap');"
|
||||
" return [el.scrollWidth, el.clientWidth]; }"
|
||||
)
|
||||
assert scroll > client, (
|
||||
|
||||
@@ -157,14 +157,16 @@ def assert_shared_bar(page: Page, admin: bool, page_kind: str, mobile: bool = Fa
|
||||
)
|
||||
expect(page.locator("#sign-out-btn")).to_be_hidden()
|
||||
|
||||
# The New chat button is chat-page only (moved from the shared bar
|
||||
# to index.html's .chat-shell at owner request, 2026-08-28).
|
||||
# The New chat button is chat-view only (moved from the shared bar
|
||||
# to the shell's .chat-shell at owner request, 2026-08-28).
|
||||
# Phase 76 (task 02): in the shell the chat view (with the button)
|
||||
# is in the DOM on every view — hidden + inert — so the pin is
|
||||
# VISIBLE, not ABSENT (to_be_hidden() also passes on standalone
|
||||
# pages like the viewer, where the button does not exist at all).
|
||||
if page_kind == "chat":
|
||||
expect(page.locator("#new-chat-btn")).to_be_visible()
|
||||
else:
|
||||
assert page.locator("#new-chat-btn").count() == 0, (
|
||||
f"{page_kind}: the New chat button is chat-page only"
|
||||
)
|
||||
expect(page.locator("#new-chat-btn")).to_be_hidden()
|
||||
|
||||
# Phase 34: EVERY page kind — viewer included — carries the SAME
|
||||
# nav contract: the Chat link always visible; the admin-only
|
||||
@@ -311,12 +313,15 @@ def test_new_chat_button_is_chat_page_only(
|
||||
page.set_viewport_size({"width": 1280, "height": 800})
|
||||
_seed_db(mock_llm)
|
||||
|
||||
# No non-chat page carries the button anymore…
|
||||
# No non-chat view shows the button (phase 76 task 02: in the shell
|
||||
# it EXISTS in the DOM on every view — hidden + inert — so the pin
|
||||
# is visibility, not existence; standalone pages carry none at
|
||||
# all, and to_be_hidden() passes for both) …
|
||||
for path in (SOURCES_URL, VIEWER_URL, "/tuning.html", "/login.html", "/git-sources.html"):
|
||||
page.goto(app_url + path)
|
||||
expect(page.locator("#new-chat-btn")).to_have_count(0)
|
||||
expect(page.locator("#new-chat-btn")).to_be_hidden()
|
||||
|
||||
# …and the chat page has exactly one (visible, inside .chat-shell).
|
||||
# …and the chat view has exactly one (visible, inside .chat-shell).
|
||||
page.goto(app_url + "/")
|
||||
expect(page.locator("#new-chat-btn")).to_have_count(1)
|
||||
expect(page.locator("#new-chat-btn")).to_be_visible()
|
||||
|
||||
@@ -1,36 +1,45 @@
|
||||
"""Phase 20 E2E (Playwright): navigating away mid-turn keeps the answer.
|
||||
"""Story: mid-stream navigation — the phase-76 SPA shell fix (phase 20
|
||||
story, re-purposed by phase 76 task 02).
|
||||
|
||||
Story: ``.agents/user_stories/sources-midstream.md``
|
||||
Bug report (TODO.md L3): *"Clicking "sources" while chat is generating
|
||||
clears chat and result will never show up."*
|
||||
Run in isolation (DB must be up: ``podman compose up -d db``):
|
||||
|
||||
uv run pytest tests/e2e/test_sources_midstream_bug.py -v --no-cov
|
||||
|
||||
The bug: the brain message persisted only on ``done``, so leaving the
|
||||
chat page while a turn was in flight aborted the stream and dropped
|
||||
whatever had already streamed — the user came back to their own question
|
||||
with no result, ever. The fix (phase 20, owner-confirmed A1): a single
|
||||
``pagehide`` save point in app.js persists the partial raw answer (via
|
||||
the existing ``rememberBrainTurn`` helper) when navigation hits a turn
|
||||
that is in flight and has already streamed text.
|
||||
Phase 20 (bug 24) pinned a REAL departure from the chat mid-answer: a
|
||||
full page navigation (the "Sources" navbar link → /sources.html) aborted
|
||||
the stream via the unload, and a single ``pagehide`` save point in
|
||||
app.js persisted the partial raw answer (``rememberBrainTurn``) so the
|
||||
user came back to their question WITH the partial, rendered as
|
||||
"Partial answer — navigation interrupted the stream."
|
||||
|
||||
Phase 76 (task 02) folded /sources.html (and /git-sources.html) into
|
||||
the ONE-document shell: from this phase on, a navbar click is a
|
||||
CLIENT-SIDE view switch — the document (and its in-flight SSE reader)
|
||||
survive, so the pinned behavior is "the stream survives and the answer
|
||||
COMPLETES." The phase-20 pagehide partial-persist REMAINS for REAL
|
||||
departures only (a cross-document navigation still aborts the fetch),
|
||||
and its coverage home is scenario 1 below in its renamed form.
|
||||
|
||||
Timing is deterministic by construction:
|
||||
|
||||
* scenario 1 keys off the mock's ``write a long answer`` trigger — a
|
||||
~5400-char / ~450-frame / ~9s content stream, so the navigation lands
|
||||
~5400-char / ~450-frame / ~9s content stream, so the departure lands
|
||||
mid-stream with a wide margin;
|
||||
* scenario 2 keys off the mock's ``think out loud then hesitate``
|
||||
trigger — the phase-17 thinking stream followed by a 4s silence before
|
||||
the first content frame, so the navigation lands inside pure thinking;
|
||||
* scenarios 3 and 4 settle the turn fully (send button re-enabled)
|
||||
before any navigation.
|
||||
* scenarios 2–3 key off the same long stream (mid-stream view switch)
|
||||
and the ``think out loud then hesitate`` trigger — the phase-17
|
||||
thinking stream followed by a 4s silence before the first content
|
||||
frame, so the switch lands inside pure thinking;
|
||||
* scenario 4 (the pre-token real-departure pin) uses the same
|
||||
hesitate trigger; scenarios 5 and 6 settle the turn fully (send
|
||||
button re-enabled) before any navigation.
|
||||
|
||||
Test → story mapping (Playwright Mapping Rule):
|
||||
1. ``test_partial_answer_survives_sources_nav_midstream``
|
||||
2. ``test_no_orphan_brain_message_when_navigated_before_first_token``
|
||||
3. ``test_completed_turn_unaffected``
|
||||
4. ``test_new_chat_still_clears_conversation``
|
||||
1. ``test_partial_answer_survives_real_departure_midstream``
|
||||
2. ``test_full_answer_completes_after_rag_nav_midstream``
|
||||
3. ``test_nav_switch_before_first_token_completes``
|
||||
4. ``test_no_orphan_brain_message_when_navigated_before_first_token``
|
||||
5. ``test_completed_turn_unaffected``
|
||||
6. ``test_new_chat_still_clears_conversation``
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -51,7 +60,7 @@ 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
|
||||
from e2e.mock_llm import LONG_ANSWER_END, LONG_ANSWER_LINES, long_answer
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
FIXTURES = REPO / "tests" / "fixtures" / "docs"
|
||||
@@ -71,7 +80,7 @@ FIRST_CHUNK_RAW = re.findall(r".{1,12}", FULL_LONG, re.S)[0]
|
||||
#: dropping the marker (pinned by test_long_answers).
|
||||
FIRST_LINE_DOM = "Step 1: configure node-1"
|
||||
|
||||
# --- scenario 2: navigation during pure thinking (no answer tokens) -----
|
||||
# --- scenario 3: navigation during pure thinking (no answer tokens) -----
|
||||
HESITATE_QUESTION = (
|
||||
"think out loud then hesitate — how is my kubernetes cluster set up?"
|
||||
)
|
||||
@@ -123,6 +132,20 @@ def _reset_db(mock_port: int, seed: bool) -> ImportSummary | None:
|
||||
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 tests/e2e/test_hidden_tab_
|
||||
stream.py).
|
||||
"""
|
||||
with SessionLocal() as db:
|
||||
return db.execute(text("SELECT count(*) FROM query_log")).scalar_one()
|
||||
|
||||
|
||||
def _stored(page: Page) -> str | None:
|
||||
"""Raw localStorage payload for the chat (None when the key is absent)."""
|
||||
return page.evaluate(f"() => localStorage.getItem('{STORAGE_KEY}')")
|
||||
@@ -148,8 +171,12 @@ def _ask(page: Page, question: str) -> None:
|
||||
|
||||
def _no_error_banner(page: Page) -> None:
|
||||
"""The never-stale contract: a restored/partial state must never
|
||||
present an error banner (role=alert) — the turn is simply partial."""
|
||||
expect(page.locator('[role="alert"]')).to_have_count(0)
|
||||
present an error banner (role=alert) — the turn is simply partial.
|
||||
Phase 76 (task 02): the shell's hidden views carry their own
|
||||
ship-hidden role=alert surfaces (sync banner, upload banner, …),
|
||||
so the pin is VIEW-SCOPED IN EFFECT — NO alert may be VISIBLE,
|
||||
whatever the document carries hidden."""
|
||||
expect(page.locator('[role="alert"]:visible')).to_have_count(0)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
@@ -164,17 +191,17 @@ def seeded_kb(mock_llm: int, db_ready: None) -> Iterator[None]:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Mid-stream navigation via the Sources nav link: the partial answer
|
||||
# that had already streamed is persisted and restored
|
||||
# 1. REAL departure mid-stream (pagehide partial persist — the phase-20
|
||||
# contract, now exercised via a genuine cross-document navigation;
|
||||
# a navbar click is no longer a departure — that is scenario 2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_partial_answer_survives_sources_nav_midstream(
|
||||
def test_partial_answer_survives_real_departure_midstream(
|
||||
page: Page, app_url: str, seeded_kb: None
|
||||
) -> None:
|
||||
page.set_default_timeout(30_000)
|
||||
# Admin (phase 16/19): only the admin sees the #nav-sources link the
|
||||
# bug report clicks.
|
||||
# Admin (phase 16/19): only the admin sees the #nav-sources link.
|
||||
login(page, app_url, next="/")
|
||||
expect(page.locator("#nav-sources")).to_be_visible()
|
||||
|
||||
@@ -193,13 +220,22 @@ def test_partial_answer_survives_sources_nav_midstream(
|
||||
expect(page.locator("#send-btn")).to_be_enabled()
|
||||
expect(page.locator("#send-label")).to_have_text("Stop")
|
||||
|
||||
# THE BUG REPORT, VERBATIM: click "Sources" while chat is generating.
|
||||
page.click("#nav-sources")
|
||||
expect(page).to_have_url(app_url + "/sources.html")
|
||||
# The navigation really landed on the admin catalog (mid-stream state
|
||||
# of the stream itself does not matter to the page — the fetch is
|
||||
# aborted by the unload, which is the point).
|
||||
expect(page.locator("#docs-tbody tr").first).to_be_visible(timeout=15_000)
|
||||
# THE DEPARTURE, in its phase-76 form: a REAL cross-document
|
||||
# navigation — page.goto to a genuine other document. /shared.html
|
||||
# is a plain document (stable for every session state) and — unlike
|
||||
# /login.html, which auto-redirects a signed-in session straight
|
||||
# back into the shell — it is a real departure, so the in-flight SSE
|
||||
# fetch is aborted by the unload (the point).
|
||||
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.
|
||||
page.goto(app_url + "/")
|
||||
@@ -238,8 +274,149 @@ def test_partial_answer_survives_sources_nav_midstream(
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Navigation BEFORE the first answer token (pure thinking): nothing
|
||||
# brain-side is persisted — the question comes back alone
|
||||
# 2. Navbar click to RAG mid-stream = a client-side VIEW SWITCH (phase 76,
|
||||
# task 02): the in-flight stream keeps running while the RAG view
|
||||
# shows, and the answer COMPLETES — the phase-20 "answer cut short"
|
||||
# outcome is impossible now (the fetch was never cancelled)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_full_answer_completes_after_rag_nav_midstream(
|
||||
page: Page, app_url: str, seeded_kb: None
|
||||
) -> None:
|
||||
page.set_default_timeout(30_000)
|
||||
login(page, app_url, next="/")
|
||||
expect(page.locator("#nav-sources")).to_be_visible()
|
||||
|
||||
# Start the ~9s long answer and wait until visible streaming (the
|
||||
# house pattern: first line rendered + the enabled Stop control).
|
||||
page.fill("#message-input", LONG_QUESTION)
|
||||
page.click("#send-btn")
|
||||
expect(page.locator(".msg.user .bubble").last).to_contain_text(LONG_QUESTION)
|
||||
bubble = page.locator(".msg.brain .bubble").last
|
||||
expect(bubble).to_contain_text(FIRST_LINE_DOM, timeout=30_000)
|
||||
expect(page.locator("#send-btn")).to_be_enabled()
|
||||
expect(page.locator("#send-label")).to_have_text("Stop")
|
||||
|
||||
# Same-document proof: a window sentinel set before the click is
|
||||
# still readable after — no load happened (the navigation-entries
|
||||
# length is NOT used: it resets on a real load).
|
||||
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"
|
||||
# The RAG view actually mounted (the fixture docs' rows are listed).
|
||||
expect(page.locator("#docs-tbody tr").first).to_be_visible(timeout=15_000)
|
||||
|
||||
# Stay on the RAG view while the stream keeps running in the
|
||||
# background (the switch is ~t+2s; the full answer needs ~9s).
|
||||
page.wait_for_timeout(2000)
|
||||
# Back to the chat (the header link — a router-intercepted switch).
|
||||
page.click('a.nav-link[href="/"]')
|
||||
expect(page).to_have_url(app_url + "/")
|
||||
|
||||
# The answer COMPLETED — the final sentinel line is in the bubble
|
||||
# (not a partial), no error banner.
|
||||
bubble = page.locator(".msg.brain .bubble").last
|
||||
expect(bubble).to_contain_text(LONG_ANSWER_END, timeout=30_000)
|
||||
text_now = bubble.inner_text()
|
||||
for i in range(1, LONG_ANSWER_LINES + 1):
|
||||
assert f"Step {i}: configure node-{i}" in text_now
|
||||
_no_error_banner(page)
|
||||
|
||||
# Settle, then storage: EXACTLY ONE brain turn — the FULL answer,
|
||||
# with done metadata (the settle, not a partial).
|
||||
page.wait_for_timeout(500)
|
||||
msgs = _stored_parsed(page)["messages"]
|
||||
assert [m["who"] for m in msgs] == ["user", "brain"]
|
||||
brain = msgs[1]
|
||||
assert brain["text"] == FULL_LONG
|
||||
assert brain["deflected"] is False
|
||||
assert any(s["path"] == "homelab/kubernetes.md" for s in brain["sources"])
|
||||
|
||||
# The turn SETTLED — the phase-48 query_log row exists (a
|
||||
# cancelled turn would leave no row at all).
|
||||
assert _query_log_count() == 1, "a completed turn must finalize its query_log row"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Navbar switch in the pre-first-token window (pure thinking — no
|
||||
# content frame yet): the surviving reader completes the answer, and
|
||||
# bor.chat.v1 holds exactly ONE brain turn (the no-orphan invariant
|
||||
# in its new form — a pre-token view switch neither kills the turn
|
||||
# nor persists a partial)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_nav_switch_before_first_token_completes(
|
||||
page: Page, app_url: str, seeded_kb: None
|
||||
) -> None:
|
||||
page.set_default_timeout(30_000)
|
||||
login(page, app_url, next="/")
|
||||
expect(page.locator("#nav-sources")).to_be_visible()
|
||||
|
||||
page.fill("#message-input", HESITATE_QUESTION)
|
||||
page.click("#send-btn")
|
||||
expect(page.locator(".msg.user .bubble").last).to_contain_text(HESITATE_QUESTION)
|
||||
|
||||
# The pre-first-token window, pinned the same way as scenario 4:
|
||||
# the scratchpad's tail is rendered (the thinking stream has just
|
||||
# ended) and the 4s pre-content pause (SLOW_PRETOKEN_TRIGGER) is
|
||||
# running — NO content frame has landed yet. (The .msg.brain bubble
|
||||
# element exists from turn start with its thinking block — the
|
||||
# pre-token state is "no content text", not "no bubble element".)
|
||||
thinking = page.locator(".msg.brain").last.locator("details.thinking")
|
||||
thinking.wait_for(state="attached", timeout=10_000)
|
||||
expect(thinking.locator(".thinking-text")).to_contain_text(
|
||||
THINKING_TAIL, timeout=30_000
|
||||
)
|
||||
# Still pre-token: the button is the enabled Stop control (phase 48
|
||||
# — the old disabled "Thinking…" busy state is gone).
|
||||
expect(page.locator("#send-btn")).to_be_enabled()
|
||||
expect(page.locator("#send-label")).to_have_text("Stop")
|
||||
|
||||
# Switch to the RAG view NOW — mid-pause, still before the first
|
||||
# content token.
|
||||
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"
|
||||
expect(page.locator("#docs-tbody tr").first).to_be_visible(timeout=15_000)
|
||||
|
||||
# Let the 4s pre-token pause elapse WHILE the RAG view is up — the
|
||||
# first content frames land while the chat view is still hidden —
|
||||
# then return to the chat: the surviving reader completes the
|
||||
# answer.
|
||||
page.wait_for_timeout(4500)
|
||||
page.click('a.nav-link[href="/"]')
|
||||
expect(page).to_have_url(app_url + "/")
|
||||
|
||||
# The answer COMPLETED (full text — the deterministic mock answer),
|
||||
# no error banner.
|
||||
bubble = page.locator(".msg.brain .bubble").last
|
||||
expect(bubble).to_contain_text(MOCK_ANSWER_MARKER, timeout=30_000)
|
||||
_no_error_banner(page)
|
||||
|
||||
# Storage: EXACTLY ONE brain turn — the completed answer with done
|
||||
# metadata (no partial, no orphan, no duplicate).
|
||||
page.wait_for_timeout(500)
|
||||
msgs = _stored_parsed(page)["messages"]
|
||||
assert [m["who"] for m in msgs] == ["user", "brain"]
|
||||
assert MOCK_ANSWER_MARKER in msgs[1]["text"]
|
||||
assert msgs[1]["deflected"] is False
|
||||
assert any(s["path"] == "homelab/kubernetes.md" for s in msgs[1]["sources"])
|
||||
|
||||
# The turn settled — one finalized row (a cancelled turn would
|
||||
# leave no row at all).
|
||||
assert _query_log_count() == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. REAL departure BEFORE the first answer token (pure thinking): nothing
|
||||
# brain-side is persisted — the pre-token no-orphan convention,
|
||||
# unchanged (a direct page.goto to /sources.html REMAINS a real
|
||||
# departure in the SPA — the shell is served, the fetch is aborted
|
||||
# by the unload)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -270,8 +447,9 @@ def test_no_orphan_brain_message_when_navigated_before_first_token(
|
||||
expect(page.locator("#send-btn")).to_be_enabled()
|
||||
expect(page.locator("#send-label")).to_have_text("Stop")
|
||||
|
||||
# Leave during the pause (no answer token has streamed — acc is empty,
|
||||
# so the pagehide save point must persist nothing brain-side).
|
||||
# Leave during the pause via a REAL cross-document departure (no
|
||||
# answer token has streamed — acc is empty, so the pagehide save
|
||||
# point must persist nothing brain-side).
|
||||
page.goto(app_url + "/sources.html")
|
||||
|
||||
# Return to the chat.
|
||||
@@ -294,8 +472,9 @@ def test_no_orphan_brain_message_when_navigated_before_first_token(
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Completed turn: the done save point is byte-identical to before —
|
||||
# the new pagehide save point must not duplicate or alter it
|
||||
# 5. Completed turn: the done save point is byte-identical to before —
|
||||
# the new pagehide save point must not duplicate or alter it (the
|
||||
# direct gotos are real departures — unaffected by the fold)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -337,7 +516,7 @@ def test_completed_turn_unaffected(
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. The DELIBERATE clear is untouched: New chat (chat-page only since
|
||||
# 6. The DELIBERATE clear is untouched: New chat (chat-page only since
|
||||
# the owner rework 2026-08-28) still clears the conversation
|
||||
# (phase 14 contract)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -94,7 +94,9 @@ def test_sources_page_sub_describes_the_current_source_model(
|
||||
model. The page is anonymously viewable — the catalog gate hides
|
||||
the table, not the page-head."""
|
||||
page.goto(f"{app_url}/sources.html")
|
||||
sub = page.locator(".page-sub").first
|
||||
# Phase 76 (task 02): the shell carries the hidden tuning view's
|
||||
# .page-sub earlier in the DOM — scope to the RAG view.
|
||||
sub = page.locator("#view-rag .page-sub")
|
||||
expect(sub).to_be_visible()
|
||||
text = sub.inner_text().lower()
|
||||
assert "homelab" not in text, f"retired copy in the page-sub: {text!r}"
|
||||
|
||||
@@ -46,10 +46,12 @@ QUESTION = "How is my Kubernetes cluster set up?"
|
||||
MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E"
|
||||
NOTE = "STEEER-MARKER be concise"
|
||||
XSS_NOTE = "<script>window.__xss = true; alert('xss')</script>"
|
||||
#: Both index.html and tuning.html ship exactly three classic/module
|
||||
#: script tags: the phase-39 brand.js classic layer + markdown.js + the
|
||||
#: page module (app.js / tuning.js).
|
||||
BASE_SCRIPT_COUNT = 3
|
||||
#: The shell (index.html — served for BOTH / and /tuning.html since
|
||||
#: phase 76 task 01, when the Tuning view folded into it) ships exactly
|
||||
#: FOUR classic/module script tags: the phase-39 brand.js classic layer
|
||||
#: + markdown.js + the chat module (app.js) + the shell router
|
||||
#: (router.js, which lazy-imports the tuning.js view module).
|
||||
BASE_SCRIPT_COUNT = 4
|
||||
|
||||
|
||||
async def _import_fixtures(mock_port: int) -> ImportSummary:
|
||||
|
||||
Reference in New Issue
Block a user