All completion criteria verified. Final report: **Phase 104 — final verification pass: all green** - Verified (no code changes needed): single-line ellipsized `.suggestion-chip` CSS + deleted `.maybe-try` override, `renderChips` full-text `title` + `aria-label`-when-clipped, `maxlength="4000"` + `#char-count` counter + `handleSend` over-cap guard, unit pins file, schemas boundary pins, dedicated E2E suite. - E2E (isolation): `uv run pytest tests/e2e/test_chip_sizing_question_cap.py -v --no-cov` → **6 passed**; regressions: `test_suggestion_chips.py` 8 passed, `test_pinned_composer.py` 4 passed, `test_responsive_polish.py` 7 passed, `test_chat_history.py` 5 passed. - `uv run pytest` → **2102 passed**; `--cov=app` → **99%** (>90%); `uv run ruff check . && uv run pyright` → clean, 0 errors. - Criteria: chip E2E (single-line, clipped, title+aria-label full text) ✅; paste caps at exactly 4,000, send streams, counter hides ✅; programmatic 5,000-char fill → banner, no turn, text kept ✅; 4,000/4,001 boundary pinned + HTML maxlength == JS constant cross-file pin ✅. - Diff scope: `frontend/`, new unit file, `tests/unit/test_schemas.py`, new E2E file, phase files — **no `app/` diff, no migration, no `shared.js` diff**. - Deviations: 4 regression test files touched — 2 genuine DOM-pin conflicts from the new `#char-count` child (explicitly anticipated by the overview) + 3 documented **pre-existing E2E flake fixes** (smooth-scroll race, tab-walk heuristic, 10 ms timeout), each verified pre-existing on the pre-phase-104 tree. - No commit made (harness commits per the execution protocol override). - Next pending phase: `98_sync_summary_visibility`.
542 lines
23 KiB
Python
542 lines
23 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).
|
|
# 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()
|