Files
brain-of-reese/tests/e2e/test_loading_feedback.py
T
ducoterra bc70ce36e0 feat(chat): render markdown tables in answers, viewer, and thinking
GFM pipe tables in the shared renderer (TODO.md L6): a table-protection
pass in frontend/assets/markdown.js (fences -> tables -> escape order)
pulls each header+separator+body block out as a placeholder, renders
cells escape-first with the same inline transforms, and reinserts a
semantic <table class="md-table"> inside a horizontal-overflow
.md-table-wrap — so a pipe table in a chat answer, the document
viewer/modal, and the thinking block all render the same semantic
table. Fences win over tables; lone pipes stay text.

- styles.css: .md-table palette rules (PLAN §7.2 tokens, no motion);
  min-width: max-content so a WIDE table keeps its natural width and
  the wrapper is the real scroller (width:100% alone wrapped the wide
  table's cells — proven by the new E2E).
- mock_llm.py: TABLE_TRIGGER ("show me a table") -> byte-stable
  TABLE_ANSWER (3-column table, <img onerror> XSS probe line, wide
  5-column table), checked before DEFLECT_MODE like SUMMARY_MODE.
- tests/fixtures/docs/homelab/tables.md: 3x3 pipe table + pipe-heavy
  fenced block (viewer/fence subject); the shared fixture set grows
  8 -> 9 docs, so every suite pinning the count (added/formats/
  stat-docs/EXPECTED_ROWS) is updated accordingly.
- tests/e2e/test_markdown_tables.py (new, story suite): chat table
  shape + non-deflection, wide-table wrapper scroll (no page
  overflow), XSS probe inert, viewer modal table, fence-not-a-table,
  lone pipe stays text.
- tests/e2e/test_agent_document_tools.py: fix a pre-existing flake —
  the "Calling tool…" label window is ~0.4 s at the mock's 0.1 s
  tool-frame pacing, and a polling expect could stride over it
  (failed 3 of 5 runs on the committed baseline). The pre-submit
  MutationObserver record is the deterministic source of truth; the
  racy to_have_text gate is gone.

uv run pytest: 738 passed, app/ coverage 99% (TOTAL unchanged);
ruff + pyright clean; story E2E 6/6 in isolation; regression E2E
suites (chat_rag, document_viewer, document_summaries, smoke) green.
2026-08-28 03:35:50 -04:00

312 lines
11 KiB
Python

"""Phase 06 E2E (Playwright): loading feedback & progress.
Story: ``.agent/user_stories/loading-feedback.md``
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_loading_feedback.py -v --no-cov
Determinism comes from two mock-LLM behaviors (tests/e2e/mock_llm.py):
* ``pretend to think slowly`` in the user message → a 3s warm-up before
the first token, wide enough to assert the pre-token UI (typing dots +
busy "Thinking…" button) at a known timestamp;
* the ``POST /__shutdown__`` hook → the ``llm_down`` fixture stops the
shared mock to simulate an LLM outage, then restores a fresh instance
on the same port so later tests keep working.
The state machine under test lives in frontend/assets/app.js:
``idle → thinking → streaming → done | error → idle`` (PLAN §7.4).
"""
from __future__ import annotations
import asyncio
import os
import re
import subprocess
import sys
import time
from collections.abc import Iterator
from pathlib import Path
from threading import Thread
from typing import Any
import httpx
import pytest
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
REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "docs"
MOCK_PORT = int(os.environ.get("E2E_MOCK_PORT", "8901"))
# The mock warms up for 3s when the question contains this marker, so the
# pre-token window is observable. (Retrieval may answer or deflect — the
# feedback states are identical either way.)
SLOW_QUESTION = "pretend to think slowly then tell me about kubernetes"
ON_TOPIC = "How is my Kubernetes cluster set up?"
TYPING = "#typing-indicator"
# 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 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, seed: bool) -> ImportSummary | None:
"""Truncate the KB (and query log), then optionally re-import fixtures."""
with SessionLocal() as db:
db.execute(text("TRUNCATE chunks, documents, query_log"))
db.commit()
if not seed:
return None
return _run_in_thread(_import_fixtures(mock_port))
# --------------------------------------------------------------------------
# LLM-down fixture: stop the shared mock, restore it for later tests
# --------------------------------------------------------------------------
_replacement_mocks: list[subprocess.Popen] = []
@pytest.fixture(scope="session")
def _cleanup_replacement_mocks() -> Iterator[None]:
"""Terminate mock instances spawned to replace a stopped one."""
yield
for proc in _replacement_mocks:
proc.terminate()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
def _wait_http(url: str, timeout: float = 40.0) -> None:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
httpx.get(url, timeout=2.0)
return
except Exception: # noqa: BLE001 — retry until deadline
time.sleep(0.5)
raise RuntimeError(f"server at {url} did not come up")
def _spawn_mock() -> subprocess.Popen:
env = dict(os.environ)
env.pop("DEBUGPY", None)
return subprocess.Popen(
[sys.executable, "-m", "uvicorn", "tests.e2e.mock_llm:app",
"--host", "127.0.0.1", "--port", str(MOCK_PORT), "--log-level", "warning"],
cwd=REPO,
env=env,
)
@pytest.fixture()
def llm_down(mock_llm: int, _cleanup_replacement_mocks: None) -> Iterator[None]:
"""Simulate the LLM going down for one test (stop the shared mock via
its ``__shutdown__`` hook), then restore a fresh instance on the same
port so the rest of the session keeps working."""
r = httpx.post(f"http://127.0.0.1:{MOCK_PORT}/__shutdown__", timeout=10)
assert r.status_code == 200
stopped = False
deadline = time.monotonic() + 15
while time.monotonic() < deadline:
try:
httpx.get(f"http://127.0.0.1:{MOCK_PORT}/v1/models", timeout=1.0)
except Exception: # noqa: BLE001 — connection refused == stopped
stopped = True
break
time.sleep(0.2)
assert stopped, "mock LLM did not stop in time"
yield
proc = _spawn_mock()
_replacement_mocks.append(proc)
_wait_http(f"http://127.0.0.1:{MOCK_PORT}/v1/models")
# --------------------------------------------------------------------------
# Tests (story → test mapping, .agent/user_stories/loading-feedback.md)
# --------------------------------------------------------------------------
def test_typing_indicator_during_slow_think(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
"""AC1/AC5: the 3s mock warm-up must show the typing indicator for
>=2s before any text appears, then it is gone once the answer lands."""
summary = _reset_db(mock_llm, seed=True)
assert summary is not None and summary.added == 9 # A9 formats (phase 44 added tables.md)
page.set_default_timeout(30_000)
page.goto(app_url)
page.fill("#message-input", SLOW_QUESTION)
page.click("#send-btn")
# Visible within 500ms of submit — the indicator is added synchronously
# by setUiState("thinking").
typing = page.locator(TYPING)
expect(typing).to_be_visible(timeout=500)
bubble = typing.locator(".bubble")
expect(bubble).to_have_attribute("role", "status")
expect(bubble).to_have_attribute("aria-label", "Brain of Reese is thinking")
# ~2s in: still pre-token (the mock is in its 3s warm-up).
page.wait_for_timeout(2000)
assert typing.is_visible(), "typing indicator must persist through the pre-token window"
# First token lands (~3s): dots gone, answer text present.
answer = page.locator(ANSWER)
answer.wait_for(state="visible", timeout=30_000)
expect(typing).to_be_hidden()
assert answer.inner_text().strip(), "answer bubble must contain text"
def test_button_state_machine(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
"""AC1/AC3: disabled + spinner + 'Thinking…' while in flight; enabled
+ 'Send' + focused input after done."""
_reset_db(mock_llm, seed=True)
page.set_default_timeout(30_000)
page.goto(app_url)
page.fill("#message-input", SLOW_QUESTION)
page.click("#send-btn")
btn = page.locator("#send-btn")
label = page.locator("#send-label")
expect(btn).to_be_disabled(timeout=500)
expect(label).to_have_text("Thinking…")
expect(btn.locator(".spinner")).to_be_visible()
expect(page.locator("#send-status")).to_contain_text("thinking")
# Done: button recovers and the input is focused back.
page.locator(ANSWER).wait_for(state="visible", timeout=30_000)
expect(label).to_have_text("Send", timeout=30_000)
expect(btn).to_be_enabled()
expect(btn.locator(".spinner")).to_be_hidden()
expect(page.locator("#message-input")).to_be_focused()
def test_streaming_appends_live(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
"""AC2: text appends live — a sample taken mid-stream must be strictly
shorter than a later one (no 'whole answer appears at once')."""
_reset_db(mock_llm, seed=True)
page.set_default_timeout(30_000)
page.goto(app_url)
page.fill("#message-input", ON_TOPIC)
page.click("#send-btn")
answer = page.locator(ANSWER)
answer.wait_for(state="visible", timeout=30_000)
first = answer.inner_text()
assert first.strip(), "bubble should carry text as soon as it appears"
second = first
deadline = time.monotonic() + 15
while time.monotonic() < deadline:
second = answer.inner_text()
if len(second) > len(first):
break
time.sleep(0.05)
assert len(second) > len(first), (
"answer text never grew after the first sample — no live streaming visible"
)
# The turn settles: button back to 'Send', final text no shorter.
expect(page.locator("#send-label")).to_have_text("Send", timeout=30_000)
final = answer.inner_text()
assert len(final) >= len(second)
def test_reduced_motion_keeps_feedback(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
"""AC7: under prefers-reduced-motion the dots/spinner stay visible
(static/slower) — feedback is never removed, only calmed."""
_reset_db(mock_llm, seed=True)
page.emulate_media(reduced_motion="reduce")
page.set_default_timeout(30_000)
page.goto(app_url)
page.fill("#message-input", SLOW_QUESTION)
page.click("#send-btn")
expect(page.locator(TYPING)).to_be_visible(timeout=500)
expect(page.locator(TYPING).locator(".bubble span")).to_have_count(3)
expect(page.locator("#send-btn .spinner")).to_be_visible()
# And the turn still completes and recovers.
expect(page.locator("#send-label")).to_have_text("Send", timeout=30_000)
expect(page.locator("#send-btn")).to_be_enabled()
expect(page.locator(TYPING)).to_be_hidden()
def test_error_banner_on_llm_down(
page: Page, app_url: str, mock_llm: int, db_ready: None, llm_down: None
) -> None:
"""AC4a: with the LLM down the turn ends in the red role=alert banner
with the actionable retry hint, and the button recovers (never a
zombie)."""
page.set_default_timeout(30_000)
page.goto(app_url)
page.fill("#message-input", ON_TOPIC)
page.click("#send-btn")
banner = page.locator("#kb-banner")
expect(banner).to_be_visible(timeout=30_000)
expect(banner).to_have_attribute("role", "alert")
expect(banner).to_have_class(re.compile(r"kb-banner.*is-error"))
expect(banner).to_contain_text("Try again")
expect(banner).to_contain_text("check the LLM is reachable")
# Recovery: button enabled, label 'Send', no lingering indicator.
expect(page.locator("#send-btn")).to_be_enabled(timeout=30_000)
expect(page.locator("#send-label")).to_have_text("Send")
expect(page.locator(TYPING)).to_be_hidden()