"""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). # Phase 104 (task 03, 2026-09-12): the original 10 ms timeout sat # BELOW a normal localhost round trip (~12 ms steady-state) and # flaked the whole suite on a loaded box — the sibling _save_chat # helper's 10 s is the house pattern; the server is up by the time # this probe runs (the chips it mirrors already rendered). r = page.request.get(f"{app_url}/api/suggestions", timeout=10_000) 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()