Files
brain-of-reese/tests/e2e/test_mobile_hamburger_nav.py
T
ducoterra 412a560348 phase: 85_mobile_menu_gate_overlap
All verification complete. Final report:

**Phase 85 final verification pass — all green** (tasks 01–03 already complete; independently re-verified from scratch)
- Verified fix in tree: `styles.css` `.auth-gate` z-index 500→15 + rewritten stacking comment (no `z-index: 500` left); `index.html`/`document.html` comment-only; `token-gate.js` docstring-only (logic byte-identical); `test_mobile_hamburger_nav.py` real-click conversion + new `test_anonymous_toggle_tappable_with_gate_up`; new `tests/unit/test_gate_header_stacking.py` (3 pins); `test_api_tokens.py` untouched
- `uv run pytest` → 1717 passed, 1 warning (exit 0)
- `uv run pytest --cov=app --cov-report=term-missing` → TOTAL **99%** (>90%)
- E2E in isolation: `test_mobile_hamburger_nav.py` **8 passed**; `test_api_tokens.py` **9 passed** (unchanged); `test_smoke.py` **3 passed**; `test_document_viewer.py` **7 passed**
- `uv run ruff check . && uv run pyright` → clean / 0 errors
- Live probe (375×812, anonymous, fresh server): on `/` and `/history.html` `elementFromPoint` at toggle → SVG `path`, never `#auth-gate`; real click opens menu (`aria-expanded=true`); exactly one visible nav link (Chat) + sign-in copy; Chat link topmost over gate; `#main` inert — criteria 1, 2, 3 confirmed directly
- Criteria: (1) real-click menu on / + /history ✅ (2) dropdown above gate ✅ (3) anonymous contents + #main inert ✅ (4) admin byte-identical (phase-46 tests green) ✅ (5) doc-viewer gate under bar (CSS pin + doc-viewer suite) ✅ (6) full gate green ✅ (7) diff scoped to the 5 code files + new unit test + phase files, nothing in `app/` ✅ (8) commit + todo→complete move left to harness per executor rules (task files already in `complete/`)
- Note: `.agents/remediation_plan.md` (untracked) is a pre-existing artifact of the earlier security audit — left untouched; a missing "N passed" line seen mid-pass was my own `-q`+addopts `-q` = `-qq` quirk, not a defect
- Next pending phase: `86_history_page_width`
2026-09-08 01:56:37 -04:00

546 lines
24 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 46 E2E (Playwright): the mobile hamburger dropdown nav.
Story: ``.agents/user_stories/mobile-hamburger-nav.md``
TODO.md L9 (owner permission 2026-08-27): "The navbar on mobile is way
too squished. Make it a hamburger dropdown menu with a nice animation."
Run in isolation (mock LLM; DB up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_mobile_hamburger_nav.py -v --no-cov
Contract under test: at ≤640px the nav links LEAVE the bar — a 44px
``#nav-toggle`` hamburger opens ``#app-nav`` as an animated (180ms
slide+fade) edge-to-edge dropdown with comfortable rows, the auth
visibility contract intact INSIDE the menu; at >640px the bar is
byte-identical to pre-phase-46 (hamburger absent, inline pills). No
document is ever needed — the suite exercises the shared header only.
Phase 85 (TODO.md L3): the anonymous visitor meets the token gate —
but the gate (z-index 15) now sits BELOW the sticky header (z 20), so
a REAL tap on ``#nav-toggle`` reaches the toggle for anonymous
visitors exactly as for admin: every test in this suite drives the
menu with real clicks (the phase-79 JS-dispatched workaround for the
gate-overlay interception is retired — the gate covers only ``#main``,
which stays ``inert``; it no longer covers the header).
The conftest ``page`` fixture is 1280×800, so the mobile tests create
fresh 375×812 pages via the session ``browser`` fixture (one page per
test; the reduced-motion test gets its own context).
Test → story mapping (Playwright Mapping Rule):
1. ``test_mobile_hamburger_visible_and_bar_roomy`` — 375px: the toggle
is a ≥44px visible button (``aria-expanded="false"``, closed), the
inline nav links are not visible in the bar (the closed dropdown is
opacity 0 + visibility hidden), and the page does not overflow
horizontally.
2. ``test_anonymous_menu_contents`` — anonymous at 375px: the menu
shows EXACTLY one visible link ("Chat"); the three admin-only links
stay ``hidden`` inside the menu; the open flips ``aria-expanded``.
3. ``test_anonymous_toggle_tappable_with_gate_up`` — the TODO.md L3
regression pin: anonymous toggle tappable with the gate up —
``elementFromPoint`` at the toggle center never ``#auth-gate``,
real click opens the menu above the gate.
4. ``test_admin_menu_contents`` — admin at 375px: the menu shows all
four links (the whoami reveal works inside the menu).
5. ``test_link_click_navigates_and_closes`` — admin at 375px: clicking
"RAG" navigates to /sources.html and the menu on the arrival
page ships closed.
6. ``test_esc_and_outside_close`` — Esc closes AND returns focus to the
toggle; an outside click does NOT close (accepted — see the test
docstring for why).
7. ``test_animation_and_reduced_motion`` — motion allowed: the
180ms opacity/transform transition pair is live and the open flips
class + aria; ``reducedMotion: "reduce"``: no transition in EITHER
state (the .is-open state included — the specificity trap) and
open/close still works.
8. ``test_desktop_unchanged`` — 1280×800 regression: the hamburger is
``display: none`` and the inline nav renders in the bar exactly as
before (admin: all four links, all inside the header band).
"""
from __future__ import annotations
import re
from playwright.sync_api import Browser, Page, ViewportSize, expect
from e2e.auth_helpers import login
MOBILE: ViewportSize = {"width": 375, "height": 812} # the story's phone viewport
DESKTOP: ViewportSize = {"width": 1280, "height": 800} # the conftest page size
NAV_LINKS = ("#app-nav a[href='/']", "#nav-sources", "#nav-git-sources", "#nav-tuning")
LINK_TEXTS = ("Chat", "RAG", "Sources", "Tuning")
def _mobile_page(browser: Browser) -> Page:
"""A fresh 375×812 page (the conftest ``page`` is 1280×800)."""
return browser.new_page(viewport=MOBILE)
def _wait_settled_anonymous(page: Page) -> None:
"""Wait until whoami has resolved for the anonymous visitor: the bar
Sign in copy (``#sign-in-link``) loses its ship-hidden attribute —
the phase-16 settled state, probed by attribute (not visibility):
at ≤640px the bar copy is CSS-hidden behind the ``#sign-in-link-mobile``
dropdown copy (phase 46), so visibility is viewport-dependent."""
page.wait_for_function(
"() => !document.querySelector('#sign-in-link').hasAttribute('hidden')",
timeout=10_000,
)
def _wait_settled_admin(page: Page) -> None:
"""Wait until whoami has resolved for the admin: the whoami reveal
has un-hidden the admin-only nav links (the menu-contents assertions
must run on a settled auth state). 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 (phase-46 UX revision), so it is not a cross-viewport
probe."""
page.wait_for_function(
"() => !document.querySelector('#nav-sources').hasAttribute('hidden')",
timeout=10_000,
)
def _visible_nav_links(page: Page) -> list[str]:
"""The texts of the nav links that are actually visible (Playwright
visibility: non-empty box AND not visibility:hidden/display:none —
so the closed dropdown and the ``hidden`` admin links both count as
invisible)."""
return [
page.locator(sel).inner_text()
for sel in NAV_LINKS
if page.locator(sel).is_visible()
]
def _open_menu(page: Page) -> None:
"""Open the menu with a REAL click on the toggle.
Phase 85 (TODO.md L3): the gate (z-index 15) sits BELOW the sticky
header (z 20), so a real tap reaches the toggle for anonymous
visitors too — the phase-79 JS-dispatched workaround (the gate then
covered the header at z 500 and intercepted the click) is retired,
and every test in this suite uses this one real-click helper."""
page.click("#nav-toggle")
expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "true")
# to_have_class(string) is an EXACT match on the class attribute —
# 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"))
expect(page.locator("#app-nav")).to_have_css("opacity", "1")
def _assert_menu_closed(page: Page) -> None:
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"
)
# ---------------------------------------------------------------------------
# 1. Bar: the toggle is a roomy 44px target and the nav is out of the bar
# ---------------------------------------------------------------------------
def test_mobile_hamburger_visible_and_bar_roomy(
browser: Browser, app_url: str, db_ready: None
) -> None:
"""AC1/AC5: at 375px the bar carries a ≥44px ``#nav-toggle`` (closed,
``aria-expanded="false"``), the inline nav links are NOT visible in
the bar (the closed dropdown is opacity 0 + visibility hidden — the
layout box is top:100% less the 8px slide offset, so the story's
opacity-0 branch is what pins it), and there is no horizontal
overflow — the old squished pills are gone, so the bar has room."""
page = _mobile_page(browser)
try:
page.goto(app_url)
_wait_settled_anonymous(page)
# The hamburger: a visible ≥44px×44px touch target (the phase-07
# floor), shipped closed.
toggle = page.locator("#nav-toggle")
expect(toggle).to_be_visible()
box = toggle.bounding_box()
assert box is not None, "the toggle must have a box"
assert box["width"] >= 44 and box["height"] >= 44, (
f"the toggle must be a ≥44px touch target, got "
f"{box['width']:.0f}×{box['height']:.0f}"
)
expect(toggle).to_have_attribute("aria-expanded", "false")
# The inline nav links are not visible in the bar: the closed
# dropdown is opacity 0 + visibility hidden. (The layout box is
# top:100% minus the 8px slide offset — inside the band — but
# invisible, which is the acceptance branch: opacity 0.)
assert page.evaluate(
"() => getComputedStyle(document.querySelector('#app-nav')).opacity"
) == "0", "the closed menu must be opacity 0"
assert page.evaluate(
"() => getComputedStyle(document.querySelector('#app-nav')).visibility"
) == "hidden", "the closed menu must be visibility hidden"
for sel in NAV_LINKS:
assert not page.locator(sel).is_visible(), (
f"{sel} must not be visible in the bar with the menu closed"
)
# No horizontal overflow at 375px (the old four squished text
# pills are gone from the bar).
assert page.evaluate(
"() => document.documentElement.scrollWidth"
) <= page.evaluate("() => window.innerWidth"), (
"the 375px bar must not overflow horizontally"
)
finally:
page.close()
# ---------------------------------------------------------------------------
# 2. Menu contents per auth state (the whoami contract inside the menu)
# ---------------------------------------------------------------------------
def test_anonymous_menu_contents(
browser: Browser, app_url: str, db_ready: None
) -> None:
"""AC2 (anonymous): at 375px the opened menu shows EXACTLY one
visible link — "Chat". The three admin-only links keep their
ship-hidden state INSIDE the menu (the phase-19/35 contract is
preserved by reusing the same <nav> element); opening flips
aria-expanded true.
Phase 85 (TODO.md L3): the anonymous visitor meets the token gate
— but the gate (z 15) now sits BELOW the header (z 20), so the
toggle is a real, tappable target and the menu is driven with a
real click exactly as for admin."""
page = _mobile_page(browser)
try:
page.goto(app_url)
_wait_settled_anonymous(page)
_assert_menu_closed(page)
_open_menu(page)
assert _visible_nav_links(page) == ["Chat"], (
"anonymous: the menu must show exactly one visible link (Chat)"
)
for sel in ("#nav-sources", "#nav-git-sources", "#nav-tuning"):
expect(page.locator(sel)).to_be_hidden()
# A second click closes it again — aria-expanded round-trips.
page.click("#nav-toggle")
_assert_menu_closed(page)
finally:
page.close()
def test_anonymous_toggle_tappable_with_gate_up(
browser: Browser, app_url: str, db_ready: None
) -> None:
"""TODO.md L3 regression pin (phase 85): the anonymous visitor meets
the token gate, but the gate (z-index 15) sits BELOW the sticky
header (z 20) — so the toggle is a REAL, tappable target:
``document.elementFromPoint`` at the toggle center resolves to the
toggle itself (or its SVG child), NEVER ``#auth-gate`` (the exact
probe that returned the gate in the bug reproduction), and a real
click opens the menu ABOVE the gate — the "Chat" link visible with
the gate still up (the dropdown's z context, 21 inside the
header's 20, paints over the gate's 15). The lock is untouched:
``#main`` stays ``inert``. Leaves the page clean — Esc settles the
menu closed."""
page = _mobile_page(browser)
try:
page.goto(app_url)
_wait_settled_anonymous(page)
# The gate is up and the app is locked (the phase-79 contract)…
expect(page.locator("#auth-gate")).to_be_visible(timeout=30_000)
assert page.evaluate("() => document.getElementById('main').inert === true"), (
"the gate must keep #main inert while it is up"
)
# …but the probe at the toggle center no longer resolves to the
# gate: it hits the toggle itself or a descendant of it (the
# SVG path) — the exact probe that returned #auth-gate in the
# bug reproduction (TODO.md L3).
probe = page.evaluate(
"""() => {
const t = document.querySelector('#nav-toggle');
const r = t.getBoundingClientRect();
const el = document.elementFromPoint(
r.left + r.width / 2, r.top + r.height / 2);
return {
id: el ? el.id : null,
tag: el ? el.tagName.toLowerCase() : null,
toggle_or_descendant: el !== null && t.contains(el),
};
}"""
)
assert probe["id"] != "auth-gate", (
f"the toggle center must not resolve to the gate, "
f"got <{probe['tag']} id={probe['id']!r}> (the phase-79 bug)"
)
assert probe["toggle_or_descendant"], (
f"the toggle center must resolve to the toggle or its SVG "
f"child, got <{probe['tag']} id={probe['id']!r}>"
)
# A REAL click opens the menu (the owner's exact scenario —
# "clicking it … does not expand the menu" is fixed), and the
# menu is ABOVE the gate: with the gate still up the
# dropdown's "Chat" link is visible, and the topmost element at
# its center is the link (or a descendant), never #auth-gate.
_open_menu(page)
expect(page.locator("#auth-gate")).to_be_visible()
chat = page.locator("#app-nav a[href='/']")
expect(chat).to_be_visible()
link_probe = page.evaluate(
"""() => {
const a = document.querySelector("#app-nav a[href='/']");
const r = a.getBoundingClientRect();
const el = document.elementFromPoint(
r.left + r.width / 2, r.top + r.height / 2);
return {
id: el ? el.id : null,
tag: el ? el.tagName.toLowerCase() : null,
link_or_descendant: el !== null && a.contains(el),
};
}"""
)
assert link_probe["id"] != "auth-gate", (
f"the open dropdown must paint ABOVE the gate, the Chat "
f"link center resolved to the gate ({link_probe!r})"
)
assert link_probe["link_or_descendant"], (
f"the Chat link must be the topmost target at its center, "
f"got <{link_probe['tag']} id={link_probe['id']!r}>"
)
# Leave the page clean: Esc settles it closed.
page.keyboard.press("Escape")
_assert_menu_closed(page)
finally:
page.close()
def test_admin_menu_contents(
browser: Browser, app_url: str, db_ready: None
) -> None:
"""AC2 (admin): at 375px the opened menu shows ALL FOUR links —
Chat / RAG / Sources / Tuning — i.e. the whoami reveal
works inside the menu exactly as it does inline (one <nav>, one
set of links, the same hidden attributes header.js drives)."""
page = _mobile_page(browser)
try:
login(page, app_url, next="/")
_wait_settled_admin(page)
_open_menu(page)
assert _visible_nav_links(page) == list(LINK_TEXTS), (
f"admin: the menu must show all four links, got {_visible_nav_links(page)}"
)
for sel in NAV_LINKS:
expect(page.locator(sel)).to_be_visible()
finally:
page.close()
# ---------------------------------------------------------------------------
# 3. Link close + navigation
# ---------------------------------------------------------------------------
def test_link_click_navigates_and_closes(
browser: Browser, app_url: str, db_ready: None
) -> None:
"""AC4: a menu link click navigates AND closes the menu — and the
arrival page ships the fresh (closed) header: aria-expanded false,
no .is-open, menu invisible."""
page = _mobile_page(browser)
try:
login(page, app_url, next="/")
_wait_settled_admin(page)
_open_menu(page)
expect(page.locator("#nav-sources")).to_be_visible()
page.click("#nav-sources")
expect(page).to_have_url(app_url + "/sources.html", timeout=15_000)
# The arrival page: a fresh header, shipped closed.
_assert_menu_closed(page)
expect(page.locator("#app-nav")).to_be_hidden()
finally:
page.close()
# ---------------------------------------------------------------------------
# 4. Esc close (+ focus return) and the accepted outside-click behavior
# ---------------------------------------------------------------------------
def test_esc_and_outside_close(
browser: Browser, app_url: str, db_ready: None
) -> None:
"""AC4: Esc closes the menu AND returns focus to the toggle (the
opener — a keyboard user never loses their place).
Accepted behavior (NOT a defect, per the task-02 contract and story
AC 4): an OUTSIDE click does not close the menu. The locked close
set is Esc + link + resize — the story's AC 4 lists exactly those
three, and the owner-locked scope (2026-08-27) does not include a
backdrop click (there is no backdrop element at all — the menu is
the nav itself dropping out of the sticky header). This test pins
the menu STAYING OPEN on an outside click so an accidental
backdrop-close implementation cannot sneak in later."""
page = _mobile_page(browser)
try:
page.goto(app_url)
_wait_settled_anonymous(page)
# Phase 85 (TODO.md L3): the gate (z 15) sits below the header
# (z 20) — the real click reaches the anonymous toggle too.
# Esc closes + refocuses the opener.
_open_menu(page)
page.keyboard.press("Escape")
_assert_menu_closed(page)
assert page.evaluate("() => document.activeElement.id") == "nav-toggle", (
"Esc-close must return focus to the #nav-toggle opener"
)
# Outside click: the menu STAYS open (accepted behavior — the
# locked close set is Esc + link + resize, not backdrop click).
# Phase 85: for the anonymous visitor the "outside" point is
# the gate overlay itself — a REAL mouse click below the
# centered card, outside the nav (the gate covers the content
# but no longer the header; it carries no close listener).
_open_menu(page)
page.mouse.click(10, 780) # the gate overlay — a neutral, non-nav point
expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "true")
assert "is-open" in (page.locator("#app-nav").get_attribute("class") or ""), (
"accepted behavior: an outside click must NOT close the menu"
)
# …and the menu is still fully usable (Esc still settles it).
page.keyboard.press("Escape")
_assert_menu_closed(page)
finally:
page.close()
# ---------------------------------------------------------------------------
# 5. Animation + prefers-reduced-motion
# ---------------------------------------------------------------------------
def test_animation_and_reduced_motion(
browser: Browser, app_url: str, db_ready: None
) -> None:
"""AC3: motion allowed — the menu carries the 180ms opacity/transform
transition pair and opening flips the .is-open class + aria-expanded
in lockstep. reducedMotion: "reduce" — no transition in EITHER state
(the .is-open rule is higher-specificity than a bare .app-nav rule,
so the override must name both — pinned here against the live
computed style) and open/close still works, instantly."""
# Motion allowed: the 180ms slide+fade pair is live.
page = _mobile_page(browser)
try:
page.goto(app_url)
_wait_settled_anonymous(page)
report = page.evaluate(
"() => { const cs = getComputedStyle(document.querySelector('#app-nav'));"
" return { duration: cs.transitionDuration, property: cs.transitionProperty };"
" }"
)
assert "0.18s" in report["duration"], (
f"the menu must transition in 180ms, got {report['duration']!r}"
)
assert "opacity" in report["property"] and "transform" in report["property"], (
f"the transition must cover the opacity/transform slide+fade, "
f"got {report['property']!r}"
)
# Opening flips class + aria together (the animated state).
# Phase 85: the gate sits below the header — the real click
# reaches the anonymous toggle.
_open_menu(page)
page.keyboard.press("Escape")
_assert_menu_closed(page)
finally:
page.close()
# Reduced motion: stills in both states, open/close still works.
context = browser.new_context(
reduced_motion="reduce", viewport=MOBILE
)
rpage = context.new_page()
try:
rpage.goto(app_url)
_wait_settled_anonymous(rpage)
def _stilled(el: str) -> str:
return rpage.evaluate(
f"() => getComputedStyle(document.querySelector('{el}')).transitionDuration"
)
assert _stilled("#app-nav") == "0s", (
f"reduced motion: closed state must not transition, got {_stilled('#app-nav')!r}"
)
# Phase 85: real drive (the gate no longer intercepts the
# anonymous click — it sits below the header).
_open_menu(rpage)
expect(rpage.locator("#nav-toggle")).to_have_attribute("aria-expanded", "true")
expect(rpage.locator("#app-nav")).to_have_class(re.compile(r"\bis-open\b"))
assert _stilled("#app-nav") == "0s", (
"reduced motion: the .is-open state must not transition either "
"(the override must out-specificity .app-nav.is-open)"
)
rpage.keyboard.press("Escape")
_assert_menu_closed(rpage)
assert not rpage.locator("#app-nav").is_visible(), (
"reduced motion: the closed menu must be invisible"
)
finally:
context.close()
# ---------------------------------------------------------------------------
# 6. Desktop regression: the bar is byte-identical to pre-phase-46
# ---------------------------------------------------------------------------
def test_desktop_unchanged(
browser: Browser, app_url: str, db_ready: None
) -> None:
"""AC6 (regression): at 1280×800 the hamburger is absent
(``display: none`` — outside the ≤640px block) and the inline nav
renders in the bar exactly as before — admin sees all four links,
every one of them INSIDE the header band (no dropdown at this
width; the phase-34/35 bar contract is intact)."""
page = browser.new_page(viewport=DESKTOP)
try:
login(page, app_url, next="/")
_wait_settled_admin(page)
# The hamburger is absent on desktop (global display:none).
toggle = page.locator("#nav-toggle")
expect(toggle).to_be_hidden()
assert page.evaluate(
"() => getComputedStyle(document.querySelector('#nav-toggle')).display"
) == "none", "the toggle must be display:none outside the ≤640px block"
# The inline nav: all four links visible, each box inside the
# header band (the dropdown positioning only applies ≤640px).
expect(page.locator("#app-nav")).to_be_visible()
header_box = page.locator("header.app-header").bounding_box()
assert header_box is not None
for sel in NAV_LINKS:
link = page.locator(sel)
expect(link).to_be_visible()
box = link.bounding_box()
assert box is not None
assert box["y"] >= header_box["y"] - 1, f"{sel}: link is above the header"
assert box["y"] + box["height"] <= header_box["y"] + header_box["height"] + 1, (
f"{sel}: the inline link must sit inside the header band"
)
assert _visible_nav_links(page) == list(LINK_TEXTS)
finally:
page.close()