Phase 47 (owner permission 2026-08-27, TODO.md L10–11, roadmap R1): the
full Podman quadlet family (.container, .network, .volume, .image,
.pod, .kube, .swap, .os, .endpoint) and .j2 Jinja templates join the
allowed + default A9 import formats, chunked as plain text (owner
decision — no TOML/Jinja-aware splitter). No env configuration needed:
a default import now indexes them.
- app/config.py: _ALLOWED_IMPORT_EXTENSIONS + the default
import_extensions CSV gain the ten names (the original seven first);
the never-widen BOR_IMPORT_EXTENSIONS validator is untouched and
still rejects truly unknown extensions.
- app/rag/chunker.py: ten _FORMAT_CHUNKERS entries -> chunk_text
(HARD_MAX_CHARS 1200 honored, unknown-suffix fallback unchanged);
docstring/comments cite the A9 revision 2026-08-27.
- tests/fixtures/docs/homelab/: quadlet/compose.container (realistic
quadlet TOML, >1500 chars, [Unit]/[Service]/[Container] sections,
RESE-QUADLET-SENTINEL-77aa), quadlet/lan.network,
quadlet/cache.volume, templates/deploy.j2 (for/set/if Jinja
constructs + RESE-JINJA-SENTINEL-33dd). Every suite that seeds the
fixture tree updates its 9 -> 13 document-count constants.
- tests/unit/test_config.py: allowed set carries all seventeen formats,
default CSV + dotted import_extension_set include the ten, the
validator accepts the new names and still rejects unknowns.
- tests/unit/test_chunker.py: dispatch parity with chunk_text for every
new suffix (parametrized), the .container fixture chunks >=2 under
the cap with the sentinel surviving, the .j2 fixture keeps {{ }}
verbatim, the unknown-suffix fallback is unchanged.
- tests/unit/test_importer.py: a default-extensions walk over a temp
tree indexes exactly the ten new files (unknown/hidden/excluded
filtered), the original seven still walk, stem-title fallback holds.
- tests/integration/test_import_quadlet_jinja.py (new): import_sources
over a temp tree with .container/.volume/.j2 -> documents + chunks
rows with stem titles; delta re-import updates only the changed .j2
doc; prune drops the deleted .volume doc with cascade.
- tests/e2e/test_quadlet_jinja_import.py (new, story suite, mock-only,
isolation): GET /api/docs (admin session) lists the four new-format
docs with non-zero chunk counts and stem titles; the Sources table
renders a row + .doc-link per file; the phase-26 modal shows the
.container TOML ([Container] section + sentinel) with stem title and
the container format badge; a RESE-JINJA-SENTINEL-33dd question
FTS-matches the .j2 chunk -> honest-positive (A8: LOW requires zero
FTS hits) — the bubble is not .is-deflected and a source chip names
templates/deploy.j2.
- README.md + .env.example: the extended default format set (A9
revised 2026-08-27, plain-text chunking, narrow-only rule intact).
- .agent/PLAN.md: the A9 revision (owner-locked R1) — A9 row status,
the revision note under the anchors table, and the §5 chunking-policy
+ §11 workflow lines. The only PLAN edit this phase.
Gates: uv run pytest 795 passed; app/ coverage TOTAL 99% (>90%);
ruff check + pyright clean; story E2E 4/4 in isolation (DB up);
regression E2E suites test_import_documents (3) / test_sync_button
(3) / test_git_sources_admin (6) green in isolation.
Also records the 47_quadlet_jinja_import task-file moves (01–03)
todo/ -> complete/.
312 lines
11 KiB
Python
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 == 13 # A9 formats (phase 47 added quadlet+j2)
|
|
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()
|