Files
brain-of-reese/tests/e2e/test_suggestion_chips.py
T
ducoterra 1f1c01c9f7
Build and Push Containers / build-and-push-app (push) Successful in 2m32s
Build and Push Containers / build-and-push-db (push) Successful in 12s
phase: 103_suggestions_session_openers
Phase 103 final verification pass — all green.

**Verified (all 3 tasks already in `complete/`; no code changes needed):**
- `opening_questions` in `app/api/suggestions.py` — forward walk, one opener per chat (first non-blank user msg, A3), reads raw `messages` not `title` (A4), phase-80 order/dedup/cap/seed contracts; `last_questions` name gone from `app/`+`tests/`
- Docs updated: `app/config.py` seed docstring, `.env.example` `BOR_SUGGESTIONS`, `README.md` — "session openers" wording
- Diff scope correct: only the 6 expected files + phase-file moves; `app/rag/suggestions.py` and `frontend/` untouched

**Test / lint / coverage results:**
- `uv run pytest tests/integration/test_suggestions_api.py -v` → 12 passed
- `uv run pytest tests/e2e/test_suggestion_chips.py -v --no-cov` → 8 passed in isolation (opener-only core pin included)
- `test_responsive_polish.py` → 7 passed; `test_chat_persistence.py` → 4 passed (both isolated, no edits)
- `uv run pytest --cov=app --cov-report=term-missing` → 2086 passed, TOTAL 99% (>90%); `app/api/suggestions.py` 100%
- `uv run ruff check .` → clean; `uv run pyright` → 0 errors, 0 warnings

**Completion criteria:** all 7 ✅ (follow-ups-never-surface pin; cap-across-chats pin; seed/dedup/case/partial/A3/401 pins; E2E suites isolated; deflection chips unchanged; full suite + lint; commit + dir move left to harness per executor rules).

**Deviations:** none — no defects found; nothing changed in this pass.
**Next pending phase:** `98_sync_summary_visibility` (numeric order in `todo/`).
2026-09-12 16:37:50 -04:00

537 lines
22 KiB
Python

"""Phase 103 E2E (Playwright): onboarding chips = the session openers.
Story: ``.agents/user_stories/suggestion-chips.md`` (phase 05) —
REWRITTEN in place for the phase-103 semantics (the phase-76/80
precedent: a semantic change rewrites the story suite in place).
Source: owner request 2026-09-12 — a suggested question must make
sense on its own, and a follow-up never does.
The new contract (owner decision A1): the chips are the **session
openers** — each saved chat contributes AT MOST ONE chip: its FIRST
non-blank user message, the question that OPENED the session. Follow-up
questions can NEVER surface: a follow-up like "What about qwen 3.6
35b?" (asked after "What are the correct arguments for qwen 3.8 27b on
llama.cpp?") is meaningless as a conversation starter without the
session behind it. Everything else is the phase-80 contract, unchanged:
chats are walked newest-``updated_at`` first, openers are exact
(case-sensitive) de-duplicated, cap 3 — the cap binds ACROSS chats. A
fresh deployment — zero saved openers — gets the SEED list instead
(``BOR_SUGGESTIONS`` / the built-in default). The row refetches when
the empty state comes back (New chat), so it is never stale. The
deflection "Maybe try" chips are a separate contract
(``derive_suggestions``) — untouched.
The states pinned here:
* **seed** (unchanged) — fresh DB (no saved chats) → the chip texts
equal the built-in default list EXACTLY (the ``SEED`` literal below
is the pin for the exact seed list — ``tests/unit/test_config.py``
pins only the shape) — rendered as accessible buttons in the
role=list group, exactly as the phase-05 component contract;
* **opener-only** (the NEW core state — the owner's exact scenario) —
ONE saved chat with a 3-turn conversation (the opener Q1, the
follow-up Q2, the follow-up Q3, brain replies between) → a fresh
page load shows EXACTLY ONE chip: Q1 (the opener); Q2/Q3 are absent;
* **three-openers** (replaces the old "last-3" state) — THREE saved
chats, each multi-turn (opener + at least one follow-up), DISTINCT
``updated_at`` (the API stamps them on save — the test saves
oldest→newest) → exactly 3 chips = the three openers, newest
``updated_at`` first; none of the chats' FOLLOW-UPS appears;
* **partial** (kept, re-scoped) — exactly 2 saved (multi-turn) chats →
exactly 2 chips (the two openers — NO seed top-up; the follow-ups in
those chats do not pad the row);
* **refetch** (kept) — boot with the seed chips → save a multi-turn
chat (opener Q + a follow-up) via the API → click New chat
(``#new-chat-btn``) → the chips now are exactly Q, and the request
log shows a SECOND ``GET /api/suggestions`` (the boot fetch was the
first).
Carried-over story behavior (unchanged semantics from the phase-05/80
suites): one-tap submit (chip click → composer filled → submitted →
the mock-LLM brain bubble with the ``MOCK_ANSWER_MARKER``), Tab+Enter
keyboard reachability of the chips (the keyboard-walk assertion), and
the mobile single horizontal-scroll row.
The endpoint is authed (phase 79, ``require_user``), so every test
signs in as admin first (``auth_helpers.login``). ``saved_chats`` is
global state on the shared e2e Postgres AND the state this contract
reads — the autouse fixture truncates it before and after EVERY test
(including the ones whose turns auto-save a row), so each test starts
from — and leaves — an empty deployment.
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_suggestion_chips.py -v --no-cov
"""
from __future__ import annotations
import asyncio
import json
import time
from collections.abc import Iterator
from pathlib import Path
from threading import Thread
from typing import Any
import pytest
from playwright.sync_api import Browser, Page, Request, 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"
MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E"
#: The EXACT built-in onboarding SEED (phase 80, TODO.md L6): the chip
#: row of a brand-new deployment, shown only while no saved chat has
#: ever opened with a question. This literal is the E2E pin for the
#: exact seed list — ``tests/unit/test_config.py`` pins only the SHAPE
#: (>=3 non-blank distinct strings), and the e2e app under test is
#: forced to the code default by conftest's leak guard — keep in sync
#: with the ``Settings.suggestions`` default in ``app/config.py``.
SEED: list[str] = [
"What documents are in the knowledge base?",
"Which source does each answer come from?",
"How do I add a new source?",
"Summarize the most recent document.",
]
@pytest.fixture(autouse=True)
def clean_chats(db_ready: None) -> Iterator[None]:
"""``saved_chats`` is the state the phase-103 contract reads:
truncate it before and after every test so each state test starts
from (and leaves) an empty deployment. Unlike the KB tables, this
reset is non-optional — the chips ARE these rows' openers, and the
carried-over submit tests auto-save a row per turn, which would
otherwise leak into the later state tests."""
with SessionLocal() as db:
db.execute(text("TRUNCATE saved_chats"))
db.commit()
yield
with SessionLocal() as db:
db.execute(text("TRUNCATE saved_chats"))
db.commit()
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 owns the test loop)."""
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 _seed_kb(mock_port: int) -> ImportSummary:
"""Deterministic KB: truncate the KB tables, import the fixture
docs (needed by the carried-over submit tests' grounded answers).
``saved_chats`` is the autouse fixture's job."""
with SessionLocal() as db:
db.execute(text("TRUNCATE chunks, documents, query_log"))
db.commit()
summary = _run_in_thread(_import_fixtures(mock_port))
assert summary is not None and summary.added == 13 # A9 formats (phase 47 added quadlet+j2)
return summary
def _user(q: str) -> dict[str, Any]:
return {"who": "user", "text": q}
def _brain(text: str = "Grounded mock brain reply.") -> dict[str, Any]:
return {"who": "brain", "text": text}
def _save_chat(page: Page, app_url: str, messages: list[dict[str, Any]]) -> dict[str, Any]:
"""Save one conversation as the signed-in admin (``POST /api/chats``)
and return the 201 body. ``updated_at`` is the API's stamp (server
``now()`` at INSERT) — the save ORDER is what makes the chip-walk
order deterministic in the state tests."""
r = page.request.post(
f"{app_url}/api/chats",
data=json.dumps({"messages": messages}),
headers={"Content-Type": "application/json"},
timeout=10_000,
)
assert r.status == 201, r.text
return r.json()
def _api_suggestions(page: Page, app_url: str) -> list[str]:
# Phase 79: the endpoint is require_user-gated — the request rides
# the page's signed-in context (each test signs in above).
r = page.request.get(f"{app_url}/api/suggestions", timeout=10)
assert r.status == 200, r.text
return r.json()["suggestions"]
def _chip_locator(page: Page) -> Any:
return page.locator("#suggestions .suggestion-chip")
def _chip_texts(page: Page) -> list[str]:
return [c.strip() for c in _chip_locator(page).all_inner_texts()]
def test_seed_state_chips_are_the_builtin_default(
page: Page, app_url: str, db_ready: None
) -> None:
"""Fresh deployment (no saved chats) → the chip row is EXACTLY the
built-in seed list — texts, count, and order — rendered in
``#suggestions`` (role="list") as accessible buttons, exactly as
the phase-05 component contract."""
page.set_default_timeout(30_000)
login(page, app_url, next="/")
# Accessible group: role=list + a name screen readers can announce.
group = page.locator("#suggestions")
expect(group).to_have_attribute("role", "list")
expect(group).to_have_attribute("aria-label", "Suggested questions")
chips = _chip_locator(page)
expect(chips.first).to_be_visible(timeout=30_000)
# The EXACT seed list, in order (the E2E pin — see the SEED literal).
assert _chip_texts(page) == SEED
# ...drawn from the endpoint (the API returns the same exact list).
assert _api_suggestions(page, app_url) == SEED
# Real buttons, each with non-empty text, one per seed entry.
assert chips.count() == len(SEED)
for i in range(chips.count()):
chip = chips.nth(i)
expect(chip).to_be_visible()
expect(chip).to_have_attribute("type", "button")
expect(chip).to_have_attribute("role", "listitem")
assert chip.inner_text().strip(), "every chip needs non-empty label text"
# Chips live in the empty state, which is visible before any message.
expect(page.locator("#empty-state")).to_be_visible()
# Chip component contract: brand pill, >=44px touch target.
style = chips.first.evaluate("el => getComputedStyle(el)")
assert style["backgroundColor"] == "rgb(45, 10, 10)" # --brand-soft #2d0a0a (dark-red rebrand)
assert style["color"] == "rgb(252, 165, 165)" # --brand-ink #fca5a5 (dark-red rebrand)
assert style["borderRadius"] == "999px"
box = chips.first.bounding_box()
assert box is not None and box["height"] >= 44
def test_opener_only_state(page: Page, app_url: str, db_ready: None) -> None:
"""The NEW core state — the owner's exact scenario: ONE saved chat
with a 3-turn conversation (opener Q1, follow-up Q2, follow-up Q3)
→ a fresh page load shows EXACTLY ONE chip: Q1 (the opener). Q2/Q3
are absent — a follow-up like "What about …?" is meaningless as a
conversation starter without the session behind it."""
page.set_default_timeout(30_000)
login(page, app_url, next="/")
opener = "What are the correct arguments for qwen 3.8 27b on llama.cpp?"
follow_up_1 = "What about qwen 3.6 35b?"
follow_up_2 = "And which of the three needs the most VRAM?"
# ONE multi-turn chat (three user questions, brain replies between)
# — exactly the owner's session shape.
_save_chat(
page,
app_url,
[
_user(opener), _brain(),
_user(follow_up_1), _brain(),
_user(follow_up_2), _brain(),
],
)
# A FRESH page load (a new boot fetch, not the pre-save boot):
# EXACTLY ONE chip — the chat's opener, and nothing else.
page.goto(app_url + "/")
chips = _chip_locator(page)
expect(chips.first).to_be_visible(timeout=30_000)
assert chips.count() == 1, "a 3-turn chat yields EXACTLY its opener as the single chip"
texts = _chip_texts(page)
assert texts == [opener], "the single chip is the EXACT full opener text"
assert follow_up_1 not in texts, "the 'What about …?' follow-up must never chip"
assert follow_up_2 not in texts
# ...and the endpoint itself holds the same contract (same row).
assert _api_suggestions(page, app_url) == [opener]
def test_three_openers_newest_first(page: Page, app_url: str, db_ready: None) -> None:
"""THREE saved chats, each multi-turn (opener + at least one
follow-up), DISTINCT ``updated_at`` (the API stamps them on save —
the test saves oldest→newest) → a fresh page load shows EXACTLY
the three openers, newest ``updated_at`` first; none of the chats'
FOLLOW-UPS appears anywhere in the row."""
page.set_default_timeout(30_000)
login(page, app_url, next="/")
openers = [
"How did I install the GitLab runner on the Proxmox node?",
"What TLS termination does Traefik do for homelab.local?",
"Which provider is the primary DNS for reeseapps.com?",
]
follow_ups = [
"What about the runners' Docker socket access?",
"And does it terminate mTLS for the internal services?",
"What about the secondary DNS for the LAN?",
]
# Save oldest→newest: the API stamps ``updated_at`` (server
# now()), so save order IS walk order. The short pauses keep the
# three stamps strictly apart (and the assert below pins that the
# order the walk sees is the order the test intended).
stamps: list[str] = []
for opener, follow_up in zip(openers, follow_ups, strict=True):
body = _save_chat(
page,
app_url,
[
_user(opener), _brain(),
_user(follow_up), _brain(),
],
)
stamps.append(body["updated_at"])
time.sleep(0.05)
assert stamps == sorted(stamps) and len(set(stamps)) == 3, (
"the three API-stamped updated_at values must be strictly increasing"
)
# A FRESH page load (a new boot fetch, not the pre-save boot):
# exactly the three openers, newest first — the cap of 3 binds
# ACROSS chats, and every chip is a session's OPENER.
page.goto(app_url + "/")
chips = _chip_locator(page)
expect(chips.first).to_be_visible(timeout=30_000)
expected = list(reversed(openers))
texts = _chip_texts(page)
assert chips.count() == 3
assert texts == expected
assert _api_suggestions(page, app_url) == expected
for follow_up in follow_ups:
assert follow_up not in texts, "a chat's follow-up must never chip"
def test_partial_state_no_seed_topup(page: Page, app_url: str, db_ready: None) -> None:
"""Exactly 2 saved (multi-turn) chats → EXACTLY 2 chips (the two
openers, newest first) — NO mixing/top-up with the seed (the
phase-80 A6 contract, visible in the UI), and the follow-ups in
those chats do not pad the row."""
page.set_default_timeout(30_000)
login(page, app_url, next="/")
a_opener = "How do I rotate the WireGuard keys on the VPN node?"
a_follow_up = "What about the peers' allowed-ips?"
b_opener = "What cron schedule runs the restic prune?"
b_follow_up = "And where do the restic lock files live?"
_save_chat(
page,
app_url,
[_user(a_opener), _brain(), _user(a_follow_up), _brain()],
)
time.sleep(0.05)
_save_chat(
page,
app_url,
[_user(b_opener), _brain(), _user(b_follow_up), _brain()],
)
page.goto(app_url + "/")
chips = _chip_locator(page)
expect(chips.first).to_be_visible(timeout=30_000)
assert chips.count() == 2, "exactly 2 chips — the row is never padded toward 3"
texts = _chip_texts(page)
assert texts == [b_opener, a_opener]
assert a_follow_up not in texts and b_follow_up not in texts
assert not (set(texts) & set(SEED)), "no seed text may appear once a question is saved"
def test_new_chat_refetches_the_chips(page: Page, app_url: str, db_ready: None) -> None:
"""The row is never stale: boot with the seed chips → save a
multi-turn chat (opener Q + a follow-up) via the API → click New
chat (``#new-chat-btn``) → the empty state comes back with the
REFETCHED row (exactly Q — the deployment now has one saved
OPENER; the chat's follow-up is never a chip), and the request log
shows a SECOND ``GET /api/suggestions`` (the boot fetch was the
first)."""
page.set_default_timeout(30_000)
sugg_gets: list[float] = []
def on_request(req: Request) -> None:
if req.url.endswith("/api/suggestions"):
sugg_gets.append(time.monotonic())
page.on("request", on_request)
login(page, app_url, next="/")
chips = _chip_locator(page)
expect(chips.first).to_be_visible(timeout=30_000)
assert _chip_texts(page) == SEED, "boot state: the seed row"
assert len(sugg_gets) == 1, "exactly one GET /api/suggestions at boot"
q = "Which service fronts the Pi-hole DNS on the network?"
_save_chat(
page,
app_url,
[
_user(q), _brain(),
_user("What about the Pi-hole's DNSSEC settings?"), _brain(),
],
)
clicked_at = time.monotonic()
page.click("#new-chat-btn")
# The refetch re-renders #suggestions in place: the 4 seed chips
# are replaced by exactly Q (the partial state, live — the chat's
# follow-up does not pad the row).
expect(chips).to_have_count(1, timeout=15_000)
expect(chips.first).to_have_text(q, timeout=15_000)
assert len(sugg_gets) == 2, "New chat triggered the refetch"
assert sugg_gets[1] > clicked_at, "the second GET is AFTER the click — the refetch"
def test_chip_click_submits(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
"""Carried over (phase 05, unchanged semantics): one tap = one
question — the click fills AND submits; a grounded mock reply
follows. The chip submitted is the SEED row's first entry (the
autouse fixture guarantees the seed state)."""
_seed_kb(mock_llm)
page.set_default_timeout(30_000)
login(page, app_url, next="/")
first = _chip_locator(page).first
expect(first).to_be_visible(timeout=30_000)
chip_text = first.inner_text().strip()
assert chip_text == SEED[0]
# One tap = one question: the click fills AND submits.
first.click()
expect(page.locator("#empty-state")).to_be_hidden()
expect(page.locator("#message-input")).to_have_value("") # submitted, not queued
expect(page.locator(".msg.user .bubble")).to_have_count(1, timeout=30_000)
expect(page.locator(".msg.user .bubble")).to_have_text(chip_text)
# A grounded mock reply follows (the seeded KB answers this topic).
brain = page.locator(".msg.brain .bubble").first
expect(brain).to_contain_text(MOCK_ANSWER_MARKER, timeout=30_000)
expect(brain).to_contain_text(chip_text)
expect(page.locator(".msg.brain.is-deflected")).to_have_count(0)
# Never stale: the send button recovers after the turn.
expect(page.locator("#send-btn")).to_be_enabled()
expect(page.locator("#send-label")).to_have_text("Send")
def test_chips_keyboard_accessible(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
"""Carried over (phase 05, unchanged semantics): the first chip is
keyboard-reachable BEFORE the composer input (skip-link + nav
links come first), and Enter activates it — submitting."""
_seed_kb(mock_llm)
page.set_default_timeout(30_000)
login(page, app_url, next="/")
first = _chip_locator(page).first
expect(first).to_be_visible(timeout=30_000)
chip_text = first.inner_text().strip()
assert chip_text
# Tab from the page start: the first chip must be reachable on the
# keyboard, and before the composer input (skip-link + 2 nav links come
# first). Track tab stops until we land on a chip.
reached_chip_at: int | None = None
for step in range(1, 11):
page.keyboard.press("Tab")
state = page.evaluate(
"""() => {
const el = document.activeElement;
return {
id: el ? el.id : "",
isChip: !!(el && el.classList && el.classList.contains("suggestion-chip")
&& el.closest("#suggestions")),
};
}"""
)
if state["id"] == "message-input":
pytest.fail("the composer input was reached before the suggestion chips")
if state["isChip"]:
reached_chip_at = step
break
assert reached_chip_at is not None, "no suggestion chip is keyboard-reachable"
expect(first).to_be_focused()
# Enter activates the focused chip button → it submits.
page.keyboard.press("Enter")
expect(page.locator("#empty-state")).to_be_hidden()
expect(page.locator("#message-input")).to_have_value("")
expect(page.locator(".msg.user .bubble")).to_have_count(1, timeout=30_000)
expect(page.locator(".msg.user .bubble")).to_have_text(chip_text)
expect(page.locator(".msg.brain .bubble").first).to_contain_text(
MOCK_ANSWER_MARKER, timeout=30_000
)
expect(page.locator("#send-btn")).to_be_enabled()
def test_chips_mobile_row(
browser: Browser, app_url: str, db_ready: None
) -> None:
"""Carried over (phase 05, unchanged semantics): on mobile (375px)
the row is a single horizontally scrollable line — the seed state
(4 chips) overflows into scroll, nothing wraps, chips stay
>=44px tall on one line."""
page = browser.new_page(viewport={"width": 375, "height": 720})
try:
page.set_default_timeout(30_000)
login(page, app_url, next="/")
row = page.locator("#suggestions")
expect(row).to_be_visible(timeout=30_000)
# The row is a single line that scrolls horizontally: content is
# wider than the viewport, the container scrolls, nothing wraps.
wrap = row.evaluate("el => getComputedStyle(el)")
assert wrap["flexWrap"] == "nowrap"
assert wrap["overflowX"] in {"auto", "scroll"}
dims = row.evaluate(
"el => ({ sw: el.scrollWidth, cw: el.clientWidth, h: el.clientHeight })"
)
assert dims["sw"] > dims["cw"], "chips must overflow into a scroll row"
assert row.evaluate("el => { el.scrollLeft = 24; return el.scrollLeft; }") > 0
# Exactly one line: every chip shares the same top edge, and the
# line height fits a single 44px-tall chip (no vertical clipping).
chips = _chip_locator(page)
assert chips.count() >= 3
tops: list[float] = []
for i in range(chips.count()):
box = chips.nth(i).bounding_box()
assert box is not None
assert box["height"] >= 44, "chips stay >=44px tall on mobile"
tops.append(box["y"])
assert max(tops) - min(tops) < 0.5, "all chips sit on one horizontal line"
row_box = row.bounding_box()
assert row_box is not None
assert row_box["height"] < 2 * 44, "the mobile row is a single line tall"
finally:
page.close()