@@ -0,0 +1,521 @@
""" 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 % o f 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 "
)