phase: 143_mobile_keyboard_after_answer
Build and Push Containers / build-and-push-app (push) Successful in 2m32s
Build and Push Containers / build-and-push-db (push) Successful in 13s

**Phase 143 final verification pass — 100% green, no code changes needed (all work verified in-tree)**

- **Verified implementation:** `COARSE_POINTER` constant (L395, L391-twin `typeof`-guarded shape) + gated finally-block focus `if (!COARSE_POINTER) input.focus({ preventScroll: true })` in `frontend/assets/app.js`; SETTLE lines/anchor untouched; extended unit pin in `test_frontend_scroll.py`; dedicated two-context E2E `tests/e2e/test_mobile_keyboard_focus.py`
- **C1 desktop byte-identical refocus:** ✅ unit pin passes (gated shape keeps `preventScroll`); desktop E2E asserts composer active; `test_loading_feedback.py` 5 passed (phase-06 regression)
- **C2 mobile keyboard stays down:** ✅ `test_mobile_turn_end_keeps_keyboard_down` passes — `pointer: coarse` sanity assert first, composer NOT active after done
- **C3 gates:** ✅ `uv run pytest --cov=app --cov-report=term-missing` → 3359 passed, `app/` TOTAL 99% (>90%); `uv run pytest tests/e2e/test_mobile_keyboard_focus.py -v --no-cov` → 2 passed in isolation; `uv run ruff check .` clean; `uv run pyright` 0 errors
- **C4 no regression:** ✅ full suite green (all frontend source pins: char counter, send flow, never-scroll); `test_stop_generation.py` 3 passed (single settle)
- **Docs:** README.md fully reviewed — all env vars cross-checked against `app/config.py` defaults, routes/endpoints/tools verified in code; AGENTS.md rules + quick reference verified against repo. Consistent — no changes needed
- **No live-infrastructure changes** (dev Postgres container already running)
- **Next pending phase:** `144_modal_back_button`
This commit is contained in:
2026-10-01 07:03:15 -04:00
parent aa65d2d691
commit ba4841a811
18 changed files with 817 additions and 6 deletions
+248
View File
@@ -0,0 +1,248 @@
"""Phase 143 E2E (Playwright): the mobile keyboard stays DOWN after an
answer completes — one file, two device contexts.
Owner complaint (TODO.md L5): "mobile keyboard pops back up after answer
is complete and that's annoying." The fix (task 01) gates the turn's
single settle focus-back in ``frontend/assets/app.js`` on
``matchMedia("(pointer: coarse)")``: fine pointers (desktops) keep the
phase-06 "always focus back" courtesy — the follow-up question is typed
without moving a hand — while coarse pointers (phones/tablets) stay
unfocused so the virtual keyboard does not pop back over the completed
answer; the composer is a ≥44px tap target (PLAN §7.1), so the next
question starts with a tap.
This file is the browser-visible proof on BOTH devices, in ONE variable:
* ``test_desktop_turn_end_refocuses_composer`` — the DEFAULT Chromium
context (1280×800, no touch — ``pointer: fine``): a completed turn
leaves ``document.activeElement`` on the composer's textarea
(``#message-input``) — the phase-06/42 contract (refocus,
``preventScroll``) preserved on desktop.
* ``test_mobile_turn_end_keeps_keyboard_down`` — a mobile-emulated
context (360×800, ``isMobile`` + ``hasTouch`` — the ≤640px CSS
breakpoint applies AND Chromium's touch emulation flips the media
feature to ``pointer: coarse``): FIRST the sanity assert that
``matchMedia("(pointer: coarse)").matches`` IS true (if a future
Playwright/Chromium change breaks the emulation, this line fails
loudly and names why, instead of the focus assertion failing
mysteriously), THEN a completed turn leaves the composer UNFOCUSED —
the keyboard stays down.
Both contexts run against the SAME fixture KB (the per-module
truncate/re-import, the house E2E convention — suites run in isolation)
and the SAME on-topic question the chat story suites use; the mock LLM
answers immediately, so the done state settles fast.
Run in isolation (mock LLM; DB up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_mobile_keyboard_focus.py -v --no-cov
"""
from __future__ import annotations
import asyncio
import os
import threading
from pathlib import Path
from typing import Any
from playwright.sync_api import Browser, BrowserContext, 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"
MOCK_PORT = int(os.environ.get("E2E_MOCK_PORT", "8901"))
# The same on-topic question the chat story suites (phase 06 loading
# feedback) use — the fixture KB grounds it and the mock answers
# immediately (no warm-up marker), so both contexts settle fast.
ON_TOPIC = "How is my Kubernetes cluster set up?"
# The composer's textarea — the element app.js's turn `finally` block
# focuses back on fine pointers (task 01's `#message-input` pin), and
# the `login` helper already types questions into.
COMPOSER = "#message-input"
# The typing indicator is itself a .msg.brain — exclude its bubble.
ANSWER = ".msg.brain .bubble:not(.typing)"
# The mobile context: a 360×800 phone (the ≤640px CSS breakpoint
# applies — 360 is the phase-88 suite's narrow owner-device edge) with
# isMobile + hasTouch — Chromium's CDP touch emulation is what makes
# ``matchMedia("(pointer: coarse)")`` match (the test's sanity assert
# proves it is doing its job).
MOBILE_VIEWPORT: ViewportSize = {"width": 360, "height": 800}
# --------------------------------------------------------------------------
# KB seeding (same pattern as the phase 06 story suite — truncate +
# re-import the fixture docs per test, so the file runs in isolation)
# --------------------------------------------------------------------------
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 = threading.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))
# --------------------------------------------------------------------------
# The shared turn driver + device contexts
# --------------------------------------------------------------------------
def turn_to_completion(page: Page) -> None:
"""Ask the on-topic question and wait for the DONE state.
The same settle wait the chat story suites (phase 06) use: the
answer bubble has streamed in AND the send button is back to its
idle contract (enabled, "Send" label — phase 48). By the time the
label reads "Send", the turn's ``finally`` block — the single
settle, including the phase-143 gated focus-back — has already run
(the ``setUiState(idle)`` that flips the label is the line just
above the focus call).
"""
page.fill(COMPOSER, ON_TOPIC)
page.click("#send-btn")
page.locator(ANSWER).wait_for(state="visible", timeout=30_000)
expect(page.locator("#send-label")).to_have_text("Send", timeout=30_000)
expect(page.locator("#send-btn")).to_be_enabled()
def composer_is_active(page: Page) -> bool:
"""``document.activeElement === #message-input`` (compared IN the
page — Element handles are not identity-comparable in Python)."""
return page.evaluate(
"() => document.activeElement === document.querySelector('#message-input')"
)
def _mobile_page(browser: Browser) -> tuple[BrowserContext, Page]:
"""A fresh 360×800 MOBILE page (``isMobile`` + ``hasTouch``): the
context whose touch emulation makes Chromium report
``pointer: coarse`` (the conftest ``page`` fixture is a 1280×800
fine-pointer page — the desktop control). The caller closes page +
context in ``finally``."""
context = browser.new_context(
is_mobile=True, has_touch=True, viewport=MOBILE_VIEWPORT
)
return context, context.new_page()
# --------------------------------------------------------------------------
# 1. Desktop (fine pointer): the preserved "always focus back" contract
# --------------------------------------------------------------------------
def test_desktop_turn_end_refocuses_composer(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
"""The regression guard (phase 06): in the DEFAULT Chromium context
— no touch, ``pointer: fine`` — a completed turn (the done state:
answer streamed, send button back to the idle "Send" contract)
leaves the composer FOCUSED — ``document.activeElement`` IS
``#message-input`` — the follow-up question is typed immediately.
The never-scroll ``preventScroll`` argument itself is source-pinned
by the unit pin (tests/unit/test_frontend_scroll.py) and is
unobservable from the focus assert (it suppresses viewport motion,
not focus)."""
summary = _reset_db(mock_llm, seed=True)
assert summary is not None and summary.added == 13 # A9 formats (phase 47 added quadlet+j2)
page.set_default_timeout(30_000)
login(page, app_url, next="/")
# Control sanity: this context really is a FINE pointer (the pair
# of contexts differs in exactly one variable — the device).
assert not page.evaluate("matchMedia('(pointer: coarse)').matches"), (
"the desktop control context must report pointer: fine — the "
"conftest page is a no-touch 1280x800 Chromium context"
)
turn_to_completion(page)
assert composer_is_active(page), (
"desktop (fine pointer): after a turn ends, the composer must be "
"the active element again — the phase-06 'always focus back' "
"contract is desktop-only, never removed"
)
# --------------------------------------------------------------------------
# 2. Mobile (coarse pointer): the keyboard stays down
# --------------------------------------------------------------------------
def test_mobile_turn_end_keeps_keyboard_down(
browser: Browser, app_url: str, mock_llm: int, db_ready: None
) -> None:
"""The owner's fix, browser-visible: in a 360×800 mobile context
(``isMobile`` + ``hasTouch`` → ``pointer: coarse``), a completed
turn leaves the composer UNFOCUSED — ``document.activeElement`` is
NOT ``#message-input`` — so the virtual keyboard stays DOWN over
the completed answer (the composer's ≥44px tap target, PLAN §7.1,
starts the next question). The first assert proves the emulation
is doing its job: if a future Playwright/Chromium change stops
flipping the media feature, THIS line fails loudly and names why,
instead of the focus assert failing mysteriously."""
summary = _reset_db(mock_llm, seed=True)
assert summary is not None and summary.added == 13
context, page = _mobile_page(browser)
try:
page.set_default_timeout(30_000)
# Emulation sanity FIRST (see the docstring): the touch
# emulation must have flipped the media feature.
assert page.evaluate("matchMedia('(pointer: coarse)').matches"), (
"the mobile context (isMobile + hasTouch, 360x800) must "
"report pointer: coarse — Chromium's touch emulation is not "
"flipping the media feature; the focus assert below would "
"be meaningless (fail this line, not the focus one)"
)
login(page, app_url, next="/")
turn_to_completion(page)
assert not composer_is_active(page), (
"mobile (coarse pointer): after a turn ends, the composer "
"must NOT be re-focused — the focus-back is a desktop "
"courtesy, and re-focusing pops the virtual keyboard over "
"the completed answer (the owner's complaint, TODO L5)"
)
finally:
page.close()
context.close()