phase: 104_chip_sizing_question_cap
Build and Push Containers / build-and-push-app (push) Successful in 1m48s
Build and Push Containers / build-and-push-db (push) Successful in 12s

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`.
This commit is contained in:
2026-09-12 19:45:00 -04:00
parent 1f1c01c9f7
commit ecc921098a
31 changed files with 1874 additions and 36 deletions
+521
View File
@@ -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% 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"
)
+48 -3
View File
@@ -186,6 +186,38 @@ def scroll_state(page: Page) -> dict[str, float]:
)
def wait_reveal_scroll_done(page: Page, target: int, timeout: int = 10_000) -> None:
"""Block until the submit's ``scrollReveal`` smooth scroll has ARRIVED
at its target (the document bottom captured right after the click,
``target`` — the first streamed delta lands tens of ms later and
would inflate a later read). Needed before a test scroll: Chromium's
smooth-scroll duration is distance- and environment-dependent (roughly
half a second to a second for a full page), and a ``window.scrollTo``
issued mid-animation does not settle the measured position (the
animation keeps moving the page after the test's scroll, so a
position read lands mid-flight, e.g. ``y=71`` instead of ``0``).
The wait is an ARRIVAL wait (``scrollY`` reached the target bottom),
not a "looks still" wait: mid-animation frame stalls under load make
a short stillness window pass while the animation is still pending
(observed: a 120 ms window passed mid-scroll; 800 ms is the fallback
for the vanishingly-rare late-target read, where the arrival
threshold is inflated by an already-landed delta). Phase 104 (task
03, 2026-09-12): pinned deterministically when the phase-104 E2E
regression exposed the race (it reproduces on the pre-phase-104 tree
— a pre-existing flake, not a phase-104 regression)."""
page.wait_for_function(
"""(target) => {
if (window.scrollY >= target - window.innerHeight - 2) return true;
const y0 = window.scrollY;
return new Promise((resolve) =>
setTimeout(() => resolve(window.scrollY === y0), 800));
}""",
arg=target,
timeout=timeout,
)
def wait_settled(page: Page, timeout: int = 30_000) -> None:
"""The turn is over: the label is back to "Send" (phase 48 — the
in-flight state is the enabled Stop control, so the label carries the
@@ -194,11 +226,18 @@ def wait_settled(page: Page, timeout: int = 30_000) -> None:
expect(page.locator("#send-label")).to_have_text("Send", timeout=timeout)
def submit(page: Page, question: str) -> None:
"""Submit through the composer (the real-user flow)."""
def submit(page: Page, question: str) -> int:
"""Submit through the composer (the real-user flow). Returns the
document ``scrollHeight`` read right after the click — the submit's
``scrollReveal`` target (``window.scrollTo({top: scrollHeight})``
runs in the click's own event dispatch, and the first streamed delta
lands tens of ms later and would inflate a later read)."""
page.fill("#message-input", question)
page.click("#send-btn")
expect(page.locator(".msg.user .bubble").last).to_contain_text(question)
# The bubble exists by now and the first streamed delta is still tens
# of ms away — the read is the scrollReveal's own target.
return page.evaluate("() => document.documentElement.scrollHeight")
def build_conversation(page: Page, n: int = 6) -> None:
@@ -371,7 +410,7 @@ def test_stop_is_reachable_from_scrolled_up(
# SECOND answer bubble exists and carries deltas (`.last` alone would
# still resolve to turn 1's finished bubble: the typing indicator is
# `.bubble.typing` and excluded from ANSWER).
submit(page, LONG_QUESTION)
reveal_target = submit(page, LONG_QUESTION)
page.wait_for_function(
"() => { const els = document.querySelectorAll('.msg.brain .bubble:not(.typing)');"
" return els.length >= 2 && els[els.length - 1].innerText.length > 80; }",
@@ -380,6 +419,12 @@ def test_stop_is_reachable_from_scrolled_up(
answer = page.locator(ANSWER).nth(1)
expect(page.locator("#send-label")).to_have_text("Stop", timeout=10_000)
# The submit's own smooth scrollReveal (to the document bottom) must
# ARRIVE before the test scrolls up — mid-animation, the browser
# keeps moving the page after the test's scrollTo and the position
# read lands mid-flight (the race wait_reveal_scroll_done documents).
wait_reveal_scroll_done(page, reveal_target)
# The user scrolls UP to read earlier content. Phase 42 leaves them
# there — the app never follows the stream.
page.evaluate("() => window.scrollTo(0, 0)")
+28 -6
View File
@@ -130,8 +130,24 @@ def _assert_no_doc_overflow(page: Page, label: str) -> None:
def _tab_outline_walk(page: Page, max_tabs: int = 60) -> list[dict[str, str]]:
"""Real keyboard Tab walk; returns each focused element's outline."""
first_key: str | None = None
"""Real keyboard Tab walk; returns each focused element's outline.
The walk covers a FULL focus cycle (it starts wherever Chromium
resumes after the skip-link check — mid-document, right after
#main — and ends when focus wraps back to the FIRST element it
visited). The wrap is detected by TRUE element identity: each
newly focused element is stamped with a unique ``data-tabwalk-id``
and the cycle ends when a STAMPED element is focused again.
Phase 104 (task 04, 2026-09-12): the pre-104 heuristic keyed on
the first 24 chars of the element's text — two chips sharing that
prefix (e.g. the two identical "How is my Kubernetes cluster set
up? write a long answer" opener chips left in ``saved_chats`` by
test_pinned_composer.py, which runs suites in sequence on the
shared e2e DB) collided and cut the walk short at two entries,
flaking ``len(seen) >= 3``. Verified pre-existing on the
pre-phase-104 tree (the mid-document Tab start is Chromium
behavior, not a phase-104 change)."""
first_id: str | None = None
seen: list[dict[str, str]] = []
for _ in range(max_tabs):
page.keyboard.press("Tab")
@@ -142,7 +158,13 @@ def _tab_outline_walk(page: Page, max_tabs: int = 60) -> list[dict[str, str]]:
const cls = String(el.className).split(" ")[0];
const label = (el.getAttribute("aria-label")
|| el.textContent || "").trim().slice(0, 24);
let id = el.getAttribute("data-tabwalk-id");
if (!id) {
id = "tw" + ((window.__twSeq = (window.__twSeq || 0) + 1));
el.setAttribute("data-tabwalk-id", id);
}
return {
id: id,
key: el.tagName + "#" + (el.id || "") + "." + cls + ":" + label,
outline_style: cs.outlineStyle,
outline_width: cs.outlineWidth,
@@ -151,11 +173,11 @@ def _tab_outline_walk(page: Page, max_tabs: int = 60) -> list[dict[str, str]]:
)
if info["key"].startswith("BODY"):
continue # focus has not entered the document yet
if first_key is None:
first_key = info["key"]
if first_id is None:
first_id = info["id"]
seen.append(info)
if len(seen) > 1 and info["key"] == first_key:
break # wrapped back to the first focusable
if len(seen) > 1 and info["id"] == first_id:
break # wrapped back to the first focusable (same ELEMENT)
return seen
+6 -1
View File
@@ -181,7 +181,12 @@ def _save_chat(page: Page, app_url: str, messages: list[dict[str, Any]]) -> dict
def _api_suggestions(page: Page, app_url: str) -> list[str]:
# Phase 79: the endpoint is require_user-gated — the request rides
# the page's signed-in context (each test signs in above).
r = page.request.get(f"{app_url}/api/suggestions", timeout=10)
# 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"]