**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`
249 lines
11 KiB
Python
249 lines
11 KiB
Python
"""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()
|