"""Phase 104 E2E (Playwright): single-line suggestion chips (hover reveals the full text) + the visible 4,000-char question cap. Source: owner request 2026-09-12 — "…tweak the size of the suggestion chips on the chat page. Some users submit truly massive queries and the 'chips' become more like 'chonks'. Hovering over the chips should still show the full message … put a character cap on the chat submission box, to prevent ultra-long context overflowing queries. Users should still be able to paste code example of a few dozen lines, but nothing much longer than that." The contract (owner decisions A1–A6, recorded in 00_phase.md): * **A1/A2 — single-line chips, full text one hover away:** every suggestion chip is ONE line at every viewport width (never a wrapped multi-line "chonk") — long text ellipsizes at the row edge, and the FULL text is recoverable via the native ``title`` tooltip on every chip plus the ``aria-label`` accessible name when the visible text is actually clipped; * **A3 — the hard cap through the input path:** the composer textarea carries ``maxlength="4000"`` mirroring the pre-existing server cap (``ChatRequest.message max_length=4000`` — the server 422s beyond); a paste of more than 4,000 chars lands as EXACTLY 4,000, and submitting at the cap is a clean turn (no 422 error state); * **A4 — the counter:** ``#char-count`` is hidden while the RAW length is below 80% of the cap (3,200 — no noise on normal use), shows ``len/4000`` from there, and reads ``len/4000 — character limit`` + the ``.is-max`` (err family + copy change, B3) at/over the cap; * **A5 — the over-cap guard:** a programmatic fill (the chip one-tap path) bypasses ``maxlength`` — ``handleSend`` refuses trimmed text over the cap with the out-of-turn error banner (the ``saveAsDoc`` precedent), NO turn, and the input KEEPS the text (the user trims it — never stale, PLAN §7.4). The states pinned here (six tests): 1. **truncated-chip** (A1/A2 core) — a saved chat whose FIRST user question is LONG (720 chars — a readable repeated phrase, 300+ per the phase) with a short follow-up → a fresh page load shows EXACTLY ONE onboarding chip (the phase-103 opener semantics — the follow-up never surfaces): computed ``white-space: nowrap`` / ``overflow: hidden`` / ``text-overflow: ellipsis``, visually clipped (``scrollWidth > clientWidth``), single line (``44 <= offsetHeight <= 60`` — a one-line pill sits at the 44px ``min-height`` floor, border-box; a wrapped two-liner is ~65px — the chonk), and ``title`` + ``aria-label`` == the FULL long text; 2. **seed-contrast** — a fresh deployment's (seed) short chips have ``title`` set AND NO ``aria-label`` (not truncated — the attribute is absent by design; the ``textContent`` already carries the full text); 3. **counter-threshold** (A4) — ``#char-count`` hidden at boot; 100 chars typed → still hidden; exactly 3,500 chars (a dispatched ``input`` event — ``locator.fill`` does this) → visible, text ``3500/4000``, NO ``.is-max``; 4. **paste-cap** (A3) — ``keyboard.insert_text`` of 6,500 chars (CDP ``Input.insertText`` = the paste path — ``maxlength`` applies) → the textarea holds EXACTLY 4,000 chars; the counter reads ``4000/4000 — character limit`` + ``.is-max``; Send → NO 422 error state → the mock answer streams to ``done`` (the brain bubble with the ``MOCK_ANSWER_MARKER``) → the input is cleared and the counter hidden again; 5. **over-cap-guard** (A5) — ``page.evaluate`` sets ``#message-input.value`` to 5,000 chars + dispatches an ``input`` event (the programmatic path ``maxlength`` cannot stop) → counter ``5000/4000 — character limit`` + ``.is-max`` → Send → the error banner shows the "4,000 characters" cap copy, NO bubble is appended, the input STILL holds the 5,000 chars (kept for trimming — never stale); 6. **short-flow-regression** — a short question submits cleanly and the counter NEVER becomes visible during the turn (a MutationObserver flags any hidden→visible transition of ``#char-count`` for the whole turn). The endpoint and the chat are authed (phase 79, ``require_user``), so every test signs in as admin first (``auth_helpers.login``). The onboarding chips are the phase-103 session openers read from ``saved_chats`` — AND the send tests auto-save a row per turn — so the autouse fixture truncates that table before and after EVERY test, which keeps each test on an empty deployment (the seed state) and stops cross-test leakage. Run in isolation (DB must be up: ``podman compose up -d db``): uv run pytest tests/e2e/test_chip_sizing_question_cap.py -v --no-cov """ from __future__ import annotations import asyncio import json import re from collections.abc import Iterator from pathlib import Path from threading import Thread from typing import Any 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 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" #: A 720-char opener (300+ per the phase) — a readable repeated phrase, #: far wider than the 46rem chat column, so the single-line pill MUST #: clip. The phase-103 opener semantics surface exactly this text as #: the chat's one onboarding chip. LONG_OPENER = "What are the correct arguments for " * 20 + "qwen on llama.cpp?" SHORT_FOLLOW_UP = "And which of the two needs the most VRAM?" #: A 6,500-char paste (4,000+ = over the cap) built from a word the #: fixture KB indexes ("kubernetes" — ``tests/fixtures/docs/homelab/ #: kubernetes.md``): after ``maxlength`` trims it to EXACTLY 4,000 the #: question still carries the token, so the FTS leg of the hybrid #: retrieval hits and the mock answers the grounded (non-deflected) #: path with the marker — a clean turn at the cap, no 422. PASTE_QUESTION = "kubernetes " * 500 PASTE_AT_CAP = PASTE_QUESTION[:4000] # what maxlength=4000 keeps #: The over-cap programmatic fill (A5 — the chip one-tap path #: ``maxlength`` cannot stop): 5,000 chars, trimmed to 5,000. OVER_CAP_FILL = "x" * 5000 #: The short-flow question (well under the 3,200 counter threshold and #: grounded in the fixture KB — the mock answers it with the marker). SHORT_QUESTION = "How do I deploy a new service on the homelab node?" @pytest.fixture(autouse=True) def clean_chats(db_ready: None) -> Iterator[None]: """``saved_chats`` is the state the onboarding chips read (the phase-103 openers) AND the send tests auto-save a row per turn — truncate it before and after EVERY test so each starts from (and leaves) an empty deployment (the seed-chip state), exactly the phase-80/103 autouse pattern in test_suggestion_chips.py.""" 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 send 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.""" 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 _chip(page: Page, index: int = 0) -> Any: return page.locator("#suggestions .suggestion-chip").nth(index) def _wait_chat_booted(page: Page) -> None: """Wait until app.js has FINISHED booting the chat page. The login helper returns on the URL change (navigation commit) — the page's module script may still be executing, and an ``input`` event dispatched before its top-level listener registrations land on a page whose listeners do not exist yet (the event is simply lost). ``#view-chat.chat-booted`` is added two frames after the boot settles (AFTER every top-level listener), so it is the "the app's JS is live" sentinel (house pattern: test_mobile_chat_hamburger_ boot.py's post-login wait).""" page.wait_for_function( "() => document.getElementById('view-chat')?." "classList.contains('chat-booted')", timeout=15_000, ) expect(page.locator("#view-chat")).to_have_class(re.compile(r"\bchat-booted\b")) def test_long_chip_is_single_line_ellipsized_with_full_text_tooltip( page: Page, app_url: str, db_ready: None ) -> None: """A1/A2 core: a saved chat whose FIRST user question is 720 chars (a readable repeated phrase) with a short follow-up → a fresh page load shows EXACTLY ONE onboarding chip (the phase-103 opener semantics — the follow-up never surfaces), and it is: * ONE line — computed ``white-space: nowrap``, ``overflow: hidden``, ``text-overflow: ellipsis``; * visually clipped — ``scrollWidth > clientWidth`` (720 chars of ~7px/char far exceeds the 46rem column); * the pill, not the chonk — ``44 <= offsetHeight <= 60`` (a one-line pill sits at the 44px ``min-height`` floor, border-box — the global ``* { box-sizing: border-box }`` makes the 44px the OUTER height; a wrapped two-liner is ~65px); * the FULL text one hover away — ``title`` == the full long text; * the clipped-case accessible name — ``aria-label`` == the full long text. """ page.set_default_timeout(30_000) login(page, app_url, next="/") # ONE multi-turn chat: the LONG opener + one short follow-up # (the follow-up must never surface — phase-103 semantics). _save_chat( page, app_url, [ _user(LONG_OPENER), _brain(), _user(SHORT_FOLLOW_UP), _brain(), ], ) # A FRESH page load (a new boot fetch): EXACTLY ONE chip — the # opener, and nothing else. page.goto(app_url + "/") chips = page.locator("#suggestions .suggestion-chip") expect(chips.first).to_be_visible(timeout=30_000) expect(chips).to_have_count(1, timeout=15_000) # The chip carries the EXACT full opener in the DOM (the clipping # is visual — textContent is never truncated). assert chips.first.inner_text().strip() == LONG_OPENER chip = chips.first # A1 — the single-line computed contract (the phase-104 CSS). style = chip.evaluate("el => getComputedStyle(el)") assert style["whiteSpace"] == "nowrap", "the chip must never wrap (owner A1)" assert style["overflow"] in {"hidden", "hidden hidden"}, ( "`overflow: hidden` is what zeroes the flex auto-min so the " "ellipsis clip at the row edge binds" ) assert style["textOverflow"] == "ellipsis", "the clipped text shows the ellipsis" # Clipped + single line: the text overflows the pill (ellipsis) but # the pill itself stays ONE 44px-min line tall — never the chonk. # offsetHeight (border-box): the global `* { box-sizing: border-box }` # makes the 44px `min-height` the OUTER floor — a one-line pill is # exactly 44px, a wrapped two-liner is ~65px (63px content + 2px # border), so the 44..60 band is single-line only. dims = chip.evaluate( "el => ({ sw: el.scrollWidth, cw: el.clientWidth, oh: el.offsetHeight })" ) assert dims["sw"] > dims["cw"], ( "a 720-char question must be visually clipped in the 46rem column" ) assert 44 <= dims["oh"] <= 60, ( f"one-line pill: 44px min-height floor (border-box), a wrapped " f"two-liner is ~65px — got {dims['oh']}px" ) # A2 — the full text is one hover away (the native tooltip), and # the accessible name of the CLIPPED chip is the full text. assert chip.get_attribute("title") == LONG_OPENER, ( "the title tooltip must carry the FULL question (owner A2)" ) assert chip.get_attribute("aria-label") == LONG_OPENER, ( "a clipped chip's aria-label must be the full text (the " "source-chip pattern)" ) def test_short_seed_chip_has_tooltip_but_no_aria_label( page: Page, app_url: str, db_ready: None ) -> None: """The contrast pin: a fresh deployment (seed chips — the autouse fixture guarantees the empty state) renders four SHORT seed chips, none of them clipped in the 1280px desktop column. Every chip still has ``title`` = its full text (A2 is universal), but NO ``aria-label`` — the attribute is absent by design when the visible text is not clipped (the ``textContent`` already carries the full text).""" page.set_default_timeout(30_000) login(page, app_url, next="/") chips = page.locator("#suggestions .suggestion-chip") expect(chips.first).to_be_visible(timeout=30_000) expect(chips).to_have_count(4, timeout=15_000) # the built-in seed (4 entries) for i in range(4): chip = chips.nth(i) full_text = chip.inner_text().strip() assert full_text, "every seed chip needs non-empty label text" # Not clipped: the pill fits its text in the desktop column. dims = chip.evaluate( "el => ({ sw: el.scrollWidth, cw: el.clientWidth, h: el.clientHeight })" ) assert dims["sw"] <= dims["cw"], f"seed chip {i!r} must not be clipped" # A2 universal: the tooltip is set on EVERY chip… assert chip.get_attribute("title") == full_text # …but the accessible name is added ONLY when clipped. assert chip.get_attribute("aria-label") is None, ( f"an unclipped chip must NOT carry an aria-label (chip {i!r}) — " "the textContent already reads the full text" ) def test_counter_hidden_below_threshold_and_visible_above( page: Page, app_url: str, db_ready: None ) -> None: """A4: the counter is hidden at boot, stays hidden at 100 chars (below the 3,200 = 80% threshold — no noise on normal use), and at exactly 3,500 chars (a dispatched ``input`` event — ``locator.fill`` fires it) it is visible reading ``3500/4000`` with NO ``.is-max`` (that state is for at/over the cap).""" page.set_default_timeout(30_000) login(page, app_url, next="/") _wait_chat_booted(page) counter = page.locator("#char-count") expect(counter).to_be_hidden() # boot state (hidden by default in the HTML) page.fill("#message-input", "a" * 100) expect(counter).to_be_hidden() # 100 < 3,200 — still no noise page.fill("#message-input", "b" * 3500) expect(counter).to_be_visible(timeout=5_000) expect(counter).to_have_text("3500/4000") expect(counter).not_to_have_class(re.compile("is-max")) def test_paste_path_hard_caps_at_the_cap_and_sends( page: Page, app_url: str, mock_llm: int, db_ready: None ) -> None: """A3: the paste path (CDP ``Input.insertText`` — the browser applies ``maxlength`` to it, like a paste) hard-caps at EXACTLY 4,000: a 6,500-char paste lands as 4,000, the counter reads ``4000/4000 — character limit`` + ``.is-max``, and submitting a 4,000-char question PASSES the server cap (no 422 error state) — the mock answer streams to ``done`` and the turn clears the input + the counter.""" _seed_kb(mock_llm) page.set_default_timeout(30_000) login(page, app_url, next="/") _wait_chat_booted(page) counter = page.locator("#char-count") input_el = page.locator("#message-input") expect(counter).to_be_hidden() # The paste path: one CDP insert of 6,500 chars — maxlength trims # the OVERFLOW, the textarea keeps EXACTLY the first 4,000. input_el.click() page.keyboard.insert_text(PASTE_QUESTION) expect(input_el).to_have_value(PASTE_AT_CAP) # EXACTLY 4,000 chars # At the cap: the honest reading + the B3 state (copy + err color). expect(counter).to_be_visible(timeout=5_000) expect(counter).to_have_text("4000/4000 — character limit") expect(counter).to_have_class(re.compile("is-max")) # Submit at the cap: the 4,000-char question passes the server cap # (no 422 error state — a 422 would raise the error banner). banner = page.locator("#kb-banner") expect(banner).to_be_hidden() page.click("#send-btn") # A clean turn: the user bubble is the exact 4,000-char question and # the grounded mock answer streams to done (the KB carries # "kubernetes" — the FTS leg hits, non-deflected). expect(page.locator(".msg.user .bubble")).to_have_count(1, timeout=30_000) expect(page.locator(".msg.user .bubble")).to_have_text(PASTE_AT_CAP) brain = page.locator(".msg.brain .bubble").first expect(brain).to_contain_text(MOCK_ANSWER_MARKER, timeout=30_000) expect(page.locator(".msg.brain.is-deflected")).to_have_count(0) expect(banner).to_be_hidden() # NO 422 error state at the cap # Never stale: the turn cleared the input AND the counter, and the # send button recovered. expect(input_el).to_have_value("") expect(counter).to_be_hidden() expect(page.locator("#send-btn")).to_be_enabled() def test_over_cap_programmatic_fill_hits_the_guard( page: Page, app_url: str, db_ready: None ) -> None: """A5: the one path ``maxlength`` cannot stop — a programmatic ``value`` assignment (the chip one-tap fill). A 5,000-char fill + a dispatched ``input`` event → the counter reads the honest ``5000/4000 — character limit`` + ``.is-max``; Send → the over-cap guard fires: the error banner shows the "4,000 characters" cap copy, NO turn (no user or brain bubble), and the input STILL holds the 5,000 chars (kept for trimming — never stale, PLAN §7.4).""" page.set_default_timeout(30_000) login(page, app_url, next="/") _wait_chat_booted(page) counter = page.locator("#char-count") expect(counter).to_be_hidden() # The programmatic path: assign + dispatch (maxlength never sees it). page.evaluate( """(value) => { const el = document.querySelector("#message-input"); el.value = value; el.dispatchEvent(new Event("input", { bubbles: true })); }""", OVER_CAP_FILL, ) expect(counter).to_be_visible(timeout=5_000) expect(counter).to_have_text("5000/4000 — character limit") expect(counter).to_have_class(re.compile("is-max")) # Send over the cap: the guard refuses BEFORE the request. page.click("#send-btn") banner = page.locator("#kb-banner") expect(banner).to_be_visible(timeout=5_000) expect(banner).to_have_attribute("role", "alert") expect(page.locator("#kb-banner-text")).to_contain_text("4,000 characters") # NO turn: nothing was appended, and the input keeps the text the # user trims (a cleared input would be stale — PLAN §7.4). expect(page.locator(".msg.user .bubble")).to_have_count(0) expect(page.locator(".msg.brain .bubble")).to_have_count(0) expect(page.locator("#message-input")).to_have_value(OVER_CAP_FILL) # The counter (which mirrors the untouched input) and the send # button (never mid-turn) are consistent with the kept text. expect(counter).to_have_text("5000/4000 — character limit") expect(page.locator("#send-btn")).to_be_enabled() def test_short_flow_never_shows_the_counter( page: Page, app_url: str, mock_llm: int, db_ready: None ) -> None: """Short-flow regression: a 49-char question submits cleanly (the grounded mock answer lands) and ``#char-count`` NEVER becomes visible during the whole turn — a MutationObserver flags ANY hidden→visible transition of the counter (with ``oldValue``, so even a same-task set/unset is caught).""" _seed_kb(mock_llm) page.set_default_timeout(30_000) login(page, app_url, next="/") _wait_chat_booted(page) # Arm the never-visible tripwire for the rest of this page's life. page.evaluate( """() => { window.__char_count_ever_visible = false; const el = document.querySelector("#char-count"); new MutationObserver((records) => { for (const r of records) { if (r.attributeName === "hidden" && r.oldValue === "true") { window.__char_count_ever_visible = true; } } }).observe(el, { attributes: true, attributeFilter: ["hidden"], attributeOldValue: true, }); }""" ) counter = page.locator("#char-count") expect(counter).to_be_hidden() page.fill("#message-input", SHORT_QUESTION) # 49 chars << 3,200 expect(counter).to_be_hidden() page.click("#send-btn") expect(page.locator(".msg.user .bubble")).to_have_count(1, timeout=30_000) expect(page.locator(".msg.user .bubble")).to_have_text(SHORT_QUESTION) brain = page.locator(".msg.brain .bubble").first expect(brain).to_contain_text(MOCK_ANSWER_MARKER, timeout=30_000) expect(page.locator("#message-input")).to_have_value("") expect(page.locator("#send-btn")).to_be_enabled() # The whole turn: the counter never once became visible. assert page.evaluate("() => window.__char_count_ever_visible") is False, ( "a short question must never surface the counter — not at the " "keystrokes, not at the send, not during the turn" )