Files
brain-of-reese/tests/e2e/test_mobile_chat_hamburger_boot.py
ducoterra 4d287155c0
Build and Push Containers / build-and-push-app (push) Successful in 2m16s
Build and Push Containers / build-and-push-db (push) Successful in 11s
phase: 88_mobile_chat_hamburger_boot
All completion criteria verified green. Final state confirmed: phase stays in `todo/` per A4 (owner device gate), task files in `complete/`, no `app/` changes, no commits made (harness commits).

## Phase 88 — final verification pass report

**Verified (all 6 tasks already complete):** `touch-action: manipulation` on mobile `#nav-toggle`; `#view-chat.chat-booted` double-rAF sticky handover (CSS gate + app.js boot IIFE); `body.nav-menu-open` marker in `setNavMenu` + ≤640px `visibility: hidden` rule; router boot contract (`wasMounted && !opts.boot` + `boot: true`); new unit module (10 pins) + 4-test real-touch E2E story.

**Test / lint / coverage:**
- `uv run pytest --cov=app --cov-report=term-missing` → 1741 passed, **99%** on `app/` (>90% ✓)
- `tests/e2e/test_mobile_chat_hamburger_boot.py` 4/4, `test_mobile_hamburger_nav.py` 8/8, `test_pinned_composer.py` 4/4, `test_smoke.py` 3/3 — each in isolation, `--no-cov` ✓
- `uv run ruff check .` clean; `uv run pyright` 0 errors ✓

**Completion criteria:** (1) 360px cold-boot touch-action + sticky handover + tap-opens-menu-with-cluster-hidden (E2E 1–2) ✓; (2) boot fires no `bor:view-refresh`, re-show fires exactly one, `/sources.html` regression (E2E 3–4) ✓; (3) full gate green ✓; (4) diff limited to 4 assets + 2 new tests + phase files, 0 changes in `app/` ✓; (5) commit deferred to harness per executor rules ✓; (6) owner device re-verification **pending** (A4 — gates the archive; no Owner report recorded yet).

**Deviations (both documented in-tree):** unit pins updated in `test_frontend_router.py`/`test_hamburger_nav.py` (their exact-text pins collided with the mandated new guard/marker text — without them the suite goes red); `boot: true` count pinned at 1 not 2 (codebase has one boot call site, no `history.state` branch — verified against git HEAD).

**Next pending phase:** none in `todo/` — pipeline awaits the owner's on-device report (archive, or `?dbg=nav` instrumentation follow-up if the menu is still dead).
2026-09-08 16:02:45 -04:00

383 lines
18 KiB
Python
Raw Permalink 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 88 E2E (Playwright): the mobile hamburger on the chat page's
COLD BOOT — the state the phase-46 suite never covers.
Owner bug report 2026-09-08 (continuation of the phase-85 report; the
phase overview's Bug basis is the trace): after the phase-76 SPA
migration, the mobile hamburger (``#nav-toggle``) is DEAD on the chat
page on two real Android phones (cache cleared, production) — only on
a FRESH LOAD / REFRESH of ``/``: the shell cold boot with the chat
view visible from the FIRST frame (in the empty state the document is
exactly 100dvh). A fresh load of ``/sources.html`` works, any
client-side switch into chat works, and the login page works;
rotation, pinch-zoom, and scrolling do not heal the dead state. The
failure lives in the real devices' touch→click / compositor pipeline —
no spec-compliant Chromium repro exists (real-touch probes at
360–412px, both auth states: the toggle is always hit-testable and a
tap opens the menu).
The phase removes EVERY surviving candidate mechanism (owner decision
A1 — belt-and-suspenders; the owner's on-device re-verification, A4,
gates the archive):
* (1) ``touch-action: manipulation`` on the toggle (task 01) — the
standard dead-mobile-button fix: removes the double-tap-zoom / pinch
/ tap-disambiguation window from THIS control's touch pipeline;
* (2) the composer cluster's sticky compositor layer deferred OUT of
the first layout commit behind ``#view-chat.chat-booted`` (task 02)
— app.js adds the class two frames after the boot settles, so the
layer is born AFTER the boot paint (the "born on a settled page"
condition the owner's phone already accepts);
* (3) the cluster hidden while the menu is open — the
``body.nav-menu-open`` marker from setNavMenu (task 03) + the ≤640px
``visibility: hidden`` rule;
* (4) the router's boot-refresh contract (task 04) — a cold boot must
dispatch NO ``bor:view-refresh`` (``mounted.chat`` starts true, so
the boot show hit the wasMounted branch and refreshed the
pre-mounted chat view; the phase-77 contract says the first show and
boot never fire it).
Two differences from the phase-46 suite
(``tests/e2e/test_mobile_hamburger_nav.py``) are the whole point of
this one:
* **Real touch.** The phase-46 suite drives the menu with
``page.click`` (mouse). THIS suite creates every page in a
``has_touch=True`` 360×800 context (the narrower edge of the
owner's devices; 1 page per test, closed in ``finally``) and drives
the toggle and the menu rows with ``page.tap`` — the touch→click
pipeline the bug lives in.
* **The cold-boot state.** Every test starts from a FRESH navigation
(the form-login redirect to ``next`` is a real document load) —
never a client-side switch — so the chat view (or, in test 4, the
RAG view) is visible from the first frame: the dead state.
Run in isolation (mock LLM; DB up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_mobile_chat_hamburger_boot.py -v --no-cov
Test → story mapping (Playwright Mapping Rule):
1. ``test_chat_boot_touch_action_and_sticky_handover`` — fresh admin
load of ``/``: the toggle's computed ``touch-action`` is
``manipulation`` (removal 1), and ``#view-chat`` carries
``chat-booted`` with ``.chat-bottom`` AND ``#composer`` computed
``position`` ``sticky`` (removal 2 — the handover landed at rest;
if the flag never lands, the gate rule keeps the cluster
``static`` and this fails).
2. ``test_hamburger_tap_opens_menu_and_hides_cluster`` — a real touch
tap on the toggle opens the menu (``aria-expanded="true"``,
``.is-open``) and, while open, ``.chat-bottom`` computed
``visibility`` is ``hidden`` (removal 3, end-to-end); a touch tap
on the "Tuning" row navigates (``/tuning.html``, ``#view-tuning``
visible) and the menu closes again (``aria-expanded="false"``, no
``.is-open``, the ``nav-menu-open`` marker gone from the body) with
``.chat-bottom`` back to ``visibility: visible``.
3. ``test_boot_does_not_fire_view_refresh`` — a pre-navigation hook on
``Element.prototype.dispatchEvent`` counts every
``bor:view-refresh`` dispatch (per document — the login hop and the
``/`` redirect each re-init the counter, while the pushState view
switches are same-document): the cold boot of ``/`` fires NONE
(removal 4 — pre-phase code fired one on the pre-mounted chat
view), and the RE-SHOW of chat (History → Chat) fires EXACTLY ONE
(the phase-77 contract is preserved, not deleted).
4. ``test_sources_boot_regression`` — a fresh 360px load of
``/sources.html`` (the rag boot the cluster change must NOT disturb
— ``#view-rag`` mounts lazily via ``sources.js``): the touch tap
still opens the menu, and the "Chat" row lands on the chat view
with ``.chat-bottom`` computed ``position: sticky`` (the handover
class lands in this session too — app.js adds it at shell boot
regardless of the boot view) and ``visibility: visible``.
"""
from __future__ import annotations
import re
from playwright.sync_api import Browser, BrowserContext, Page, ViewportSize, expect
from e2e.auth_helpers import login
#: The narrower edge of the owner's devices (the Bug basis traced the
#: dead state at 360–412px); 800px tall keeps every menu row clear of
#: the bottom cluster.
MOBILE: ViewportSize = {"width": 360, "height": 800}
#: Test 3's pre-navigation counter (Playwright ``add_init_script``):
#: hooks ``Element.prototype.dispatchEvent`` and increments
#: ``window.__borRefreshFired`` for every ``bor:view-refresh``. The
#: script re-runs on EVERY document (the counter is per-document —
#: the login.html hop and the ``/`` / ``/sources.html`` redirects each
#: start at 0), while the pushState view switches are same-document,
#: so it accumulates exactly across the re-shows under test.
REFRESH_COUNTER_HOOK = """
(() => {
window.__borRefreshFired = 0;
const orig = Element.prototype.dispatchEvent;
Element.prototype.dispatchEvent = function (ev) {
if (ev && ev.type === "bor:view-refresh") window.__borRefreshFired++;
return orig.call(this, ev);
};
})();
"""
def _touch_page(browser: Browser) -> tuple[BrowserContext, Page]:
"""A fresh 360×800 REAL-TOUCH page (context + page).
The conftest ``page`` fixture is a 1280×800 mouse page, and the
phase-46 suite's 375×812 pages are mouse-driven too — this
suite's whole point is the touch→click pipeline, so every page is
created in a ``has_touch=True`` context (1 page per test; the
caller closes page + context in ``finally``)."""
context = browser.new_context(has_touch=True, viewport=MOBILE)
return context, context.new_page()
def _wait_settled_admin(page: Page) -> None:
"""Wait until whoami has resolved for the admin (copied from
tests/e2e/test_mobile_hamburger_nav.py): the whoami reveal has
un-hidden the admin-only nav links — the nav link is the
viewport-independent settled signal (the sign-out control is the
bar copy on desktop but the #sign-out-btn-mobile dropdown copy at
≤640px, so it is not a cross-viewport probe)."""
page.wait_for_function(
"() => !document.querySelector('#nav-sources').hasAttribute('hidden')",
timeout=10_000,
)
def _chat_bottom_style(page: Page, prop: str) -> str:
"""A computed style of the chat's sticky bottom cluster
(``.chat-bottom`` — the element the phase-88 CSS rules target)."""
return page.evaluate(
f"() => getComputedStyle(document.querySelector('.chat-bottom')).{prop}"
)
# ---------------------------------------------------------------------------
# 1. Removals 1 + 2 at rest, in the dead state
# ---------------------------------------------------------------------------
def test_chat_boot_touch_action_and_sticky_handover(
browser: Browser, app_url: str, db_ready: None
) -> None:
"""The dead state, fixed at rest: a FRESH admin load of ``/``
(the form-login redirect IS the fresh document load — the chat
view is visible from the first frame) carries the toggle's
``touch-action: manipulation`` fix (removal 1), and the sticky
handover has LANDED (removal 2): ``#view-chat`` carries
``chat-booted`` and ``.chat-bottom`` AND ``#composer`` computed
``position`` is ``sticky`` — if the boot flag never lands (a
throw before the double-rAF pair), the gate rule keeps the cluster
``static`` and the position asserts fail."""
context, page = _touch_page(browser)
try:
login(page, app_url, next="/") # the redirect IS the fresh load of /
_wait_settled_admin(page)
# The handover landed: #view-chat carries chat-booted (app.js
# adds it two frames after the boot settles).
page.wait_for_function(
"() => document.getElementById('view-chat')?."
"classList.contains('chat-booted')",
timeout=10_000,
)
expect(page.locator("#view-chat")).to_have_class(re.compile(r"\bchat-booted\b"))
# Removal 1: the toggle's touch fix is live at 360px (the
# ≤640px rule; the base rule is display:none on desktop).
assert page.evaluate(
"() => getComputedStyle(document.querySelector('#nav-toggle')).touchAction"
) == "manipulation", (
"the mobile toggle must carry touch-action: manipulation "
"(the standard dead-mobile-button fix)"
)
# Removal 2 at rest: the cluster is pinned — the gate matches
# nothing once .chat-booted is present, so both sticky pairs
# compute exactly as pre-phase.
assert _chat_bottom_style(page, "position") == "sticky", (
".chat-bottom must be sticky at rest (the sticky handover "
"landed — pre-boot it is static behind .chat-booted)"
)
assert page.evaluate(
"() => getComputedStyle(document.querySelector('#composer')).position"
) == "sticky", (
"#composer must keep its own sticky pair at rest (task 02 "
"gates both selectors)"
)
finally:
page.close()
context.close()
# ---------------------------------------------------------------------------
# 2. Removal 3 end-to-end: a touch tap opens the menu, the cluster
# hides behind it, a touch tap on a row navigates
# ---------------------------------------------------------------------------
def test_hamburger_tap_opens_menu_and_hides_cluster(
browser: Browser, app_url: str, db_ready: None
) -> None:
"""In the dead state, a REAL touch tap on the toggle opens the
menu (``aria-expanded="true"``, ``.is-open``) and, while open,
``.chat-bottom`` computed ``visibility`` is ``hidden`` (removal 3
end-to-end — the body marker + the ≤640px rule); a touch tap on
the "Tuning" row navigates client-side (``/tuning.html``,
``#view-tuning`` visible) and the menu closes again —
``aria-expanded="false"``, no ``.is-open``, the ``nav-menu-open``
marker gone from the body — with ``.chat-bottom`` back to
``visibility: visible`` (visibility, not display: closing never
reflows the chat column)."""
context, page = _touch_page(browser)
try:
login(page, app_url, next="/")
_wait_settled_admin(page)
# A REAL touch tap opens the menu...
page.tap("#nav-toggle")
expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "true")
# to_have_class(string) is an EXACT match — the nav is
# "app-nav is-open", so match the token with a regex.
expect(page.locator("#app-nav")).to_have_class(re.compile(r"\bis-open\b"))
# ...and the cluster is hidden behind it (removal 3).
assert _chat_bottom_style(page, "visibility") == "hidden", (
"while the mobile menu is open, .chat-bottom must compute "
"visibility:hidden (the body.nav-menu-open marker + the "
"≤640px rule)"
)
# ...and a touch tap on the "Tuning" row navigates (the router
# intercepts the same-shell link — a view switch, never a
# document load)...
page.tap("#app-nav a[href='/tuning.html']")
expect(page).to_have_url(app_url + "/tuning.html", timeout=15_000)
expect(page.locator("#view-tuning")).to_be_visible(timeout=15_000)
# ...and the menu closed with the marker gone; the cluster is
# visible again.
expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "false")
assert "is-open" not in (page.locator("#app-nav").get_attribute("class") or ""), (
"the closed menu must not carry the .is-open state"
)
assert "nav-menu-open" not in (page.locator("body").get_attribute("class") or ""), (
"the body.nav-menu-open marker must be gone when the menu closes"
)
assert _chat_bottom_style(page, "visibility") == "visible", (
"closing the menu must bring .chat-bottom back to visible "
"(visibility, not display — no reflow)"
)
finally:
page.close()
context.close()
# ---------------------------------------------------------------------------
# 3. Removal 4: the router's boot contract — no refresh at boot,
# exactly one on a re-show
# ---------------------------------------------------------------------------
def test_boot_does_not_fire_view_refresh(
browser: Browser, app_url: str, db_ready: None
) -> None:
"""The boot-refresh contract (A3), live: a hook installed BEFORE
any navigation counts every ``bor:view-refresh`` dispatch (per
document — the login.html hop and the ``/`` redirect each re-init
the counter; the pushState view switches are same-document, so it
accumulates exactly across them). The cold boot of ``/`` fires
NONE — pre-phase code fired one on the pre-mounted chat view
(``mounted.chat`` starts true, so the boot show hit the wasMounted
branch); a first show (the History mount) fires none either (the
mount's own load is the first fetch); and the RE-SHOW of chat
(History → Chat) fires EXACTLY ONE — the phase-77 contract is
preserved, not deleted."""
context, page = _touch_page(browser)
try:
# BEFORE the login navigation: the hook re-runs on every
# document it precedes; the counter is per-document.
page.add_init_script(REFRESH_COUNTER_HOOK)
login(page, app_url, next="/")
_wait_settled_admin(page)
# THE PIN: the cold boot dispatched no refresh at all (with
# pre-phase router.js the pre-mounted chat view got one).
assert page.evaluate("() => window.__borRefreshFired") == 0, (
f"the cold boot of / must fire NO bor:view-refresh (the "
f"phase-77 contract: the first show and boot never do), "
f"got {page.evaluate('() => window.__borRefreshFired')}"
)
# Chat → History (a FIRST show — the mount's own load is the
# first fetch, never a refresh). #view-history un-hides only
# AFTER its mount (and load) resolves — the settle.
page.tap("#nav-toggle")
expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "true")
page.tap("#app-nav a[href='/history.html']")
expect(page).to_have_url(app_url + "/history.html", timeout=15_000)
expect(page.locator("#view-history")).to_be_visible(timeout=15_000)
assert page.evaluate("() => window.__borRefreshFired") == 0, (
"a first show (the mount) must not fire the refresh either"
)
# ...and back to Chat — the RE-SHOW of the already-mounted
# view fires the refresh exactly once.
page.tap("#nav-toggle")
expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "true")
page.tap("#app-nav a[href='/']")
expect(page).to_have_url(app_url + "/", timeout=15_000)
expect(page.locator("#view-chat")).to_be_visible()
assert page.evaluate("() => window.__borRefreshFired") == 1, (
"a RE-SHOW of an already-mounted view must fire the "
"refresh EXACTLY once (the phase-77 contract is "
"preserved, not deleted)"
)
finally:
page.close()
context.close()
# ---------------------------------------------------------------------------
# 4. Regression: the fresh /sources.html (rag) boot is untouched
# ---------------------------------------------------------------------------
def test_sources_boot_regression(
browser: Browser, app_url: str, db_ready: None
) -> None:
"""The rag boot, in the dead state's sibling: a fresh 360px load
of ``/sources.html`` — the one view state the cluster change must
NOT disturb (``#view-rag`` mounts lazily via ``sources.js``): the
touch tap still opens the menu (``.is-open``), and the "Chat" row
lands on the chat view with ``.chat-bottom`` computed
``position: sticky`` (the handover class lands in this session
too — app.js adds it at shell boot regardless of the boot view)
and ``visibility: visible`` (the menu's close cleared the marker)."""
context, page = _touch_page(browser)
try:
login(page, app_url, next="/sources.html") # the fresh rag boot
_wait_settled_admin(page)
expect(page.locator("#view-rag")).to_be_visible(timeout=15_000)
# The touch tap still opens the menu on the rag boot...
page.tap("#nav-toggle")
expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "true")
expect(page.locator("#app-nav")).to_have_class(re.compile(r"\bis-open\b"))
# ...and the "Chat" row lands on a pinned, visible cluster.
page.tap("#app-nav a[href='/']")
expect(page).to_have_url(app_url + "/", timeout=15_000)
expect(page.locator("#view-chat")).to_be_visible()
assert _chat_bottom_style(page, "position") == "sticky", (
"after the rag-boot switch into chat, .chat-bottom must be "
"sticky (the handover class lands at shell boot regardless "
"of the boot view)"
)
assert _chat_bottom_style(page, "visibility") == "visible", (
"the cluster must be visible once the menu closes"
)
finally:
page.close()
context.close()