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

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

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

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

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

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

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

336 lines
13 KiB
Python

"""Phase 48 E2E (Playwright): stop / cancel an in-flight answer.
Source: ``TODO.md`` L3 — "Need a way to stop or cancel generation of text
in the chat" (no user story file — TODO-derived phase).
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_stop_generation.py -v --no-cov
Mock-only, no admin login (chat is public). The owner-locked contract
(2026-08-29) under test:
* while a turn is in flight the Send button is the enabled **Stop**
control ("Stop" label, ``.is-stop`` rose treatment) — a click *or* an
Enter in the focused input stops the turn;
* a mid-stream stop keeps the partial answer on screen, shows the
``.stopped-note`` "Stopped" marker, no error banner, and persists the
partial with the optional ``stopped: true`` marker in ``bor.chat.v1``
(a reload restores it, conversation order intact);
* a pre-token stop leaves only the question in the conversation (no
brain bubble, no brain-side record — phase-20 convention);
* the server tears the model stream down on the client disconnect and
writes **no** ``query_log`` row for a cancelled turn (unit-proven in
``tests/unit/test_chat_cancel.py``; asserted here against the live
database after the browser-side stop settles).
Determinism: the mock streams 12 chars / 0.02 s, so the ~900-word long
answer ("write a long answer" trigger, the phase-11 on-topic phrasing)
takes ~8 s — a comfortable stop window; the pre-token window is the
mock's 3 s "pretend to think slowly" warm-up (phase-06 phrasing). The
tests wait for observable states only (the one sanctioned 1.5 s
re-read is the partial-stability check from the task).
"""
from __future__ import annotations
import asyncio
import json
import re
import time
from pathlib import Path
from threading import Thread
from typing import Any
from playwright.sync_api import 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
REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "docs"
#: On-topic question + the mock's long-answer trigger — the phase-11
#: phrasing, so the honesty gate is HIGH and the ~900-word answer
#: streams for ~8 s (12 chars / 0.02 s): the stop window.
LONG_QUESTION = "How is my Kubernetes cluster set up? write a long answer"
#: Phase-06 phrasing: the mock's 3 s warm-up before the first token —
#: the observable pre-token window.
SLOW_QUESTION = "pretend to think slowly then tell me about kubernetes"
SLOW_QUESTION_2 = "pretend to think slowly then tell me about backups"
#: The long answer's unique final line — absent from any partial.
LONG_ANSWER_END = "LONG-ANSWER-END"
STORAGE_KEY = "bor.chat.v1"
# The typing indicator is itself a .msg.brain — exclude its bubble.
ANSWER = ".msg.brain .bubble:not(.typing)"
# ---------------------------------------------------------------------------
# KB seeding (same pattern as the phase 02/03/06/14 story suites)
# ---------------------------------------------------------------------------
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:
with SessionLocal() as db:
db.execute(text("TRUNCATE chunks, documents, query_log"))
db.commit()
return _run_in_thread(_import_fixtures(mock_port))
def _query_log_count() -> int:
with SessionLocal() as db:
return db.execute(text("SELECT count(*) FROM query_log")).scalar_one()
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 _assert_no_error_banner(page: Page) -> None:
"""A stop settles to idle through the same finally path — never the
red role=alert error banner (the KB-offline banner is a separate,
health-driven state the db_ready fixture keeps away)."""
banner = page.locator("#kb-banner")
expect(banner).to_be_hidden()
expect(banner).not_to_have_attribute("role", "alert")
expect(banner).not_to_have_class(re.compile(r"is-error"))
# ---------------------------------------------------------------------------
# Shared flows
# ---------------------------------------------------------------------------
def _stop_mid_stream(page: Page) -> str:
"""Ask the long on-topic question and Stop it once a few words of
answer have streamed. Returns the rendered partial text (stable
after the stop)."""
page.fill("#message-input", LONG_QUESTION)
page.click("#send-btn")
# First deltas: the brain bubble exists and grows past a few words.
answer = page.locator(ANSWER)
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 stop"
# In flight: the button IS the enabled Stop control.
btn = page.locator("#send-btn")
expect(btn).to_be_enabled()
expect(page.locator("#send-label")).to_have_text("Stop")
expect(btn).to_have_class(re.compile(r"is-stop"))
# Stop it.
btn.click()
# Settled to idle: Send again, stop treatment gone, no error banner.
expect(page.locator("#send-label")).to_have_text("Send", timeout=5_000)
expect(btn).to_be_enabled()
expect(btn).not_to_have_class(re.compile(r"is-stop"))
_assert_no_error_banner(page)
# The partial is kept on screen — real answer text, and far shorter
# than the mock's full long answer (its unique final line never
# arrived).
stopped_text = answer.inner_text()
assert stopped_text.strip()
assert "Step 1:" in stopped_text
assert LONG_ANSWER_END not in stopped_text
# Stable: no further growth ~1.5 s after the stop.
page.wait_for_timeout(1_500)
assert answer.inner_text() == stopped_text, (
"the partial answer kept growing after the stop"
)
return stopped_text
def _stop_pre_token(page: Page, question: str, via_enter: bool) -> None:
"""Ask a slow question and stop it during the 3 s pre-token warm-up —
by clicking the Stop control, or by pressing Enter in the focused
input (owner-locked: click *or* Enter)."""
page.fill("#message-input", question)
if via_enter:
page.keyboard.press("Enter") # the fill focused the input; it submits
else:
page.click("#send-btn")
# In the warm-up window the button reads Stop (thinking state).
expect(page.locator("#send-label")).to_have_text("Stop", timeout=5_000)
if via_enter:
# The input kept focus through the submit — Enter stops the turn.
expect(page.locator("#message-input")).to_be_focused()
page.keyboard.press("Enter")
else:
page.locator("#send-btn").click()
# Settled to idle: no error banner, no brain bubble at all (the typing
# indicator is gone with it), the question survived, focus is back.
expect(page.locator("#send-label")).to_have_text("Send", timeout=5_000)
btn = page.locator("#send-btn")
expect(btn).to_be_enabled()
expect(btn).not_to_have_class(re.compile(r"is-stop"))
_assert_no_error_banner(page)
expect(page.locator(".msg.brain")).to_have_count(0)
expect(page.locator(ANSWER)).to_have_count(0)
expect(page.locator(".msg.user .bubble").last).to_contain_text(question)
expect(page.locator("#message-input")).to_be_focused()
# ---------------------------------------------------------------------------
# 1. Mid-stream stop: partial kept + "Stopped" note + stopped persistence
# ---------------------------------------------------------------------------
def test_stop_mid_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 # A9 formats (phase 47 added quadlet+j2)
page.set_default_timeout(30_000)
login(page, app_url, next="/")
stopped_text = _stop_mid_stream(page)
# The bubble keeps the partial and carries the "Stopped" note.
expect(page.locator(ANSWER)).to_contain_text(stopped_text)
note = page.locator(".msg.brain .stopped-note")
expect(note).to_have_count(1)
expect(note.first).to_contain_text("Stopped")
# No sources on a stopped turn — it never settled.
expect(page.locator(".msg.brain .source-chip")).to_have_count(0)
# The live region confirms the stop (no error wording).
expect(page.locator("#send-status")).to_contain_text("Answer stopped.")
# Persistence: question, then the partial with the optional
# ``stopped`` marker (raw text — no HTML, no LONG-ANSWER-END).
stored = _stored_parsed(page)
assert [m["who"] for m in stored["messages"]] == ["user", "brain"]
assert stored["messages"][0]["text"] == LONG_QUESTION
last = stored["messages"][-1]
assert last["stopped"] is True
assert last["text"].strip()
assert "Step 1:" in last["text"]
assert LONG_ANSWER_END not in last["text"]
# Server side: the cancelled turn wrote no durable record (the
# stability wait above already gave the teardown its margin).
assert _query_log_count() == 0
# ---------------------------------------------------------------------------
# 2. Pre-token stop: question kept, nothing brain-side (click and Enter)
# ---------------------------------------------------------------------------
def test_stop_pre_token(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db(mock_llm)
page.set_default_timeout(30_000)
login(page, app_url, next="/")
_stop_pre_token(page, SLOW_QUESTION, via_enter=False)
# The conversation holds exactly the question — no brain-side record.
stored = _stored_parsed(page)
assert [m["who"] for m in stored["messages"]] == ["user"]
assert stored["messages"][0]["text"] == SLOW_QUESTION
assert "stopped" not in stored["messages"][0]
# The owner-locked "click *or* Enter": the same pre-token stop, this
# time submitted and stopped through the focused input's Enter.
_stop_pre_token(page, SLOW_QUESTION_2, via_enter=True)
expect(page.locator(".msg.user .bubble")).to_have_count(2)
expect(page.locator(".msg.brain")).to_have_count(0)
stored = _stored_parsed(page)
assert [m["who"] for m in stored["messages"]] == ["user", "user"]
assert stored["messages"][-1]["text"] == SLOW_QUESTION_2
# Server side: neither pre-token stop left a durable record. The UI
# settle was observed above; the short margin covers the server's
# disconnect teardown (mock cadence 0.02 s).
page.wait_for_timeout(500)
assert _query_log_count() == 0
# ---------------------------------------------------------------------------
# 3. A stopped turn survives a reload
# ---------------------------------------------------------------------------
def test_stopped_turn_survives_reload(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db(mock_llm)
page.set_default_timeout(30_000)
login(page, app_url, next="/")
stopped_text = _stop_mid_stream(page)
assert _query_log_count() == 0
page.reload()
# Restored: the user message first, then the stopped brain bubble —
# the same partial text, the "Stopped" note, and the idle Send button.
expect(page.locator("#empty-state")).to_be_hidden()
msgs = page.locator("#messages > .msg")
expect(msgs).to_have_count(2)
expect(msgs.nth(0)).to_have_class(re.compile(r"msg user"))
expect(msgs.nth(1)).to_have_class(re.compile(r"msg brain"))
expect(page.locator(".msg.user .bubble")).to_contain_text(LONG_QUESTION)
bubble = page.locator(ANSWER)
expect(bubble).to_have_count(1)
assert bubble.inner_text() == stopped_text, "the restored partial differs"
note = page.locator(".msg.brain .stopped-note")
expect(note).to_have_count(1)
expect(note.first).to_contain_text("Stopped")
expect(page.locator("#send-label")).to_have_text("Send")
expect(page.locator("#send-btn")).to_be_enabled()
expect(page.locator("#send-btn")).not_to_have_class(re.compile(r"is-stop"))
# The marker still rides the stored record after the restore.
stored = _stored_parsed(page)
assert [m["who"] for m in stored["messages"]] == ["user", "brain"]
assert stored["messages"][-1]["stopped"] is True