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`.
522 lines
23 KiB
Python
522 lines
23 KiB
Python
"""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"
|
||
)
|