Files
brain-of-reese/tests/e2e/test_mobile_hamburger_nav.py
T
ducoterra 7fce6572d0
Build and Push Containers / build-and-push-app (push) Successful in 1m45s
Build and Push Containers / build-and-push-db (push) Successful in 13s
feat: phases 77–80 — navbar view refresh, static background, API tokens, history suggestion chips
Single consolidated commit for four completed, validated phases (77, 78,
79, 80). The pipeline run left all work uncommitted because the harness
commits only with PHASE_COMMIT=1 while child executors are forbidden from
committing; the phases themselves all passed validation and moved to
.agents/phases/complete/.

Phase 77 — navbar view refresh
- router.js dispatches bor:view-refresh on re-show / active re-click /
  popstate (gated on wasMounted; first show and boot exempt)
- History / RAG / Sources / Tuning re-fetch on refresh (admin branch);
  Chat deliberately excluded (stream survival)
- History "Refresh" button (admin-only, in-flight disable + status line)
- New story suite tests/e2e/test_navbar_refresh.py (7 tests)

Phase 78 — static background
- Removed the animated glow layers; static 44px grid over the flat --bg
  canvas; default and reduced-motion renders byte-identical
- Updated background/theme E2E suites; removed bg-glow test pins

Phase 79 — API tokens
- api_tokens model + migration 0012; hash-only token service
- Admin tokens API + Tokens admin view; POST /api/token-auth;
  live-revoking require_user on chat / suggestions / document content
- Frontend token gate with localStorage cache; anonymous E2E suites
  migrated to token login
- New story suite tests/e2e/test_api_tokens.py (9 tests)

Phase 80 — history suggestion chips
- last_questions() endpoint with SEED fallback; startNewChat() refetch
- Seed-semantics docs (config.py, .env.example, README)
- Integration state matrix + E2E suite rewritten to the 4 chip states

Also included: phase-76 report artifacts and the repo restore-test-db
skill (previously untracked), scripts/* ruff fixes from phase 77.

Final gate state (phase 80 final pass, covers everything above):
- uv run pytest --cov=app → 1637 passed, 0 failed, app/ coverage 99%
- uv run ruff check . && uv run pyright → clean, 0 errors
- Per-phase story E2E suites green in isolation
2026-09-07 12:39:01 -04:00

452 lines
19 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.
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_admin_menu_contents`` — admin at 375px: the menu shows all
four links (the whoami reveal works inside the menu).
4. ``test_link_click_navigates_and_closes`` — admin at 375px: clicking
"RAG" navigates to /sources.html and the menu on the arrival
page ships closed.
5. ``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).
6. ``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.
7. ``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:
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 _js_open_menu(page: Page) -> None:
"""Phase 79 (task 05): the in-app token gate is a full-viewport
overlay for ANONYMOUS visitors — it physically covers the header,
so a real click on #nav-toggle is intercepted by the gate (the gate
is the only interactive surface; the header is locked out with the
rest of the page). The binding is identical, so the menu contract
is driven programmatically: a JS-dispatched click runs the exact
same listener a real click would."""
page.evaluate("() => document.querySelector('#nav-toggle').click()")
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"))
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 79 (task 05): the anonymous visitor meets the token gate — a
full-viewport overlay that covers the header — so the toggle is
driven programmatically (the binding is identical; see
_js_open_menu)."""
page = _mobile_page(browser)
try:
page.goto(app_url)
_wait_settled_anonymous(page)
_assert_menu_closed(page)
_js_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.evaluate("() => document.querySelector('#nav-toggle').click()")
_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 79 (task 05): the anonymous visitor's toggle click is
# intercepted by the gate overlay — drive the identical binding
# programmatically (see _js_open_menu).
# Esc closes + refocuses the opener.
_js_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 79 (task 05): for the anonymous visitor the "outside"
# point is the gate overlay itself — a REAL mouse click below
# the centered card (outside the nav, intercepted by the gate).
_js_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 79 (task 05): the anonymous toggle click is intercepted
# by the gate overlay — programmatic drive, same binding.
_js_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 79 (task 05): programmatic drive (the gate overlay
# intercepts the anonymous real click — same binding).
_js_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()