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
This commit is contained in:
@@ -1,37 +1,110 @@
|
||||
"""Phase 05 E2E (Playwright): onboarding suggestion chips, one-tap submit.
|
||||
"""Phase 80 E2E (Playwright): onboarding chips = the last 3 questions asked.
|
||||
|
||||
Story: ``.agents/user_stories/suggestion-chips.md`` (phase 05) —
|
||||
REWRITTEN in place for the phase-80 semantics (the phase-76 precedent:
|
||||
a semantic change rewrites the story suite in place). Source:
|
||||
``TODO.md`` L6.
|
||||
|
||||
The new contract (owner decision A6): the empty-state chip row is the
|
||||
3 most recent user questions across ALL saved chats — chats walked
|
||||
newest-``updated_at`` first, each chat's messages newest-first,
|
||||
exact (case-sensitive) de-duplicated, cap 3. A fresh deployment — zero
|
||||
saved questions — gets the SEED list instead (``BOR_SUGGESTIONS`` /
|
||||
the built-in default). 1–2 saved questions → exactly those chips (NO
|
||||
mixing with the seed). 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 four states pinned here:
|
||||
|
||||
* **seed** — 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;
|
||||
* **last-3** — two saved chats with 5 user questions total (the older
|
||||
one saved FIRST — the API stamps ``updated_at``) → a fresh page
|
||||
load shows EXACTLY the 3 newest questions, newest-first;
|
||||
* **partial** — exactly 2 saved questions deployment-wide → exactly
|
||||
2 chips (no seed top-up — the A6 contract, visible in the UI);
|
||||
* **refetch** — boot with the seed chips, save a chat whose newest
|
||||
question is Q via the API, click New chat (``#new-chat-btn``) → the
|
||||
chips now are 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
|
||||
suite): one-tap submit (chip click → composer filled → submitted →
|
||||
the mock-LLM brain bubble), 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.
|
||||
|
||||
Story: ``.agents/user_stories/suggestion-chips.md``
|
||||
Run in isolation (DB must be up: ``podman compose up -d db``):
|
||||
|
||||
uv run pytest tests/e2e/test_suggestion_chips.py -v --no-cov
|
||||
|
||||
The onboarding row in the empty state renders real ``<button>`` chips from
|
||||
``GET /api/suggestions`` (settings defaults). Clicking — or Tab + Enter —
|
||||
fills the composer AND submits: one tap produces a user bubble with the
|
||||
chip's exact text and a streamed Brain reply. On mobile (375px) the row
|
||||
becomes a single horizontally scrollable line.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from threading import Thread
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from playwright.sync_api import Browser, Page, expect
|
||||
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 before any question has
|
||||
#: ever been saved. 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-80 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, 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"}
|
||||
@@ -58,7 +131,9 @@ def _run_in_thread(coro: Any) -> Any:
|
||||
|
||||
|
||||
def _seed_kb(mock_port: int) -> ImportSummary:
|
||||
"""Deterministic KB: truncate everything, import the fixture docs."""
|
||||
"""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()
|
||||
@@ -67,42 +142,75 @@ def _seed_kb(mock_port: int) -> ImportSummary:
|
||||
return summary
|
||||
|
||||
|
||||
def _api_suggestions(app_url: str) -> list[str]:
|
||||
body = httpx.get(f"{app_url}/api/suggestions", timeout=10).json()
|
||||
return body["suggestions"]
|
||||
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 test_onboarding_chips_render(
|
||||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||
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:
|
||||
_seed_kb(mock_llm)
|
||||
"""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)
|
||||
page.goto(app_url)
|
||||
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")
|
||||
|
||||
# 3+ visible chips, real buttons, each with non-empty text — and the
|
||||
# texts match what the API returned (chips are drawn from the endpoint).
|
||||
chips = _chip_locator(page)
|
||||
expect(chips.first).to_be_visible(timeout=30_000)
|
||||
assert chips.count() >= 3
|
||||
api_texts = _api_suggestions(app_url)
|
||||
# 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")
|
||||
text = chip.inner_text().strip()
|
||||
assert text, "every chip needs non-empty label text"
|
||||
assert text in api_texts
|
||||
assert len(set(api_texts)) >= 3
|
||||
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()
|
||||
@@ -115,17 +223,136 @@ def test_onboarding_chips_render(
|
||||
assert box is not None and box["height"] >= 44
|
||||
|
||||
|
||||
def test_last_three_questions_state(page: Page, app_url: str, db_ready: None) -> None:
|
||||
"""5 user questions across two saved chats (the older one saved
|
||||
FIRST — the API stamps ``updated_at`` at INSERT) → a fresh page
|
||||
load shows EXACTLY the 3 newest questions, newest-first: the
|
||||
newer chat is walked first, then the older chat newest-first."""
|
||||
page.set_default_timeout(30_000)
|
||||
login(page, app_url, next="/")
|
||||
|
||||
old_q = [
|
||||
"How did I install the GitLab runner on the Proxmox node?",
|
||||
"Which disk holds the Borg backup archives?",
|
||||
"How is the nftables firewall rule set ordered?",
|
||||
]
|
||||
new_q = [
|
||||
"What TLS termination does Traefik do for homelab.local?",
|
||||
"Which provider is the primary DNS for reeseapps.com?",
|
||||
]
|
||||
|
||||
# The OLDER chat first: the API stamps ``updated_at`` (server
|
||||
# now()), so save order IS walk order. The short pause keeps the
|
||||
# two stamps strictly apart (and the assert below pins that the
|
||||
# order the walk sees is the order the test intended).
|
||||
older = _save_chat(
|
||||
page,
|
||||
app_url,
|
||||
[
|
||||
_user(old_q[0]), _brain(),
|
||||
_user(old_q[1]), _brain(),
|
||||
_user(old_q[2]), _brain(),
|
||||
],
|
||||
)
|
||||
time.sleep(0.05)
|
||||
newer = _save_chat(
|
||||
page,
|
||||
app_url,
|
||||
[
|
||||
_user(new_q[0]), _brain(),
|
||||
_user(new_q[1]), _brain(),
|
||||
],
|
||||
)
|
||||
assert datetime.fromisoformat(newer["updated_at"]) > datetime.fromisoformat(
|
||||
older["updated_at"]
|
||||
), "the two API-stamped updated_at values must be strictly ordered"
|
||||
|
||||
# A FRESH page load (a new boot fetch, not the pre-save boot):
|
||||
# the chips are exactly the 3 newest questions, newest first.
|
||||
page.goto(app_url + "/")
|
||||
chips = _chip_locator(page)
|
||||
expect(chips.first).to_be_visible(timeout=30_000)
|
||||
expected = [new_q[1], new_q[0], old_q[2]]
|
||||
assert _chip_texts(page) == expected
|
||||
assert _api_suggestions(page, app_url) == expected
|
||||
# The two older questions (and everything seed-shaped) are gone.
|
||||
assert old_q[0] not in _chip_texts(page)
|
||||
assert old_q[1] not in _chip_texts(page)
|
||||
|
||||
|
||||
def test_partial_state_no_seed_topup(page: Page, app_url: str, db_ready: None) -> None:
|
||||
"""Exactly 2 saved questions deployment-wide → EXACTLY 2 chips
|
||||
(newest first) — NO mixing/top-up with the seed (the A6
|
||||
contract, visible in the UI)."""
|
||||
page.set_default_timeout(30_000)
|
||||
login(page, app_url, next="/")
|
||||
|
||||
a = "How do I rotate the WireGuard keys on the VPN node?"
|
||||
b = "What cron schedule runs the restic prune?"
|
||||
_save_chat(page, app_url, [_user(a), _brain()])
|
||||
time.sleep(0.05)
|
||||
_save_chat(page, app_url, [_user(b), _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, a]
|
||||
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 chat
|
||||
whose newest question is Q 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
|
||||
question), 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()])
|
||||
|
||||
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).
|
||||
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)
|
||||
page.goto(app_url)
|
||||
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
|
||||
assert chip_text == SEED[0]
|
||||
|
||||
# One tap = one question: the click fills AND submits.
|
||||
first.click()
|
||||
@@ -148,9 +375,12 @@ def test_chip_click_submits(
|
||||
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)
|
||||
page.goto(app_url)
|
||||
login(page, app_url, next="/")
|
||||
first = _chip_locator(page).first
|
||||
expect(first).to_be_visible(timeout=30_000)
|
||||
chip_text = first.inner_text().strip()
|
||||
@@ -193,13 +423,16 @@ def test_chips_keyboard_accessible(
|
||||
|
||||
|
||||
def test_chips_mobile_row(
|
||||
browser: Browser, app_url: str, mock_llm: int, db_ready: None
|
||||
browser: Browser, app_url: str, db_ready: None
|
||||
) -> None:
|
||||
_seed_kb(mock_llm)
|
||||
"""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)
|
||||
page.goto(app_url)
|
||||
login(page, app_url, next="/")
|
||||
row = page.locator("#suggestions")
|
||||
expect(row).to_be_visible(timeout=30_000)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user