Files
brain-of-reese/tests/e2e/test_pinned_composer.py
T
ducoterra ecc921098a
Build and Push Containers / build-and-push-app (push) Successful in 1m48s
Build and Push Containers / build-and-push-db (push) Successful in 12s
phase: 104_chip_sizing_question_cap
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`.
2026-09-12 19:45:00 -04:00

650 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Phase 52 E2E (Playwright): the composer never runs away from the user.
Source: ``TODO.md`` L3 — "The message input text box needs to be pinned to
the bottom of the screen so it doesn't \"run away\" from the user as they
try to click \"stop\"" (no user story file — TODO-derived phase).
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_pinned_composer.py -v --no-cov
Mock-only, no admin login (chat is public). The owner-locked contract
(2026-08-30) under test — the CSS pin (``position: sticky;
bottom: env(safe-area-inset-bottom)`` on ``.composer``, phase 52 task 01)
observed in a real browser:
* with an over-viewport conversation the composer box is fully inside the
viewport, flush with its bottom edge, at EVERY scroll position — the
reader never has to scroll to find the input;
* the original bug scenario: mid-turn, scrolled up reading earlier
content, the Stop control (``#send-btn.is-stop``, phase 48) is visible
and clickable WITHOUT scrolling, the click settles the turn (partial
kept + ``.stopped-note``, no error banner), it moves the viewport not
at all (the phase-42 never-auto-scroll contract — the pin adds no scroll
call site), and a reload restores the ``stopped`` record through the
normal ``bor.chat.v1`` path;
* on an empty/short chat the composer renders in its RESTING position,
which the grow puts at the BOTTOM of the screen — the only band below it
is chrome (``.app-footer``, in normal flow, never overlapped), and at the
document bottom the box has settled back into that same flow slot above
the footer instead of hovering on the viewport edge;
* ≤640px the same two rules hold: the empty phone chat puts the input at
the bottom of the screen and an over-viewport chat keeps it there, still
UNDER the sticky header (no z-index was added, so the pinned box can
never cover the bar or the hamburger dropdown — phase 46 stacking).
Determinism: the mock's grounded answers quote the question and end in the
``Deterministic mock answer for E2E`` marker; the long answer (``write a
long answer`` trigger, phase 11) streams ~900 words over ~8 s at 12 chars /
0.02 s — the in-flight window the Stop click rides — with a unique final
line (``LONG-ANSWER-END``) absent from any partial. Overflow is built from
real UI turns (no fixture, no DOM injection): 6 short turns put ~1.5k px
of conversation into the 800px viewport.
Test → story mapping (Playwright Mapping Rule):
1. ``test_composer_pinned_at_every_scroll_position``
2. ``test_stop_is_reachable_from_scrolled_up`` — the TODO.md L3 scenario
3. ``test_empty_chat_composer_sits_at_the_screen_bottom``
4. ``test_pin_holds_on_mobile_under_the_header`` — ≤640px stacking
"""
from __future__ import annotations
import asyncio
import json
import re
import time
from collections.abc import Iterator
from pathlib import Path
from threading import Thread
from typing import Any
import pytest
from playwright.sync_api import Browser, Page, ViewportSize, 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"
#: Six short grounded questions (the house phrasing + on-topic variants —
#: the honesty gate is HIGH for all of them, so every turn renders an
#: answer bubble + source chips). Six turns overflow the 800px viewport by
#: a wide margin; the tests assert the overflow, so the count can never
#: silently stop being enough.
SHORT_QUESTIONS = (
"How is my Kubernetes cluster set up?",
"How do my backups work?",
"How did I install gitlab?",
"How does my homelab networking work?",
"What is in the new-service deployment?",
"What scripts do I have in the homelab?",
)
#: The phase-11 long-answer trigger: ~900 words ≈ 8 s of streaming — the
#: in-flight window the scrolled-up Stop click rides.
LONG_QUESTION = "How is my Kubernetes cluster set up? write a long answer"
#: The long answer's unique final line — absent from any partial.
LONG_ANSWER_END = "LONG-ANSWER-END"
MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E"
STORAGE_KEY = "bor.chat.v1"
#: The conftest ``page`` viewport is 1280×800; the story's phone viewport
#: is the phase-46 one.
MOBILE: ViewportSize = {"width": 375, "height": 812}
#: Tolerance for "flush with the viewport bottom": `env(safe-area-inset-
#: bottom)` resolves to 0 on a desktop/notched-free viewport, so the only
#: slack is sub-pixel rounding. A notched device would shift the box up by
#: the inset — the same few px of headroom the assertion allows.
FLUSH_PX = 4
#: The sticky header's band (phase 12 pins it at 64px) — the pinned
#: composer must never enter it.
HEADER_PX = 64
#: How much of the viewport may sit BELOW a resting composer as chrome:
#: the footer's own measured height plus the flow padding the settled slot
#: keeps (`main { padding-block: 1.25rem }` + the column's 1rem gap, with
#: headroom for the safe-area inset). Anything more is wasted dead space —
#: the empty chat must NOT leave the input hanging mid-screen.
BOTTOM_SLACK_PX = 56
#: The resting composer also has to be visibly in the lower part of the
#: screen — the pre-revision geometry rested at ~57% of the viewport here.
LOWER_PART = 0.8
# The typing indicator is itself a .msg.brain — exclude its bubble.
ANSWER = ".msg.brain .bubble:not(.typing)"
# ---------------------------------------------------------------------------
# KB seeding (same pattern as the phase 42/48/49 story suites)
# ---------------------------------------------------------------------------
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's sync API keeps an asyncio loop running on the test
thread, so ``asyncio.run`` cannot be called directly from a test body.
"""
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 _reset_db(mock_port: int, seed: bool) -> ImportSummary | None:
"""Truncate the KB (and query log), then optionally re-import fixtures."""
with SessionLocal() as db:
db.execute(text("TRUNCATE chunks, documents, query_log"))
db.commit()
if not seed:
return None
return _run_in_thread(_import_fixtures(mock_port))
@pytest.fixture()
def seeded_kb(mock_llm: int, db_ready: None) -> Iterator[None]:
"""A fresh KB seeded from ``tests/fixtures/docs`` (13 docs, A9 formats),
truncated again on teardown. ``db_ready`` (conftest) skips with clear
instructions when Postgres is down."""
summary = _reset_db(mock_llm, seed=True)
assert summary is not None and summary.added == 13
yield
_reset_db(mock_llm, seed=False)
# ---------------------------------------------------------------------------
# Measurement + flow helpers
# ---------------------------------------------------------------------------
def scroll_state(page: Page) -> dict[str, float]:
"""The document scroller's state (there is no inner scroll container)."""
return page.evaluate(
"() => ({ y: window.scrollY, "
"sh: document.documentElement.scrollHeight, "
"ch: window.innerHeight })"
)
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
wait; the explicit timeout covers the mock's ~8 s long turn)."""
expect(page.locator("#send-btn")).to_be_enabled(timeout=timeout)
expect(page.locator("#send-label")).to_have_text("Send", timeout=timeout)
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:
"""Settle ``n`` short grounded turns through the UI (the overflow is
real conversation content, not injected DOM)."""
for question in SHORT_QUESTIONS[:n]:
submit(page, question)
wait_settled(page)
def box_of(page: Page, selector: str) -> dict[str, float]:
"""The selector's viewport-relative box as a plain dict.
Measured WITHOUT scrolling (``bounding_box`` never scrolls the page), so
the answer is "where the box sits where the user left it" — exactly the
question this story asks. Playwright hands back a FloatRect; the helpers
below speak ``dict[str, float]``.
"""
rect = page.locator(selector).bounding_box()
assert rect is not None, f"{selector} must be rendered (no bounding box)"
return {
"x": rect["x"],
"y": rect["y"],
"width": rect["width"],
"height": rect["height"],
}
def composer_box(page: Page) -> dict[str, float]:
return box_of(page, "#composer")
def assert_flush_with_viewport_bottom(page: Page) -> dict[str, float]:
"""The composer box is FULLY inside the viewport with its bottom edge
at the viewport bottom (± ``FLUSH_PX`` — the safe-area inset / sub-pixel
rounding). This is the whole story: the input is where the user's
pointer already is, at every scroll position."""
box = composer_box(page)
ch = scroll_state(page)["ch"]
assert box["y"] >= -FLUSH_PX, (
f"the pinned composer is clipped at the TOP of the viewport "
f"(y={box['y']:.1f}, viewport={ch:.0f})"
)
assert box["y"] + box["height"] <= ch + FLUSH_PX, (
f"the composer runs BELOW the viewport bottom — it ran away "
f"(bottom={box['y'] + box['height']:.1f}, viewport={ch:.0f})"
)
assert box["y"] + box["height"] >= ch - FLUSH_PX, (
f"the composer is not pinned to the bottom edge "
f"(bottom={box['y'] + box['height']:.1f}, viewport={ch:.0f})"
)
return box
def assert_no_overlap(a: dict[str, float], b: dict[str, float]) -> None:
"""Two bounding boxes must not overlap vertically."""
assert a["y"] + a["height"] <= b["y"] + FLUSH_PX, (
f"boxes overlap: {a} vs {b}"
)
def _assert_no_error_banner(page: Page) -> None:
"""A stop settles to idle through the same finally path — never the red
role=alert error banner (phase 48 contract)."""
banner = page.locator("#kb-banner")
expect(banner).to_be_hidden()
expect(banner).not_to_have_attribute("role", "alert")
expect(banner).not_to_have_class(re.compile(r"is-error"))
def _stored_parsed(page: Page) -> dict[str, Any]:
raw = page.evaluate(f"() => localStorage.getItem('{STORAGE_KEY}')")
assert raw is not None, "the conversation key must exist in localStorage"
return json.loads(raw)
# ---------------------------------------------------------------------------
# 1. Pinned while reading: over-viewport conversation, every scroll
# position keeps the composer inside the viewport at the bottom edge,
# and at the document bottom it settles back into flow above the footer
# ---------------------------------------------------------------------------
def test_composer_pinned_at_every_scroll_position(
page: Page, app_url: str, seeded_kb: None
) -> None:
page.set_default_timeout(30_000)
login(page, app_url, next="/")
build_conversation(page, n=len(SHORT_QUESTIONS))
# The story's precondition: a conversation longer than the viewport.
state = scroll_state(page)
assert state["sh"] > state["ch"] + 200, (
f"the conversation must overflow the viewport (sh={state['sh']:.0f}, "
f"ch={state['ch']:.0f}) — more turns are needed"
)
# Scrolled to the very top (the "reading the beginning" position): the
# pinned box sits at the bottom edge of the viewport, fully visible.
page.evaluate("() => window.scrollTo(0, 0)")
assert scroll_state(page)["y"] <= 1
assert_flush_with_viewport_bottom(page)
# And at every stop through the sticky range — the range is bounded by
# the composer's CONTAINING BLOCK (`.chat-shell`): the pin holds while
# that column's bottom edge sits below the viewport bottom, which is
# every reading position above the last screenful of conversation.
limits = page.evaluate(
"() => ({ shellBottom: document.querySelector('.chat-shell')"
".getBoundingClientRect().bottom + window.scrollY, "
"sh: document.documentElement.scrollHeight, ch: window.innerHeight })"
)
max_scroll = limits["sh"] - limits["ch"]
pin_limit = limits["shellBottom"] - limits["ch"]
assert pin_limit > 200, (
f"the conversation must keep the composer inside its sticky range "
f"for at least a screen of scrolling (limit={pin_limit:.0f})"
)
assert max_scroll > pin_limit, (
"the document bottom must leave the sticky range, so the settle "
"back into flow is covered too"
)
for y in (0, pin_limit * 0.25, pin_limit * 0.5, pin_limit * 0.75, pin_limit - 1):
page.evaluate("y => window.scrollTo(0, y)", y)
assert abs(scroll_state(page)["y"] - y) <= 1, f"the test scroll to {y} must land"
assert_flush_with_viewport_bottom(page)
# Settled at the document bottom: the sticky box has returned to its
# NORMAL FLOW position — above the footer, not hovering over it (the
# pin must not float the composer over the footer).
page.evaluate("() => window.scrollTo(0, document.documentElement.scrollHeight)")
assert abs(scroll_state(page)["y"] - max_scroll) <= 1, "must land at the document bottom"
footer_box = box_of(page, ".app-footer") # the footer must be rendered
composer = composer_box(page)
assert_no_overlap(composer, footer_box)
assert composer["y"] + composer["height"] < scroll_state(page)["ch"] - FLUSH_PX, (
"at the document bottom the composer settles into flow — it is no "
"longer glued to the viewport edge"
)
# The conversation itself is intact (6 turns, all answers landed) — the
# pin changed the geometry, not the content.
expect(page.locator(".msg.user .bubble")).to_have_count(len(SHORT_QUESTIONS))
expect(page.locator(".msg.brain .bubble")).to_have_count(len(SHORT_QUESTIONS))
expect(page.locator(".msg.brain .bubble").last).to_contain_text(MOCK_ANSWER_MARKER)
# ---------------------------------------------------------------------------
# 2. The TODO.md L3 scenario: mid-turn, scrolled up reading earlier
# content, Stop is visible and clickable without scrolling — and the
# click neither moves the viewport nor breaks the phase-48 contract
# ---------------------------------------------------------------------------
def test_stop_is_reachable_from_scrolled_up(
page: Page, app_url: str, seeded_kb: None
) -> None:
page.set_default_timeout(30_000)
login(page, app_url, next="/")
# Turn 1 (settled long answer) overflows the viewport — the user now
# has earlier content to read while turn 2 streams.
submit(page, LONG_QUESTION)
wait_settled(page)
state = scroll_state(page)
assert state["sh"] > state["ch"] + 200, "a long answer must make the document scrollable"
# Turn 2 streams (~8 s). Wait until it is observably in flight — the
# 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).
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; }",
timeout=30_000,
)
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)")
y0 = scroll_state(page)["y"]
assert y0 <= 1, "the test scroll to the top must land"
# The scrolled-up position is inside the composer's sticky range (the
# long answer + its question still extend past this screenful) — the
# premise of the owner's scenario.
assert page.evaluate(
"() => document.querySelector('.chat-shell').getBoundingClientRect().bottom"
) > scroll_state(page)["ch"]
# ... and the Stop control is right there, WITHOUT scrolling: it is
# the enabled `.is-stop` button inside the pinned composer, fully
# inside the viewport. (Before phase 52 the composer sat below the
# fold here — the "runs away as they try to click stop" bug.)
btn = page.locator("#send-btn")
expect(page.locator("#send-label")).to_have_text("Stop")
expect(btn).to_be_enabled()
expect(btn).to_have_class(re.compile(r"is-stop"))
expect(btn).to_be_visible()
btn_box = box_of(page, "#send-btn")
ch = scroll_state(page)["ch"]
assert btn_box["y"] >= 0 and btn_box["y"] + btn_box["height"] <= ch, (
f"Stop is off-screen while the user reads up-page "
f"(box={btn_box}, viewport={ch:.0f})"
)
# Clicking must not need a scroll: the click lands on Stop itself
# (Playwright hits the element at its own position, no scrolling).
assert_flush_with_viewport_bottom(page)
# Stop it — from the scrolled-up position, no scrolling involved.
btn.click()
# The viewport did not move: the pin adds no scroll call site and the
# stop path never scrolls (phase 42, sampled across the settle).
samples = [scroll_state(page)["y"]]
for _ in range(4):
time.sleep(0.25)
samples.append(scroll_state(page)["y"])
assert max(samples) - min(samples) <= 1, (
f"the Stop click moved the viewport ({samples}) — the reader is "
"yanked away from the content they were reading"
)
# Settled to idle with the phase-48 contract intact.
wait_settled(page, timeout=10_000)
expect(btn).not_to_have_class(re.compile(r"is-stop"))
_assert_no_error_banner(page)
expect(page.locator("#send-status")).to_contain_text("Answer stopped.")
# The partial is kept on screen with the "Stopped" marker; the full
# long answer never arrived.
stopped_text = answer.inner_text()
assert stopped_text.strip()
assert "Step 1:" in stopped_text
assert LONG_ANSWER_END not in stopped_text
expect(page.locator(".msg.brain .stopped-note")).to_have_count(1)
expect(page.locator(".msg.brain .stopped-note").first).to_contain_text("Stopped")
# ... and it persists through the normal bor.chat.v1 path: a fresh
# load restores the stopped record (the reader sees it wherever they
# were reading).
stored = _stored_parsed(page)
assert [m["who"] for m in stored["messages"]] == [
"user",
"brain",
"user",
"brain",
]
last = stored["messages"][-1]
assert last["stopped"] is True
assert LONG_ANSWER_END not in last["text"]
page.reload()
expect(page.locator(".msg.user .bubble")).to_have_count(2)
bubble = page.locator(ANSWER)
expect(bubble).to_have_count(2)
expect(bubble.nth(1)).to_contain_text("Step 1:")
expect(page.locator(".msg.brain .stopped-note")).to_have_count(1)
expect(page.locator(".msg.brain .stopped-note").first).to_contain_text("Stopped")
expect(bubble.nth(1)).not_to_contain_text(LONG_ANSWER_END)
wait_settled(page, timeout=10_000)
# The restored page keeps the pin too (the composer is still the
# last, sticky child of the column).
assert composer_box(page)["height"] > 0
assert page.evaluate(
"() => getComputedStyle(document.querySelector('#composer')).position"
) == "sticky"
# ---------------------------------------------------------------------------
# 3. Natural bottom: an empty/short chat RESTS the composer at the bottom
# of the screen — no dead band under it, no float over the footer
# ---------------------------------------------------------------------------
def assert_rests_at_the_screen_bottom(page: Page) -> dict[str, float]:
"""The resting (in-flow) composer sits at the bottom of the screen.
This is the half `position: sticky` cannot deliver — sticky only pulls
a box UP toward the viewport bottom while the document overflows, so on
a page that does not scroll the box rested where the empty state ended
(mid-screen) until `.messages` gained `flex: 1 1 auto`. The band below
the box must be chrome only: the footer (measured live, so phones with
the stacked footer are covered) plus the flow padding of the settled
slot.
"""
ch = scroll_state(page)["ch"]
composer = composer_box(page)
bottom = composer["y"] + composer["height"]
footer = box_of(page, ".app-footer")
assert bottom <= ch + FLUSH_PX, (
f"the resting composer hangs below the viewport bottom "
f"(bottom={bottom:.1f}, viewport={ch:.0f})"
)
assert ch - bottom <= footer["height"] + BOTTOM_SLACK_PX, (
f"dead band under the composer: {ch - bottom:.0f}px below it with a "
f"{footer['height']:.0f}px footer — the input is not at the bottom "
"of the screen"
)
assert bottom >= ch * LOWER_PART, (
f"the composer rests at {bottom / ch:.0%} of the viewport — it has "
"to sit at the bottom, not under the empty state"
)
assert_no_overlap(composer, footer) # chrome, but never overlapped
return composer
def test_empty_chat_composer_sits_at_the_screen_bottom(
page: Page, app_url: str, seeded_kb: None
) -> None:
page.set_default_timeout(30_000)
login(page, app_url, next="/")
# A fresh visitor: the empty state, nothing to scroll — and yet the
# input is already at the bottom of the screen.
expect(page.locator("#empty-state")).to_be_visible()
state = scroll_state(page)
assert state["sh"] <= state["ch"] + 1, (
"an empty chat must not be scrollable — the grow must not invent "
"scrollable space, it only redistributes the space the page has"
)
assert_rests_at_the_screen_bottom(page)
# The footer is present, in flow, BELOW the composer: the grow pushes
# the composer down to its own flow slot, it does not float it over the
# footer.
expect(page.locator(".app-footer")).to_be_visible()
# A single short turn: still nothing to scroll past, still resting at
# the bottom (the conversation has not overflowed the viewport yet).
submit(page, SHORT_QUESTIONS[0])
wait_settled(page)
assert scroll_state(page)["sh"] <= scroll_state(page)["ch"] + 1, (
"one short turn must not overflow the 800px test viewport"
)
assert_rests_at_the_screen_bottom(page)
expect(page.locator(ANSWER).last).to_contain_text(MOCK_ANSWER_MARKER)
# ---------------------------------------------------------------------------
# 4. ≤640px: the pin holds on a phone and stays UNDER the sticky header
# (no z-index was added, so the pinned box cannot cover the bar or the
# hamburger dropdown — phase 46 stacking)
# ---------------------------------------------------------------------------
def test_pin_holds_on_mobile_under_the_header(
browser: Browser, app_url: str, seeded_kb: None
) -> None:
page = browser.new_page(viewport=MOBILE)
try:
page.set_default_timeout(30_000)
login(page, app_url, next="/")
# An empty phone chat rests the input at the bottom of the screen.
expect(page.locator("#empty-state")).to_be_visible()
assert_rests_at_the_screen_bottom(page)
build_conversation(page, n=4)
state = scroll_state(page)
assert state["sh"] > state["ch"] + 200, (
f"four turns must overflow an 812px phone (sh={state['sh']:.0f}, "
f"ch={state['ch']:.0f})"
)
# …and an over-viewport one keeps it pinned there while reading.
page.evaluate("() => window.scrollTo(0, 0)")
assert page.evaluate(
"() => document.querySelector('.chat-shell').getBoundingClientRect().bottom"
) > scroll_state(page)["ch"], "the phone position must sit in the sticky range"
composer = assert_flush_with_viewport_bottom(page)
# The pinned box never enters the sticky header's band, and the
# header stays on top: the composer carries NO z-index, so DOM
# order + the header's z 20 keep the bar (and its dropdown) above.
assert composer["y"] >= HEADER_PX, (
f"the pinned composer overlaps the header band "
f"(y={composer['y']:.1f}, header={HEADER_PX}px)"
)
assert page.evaluate(
"() => getComputedStyle(document.querySelector('#composer')).zIndex"
) in {"auto", "0"}, "the composer must not be lifted above the header"
header_z = page.evaluate(
"() => getComputedStyle(document.querySelector('.app-header')).zIndex"
)
assert header_z == "20"
# Reachable touch target: the Send button stays ≥44px on a phone.
btn_box = box_of(page, "#send-btn")
assert btn_box["height"] >= 44 and btn_box["width"] >= 44
# The composer is usable there: typing + sending works from the pin.
submit(page, SHORT_QUESTIONS[1])
wait_settled(page)
expect(page.locator(".msg.user .bubble")).to_have_count(5)
finally:
page.close()