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
+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)")