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).
This commit is contained in:
@@ -0,0 +1,382 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user