tests/e2e/test_mobile_audit.py
Rama dev · réplica de lectura del repositorio original.
"""Mobile audit: no page may scroll sideways on a phone, and every control must be tappable.
Horizontal overflow is the mobile bug you cannot see on a laptop and users cannot un-see: the page
rocks left-right on every scroll, content sits off-screen, and nothing about it shows up in a
desktop screenshot. It is also invisible to a component test, because it only emerges when real
content meets a real viewport.
So this drives the REAL app at a phone viewport and asks the browser directly: is anything wider
than the screen, and is anything too small to tap. Findings are reported per route with the actual
offending element, not just a pass/fail.
.venv/bin/python -m pytest tests/e2e/test_mobile_audit.py -m e2e --no-cov -n0
"""
from __future__ import annotations
import re
import pytest
from playwright.sync_api import Browser, Page
from tests.e2e.conftest import (
OWNER_ACCOUNT_ID,
SESSION_COOKIE,
ShortlistApp,
build_real_rows,
session_serializer,
)
pytestmark = pytest.mark.e2e
#: iPhone 14/15 logical viewport — the narrowest mainstream phone still worth designing for. A page
#: that survives 390 survives nearly every Android too; 320 (iPhone SE 1st gen) is a stretch goal.
PHONE = {"width": 390, "height": 844}
#: iPhone SE (1st gen) / iPhone 5 — measured, but not enforced. See `ENFORCED_WIDTHS`.
NARROW = {"width": 320, "height": 568}
#: Which widths FAIL the build rather than just reporting.
#:
#: 390 is the floor we hold: every route, the wizard, the nav drawer and every dialog fit it exactly.
#: 320 is measured because the numbers are useful, but two pages still exceed it — the dashboard's
#: per-row counts by 13px, and the row editor's two stat tiles by 60px. Both now need a LAYOUT
#: decision (stack what is currently side by side, at a breakpoint below `sm`), not another
#: containment fix — the min-content causes have all been dealt with. That is a design call, and
#: enforcing it would either block the build on a 2016 phone or invite someone to "fix" it by
#: quietly widening the tolerance. Reported every run so it cannot be forgotten.
ENFORCED_WIDTHS = {PHONE["width"]}
#: Apple's HIG says 44x44pt, Material says 48x48dp. 40 is the floor below which a control is a
#: genuine miss-tap risk rather than merely tight — deliberately lenient so findings are real.
MIN_TAP = 40
#: How far past the viewport edge counts as overflow. Sub-pixel layout rounding routinely produces
#: fractions of a pixel; 2px keeps those out of the report without hiding anything a user would see.
SLOP = 2
# The QUESTION is "does this page scroll sideways" — the thing a user actually feels. The element
# list is diagnostics for when the answer is yes, not the verdict itself.
#
# Two ways an element can be wider than the screen without being a bug, and both must be excluded or
# the report is noise: a wide table inside an `overflow-x: auto` wrapper is a design decision, and
# anything under `overflow: hidden` (which is what Tailwind's `truncate` sets) is visually clipped —
# its bounding box is still full width, but nothing scrolls. Only unclipped overflow reaches the user.
PAGE_SCROLLS = """
() => {
const doc = document.documentElement;
return {
scrollWidth: doc.scrollWidth,
clientWidth: doc.clientWidth,
overflowBy: doc.scrollWidth - doc.clientWidth,
};
}
"""
FIND_OVERFLOW = """
() => {
const docWidth = document.documentElement.clientWidth;
const scrollable = (el) => {
for (let p = el.parentElement; p; p = p.parentElement) {
const o = getComputedStyle(p).overflowX;
if (o === 'auto' || o === 'scroll' || o === 'hidden' || o === 'clip') return true;
}
return false;
};
const out = [];
for (const el of document.querySelectorAll('body *')) {
const r = el.getBoundingClientRect();
if (r.width === 0 || r.height === 0) continue;
const style = getComputedStyle(el);
if (style.visibility === 'hidden' || style.display === 'none') continue;
// Fixed/absolute off-screen panels (a closed drawer) are parked deliberately.
if (style.position === 'fixed' && r.right <= 0) continue;
if (r.right <= docWidth + SLOP && r.left >= -SLOP) continue;
if (scrollable(el)) continue;
out.push({
tag: el.tagName.toLowerCase(),
cls: (el.getAttribute('class') || '').slice(0, 120),
left: Math.round(r.left),
right: Math.round(r.right),
width: Math.round(r.width),
text: (el.textContent || '').trim().slice(0, 60),
});
}
// Innermost first: the deepest element is the actual culprit, its ancestors just inherit the width.
return out.slice(-8);
}
""".replace("SLOP", str(SLOP))
FIND_SMALL_TAPS = """
() => {
const out = [];
const sel = 'button, a[href], input, select, [role="button"], [role="tab"], [role="option"]';
for (const el of document.querySelectorAll(sel)) {
const r = el.getBoundingClientRect();
if (r.width === 0 || r.height === 0) continue;
if (getComputedStyle(el).visibility === 'hidden') continue;
if (r.width >= MIN_TAP && r.height >= MIN_TAP) continue;
out.push({
tag: el.tagName.toLowerCase(),
w: Math.round(r.width),
h: Math.round(r.height),
label: (el.getAttribute('aria-label') || el.textContent || '').trim().slice(0, 40),
});
}
return out.slice(0, 8);
}
""".replace("MIN_TAP", str(MIN_TAP))
def _phone(browser: Browser, app: ShortlistApp, *, width: int = PHONE["width"]):
"""A phone-sized context carrying a valid owner session.
The session has to be injected the same way `conftest._owner_page` does it — without the cookie
every route bounces to /login and the audit measures the login page thirteen times while
reporting a clean bill of health.
"""
cookie = session_serializer(app.session_secret).dumps({"account_id": OWNER_ACCOUNT_ID, "username": "owner"})
viewport = {"width": width, "height": PHONE["height"]}
context = browser.new_context(base_url=app.url, viewport=viewport, is_mobile=True, has_touch=True)
context.add_cookies([{"name": SESSION_COOKIE, "value": cookie, "url": app.url}])
return context
def _routes(app: ShortlistApp) -> list[tuple[str, str, str | None]]:
"""(label, path, text to wait for) for every route a user can reach after setup."""
users = {u["username"]: u for u in app.api("GET", "/api/users").json()}
sarah = users["sarah"]["id"]
runs = app.api("GET", "/api/runs").json()
run_id = runs[0]["id"]
# Rows are `collections` in the API — the UI renamed them, the endpoint did not.
rows = app.api("GET", "/api/collections").json()
row_id = rows[0]["id"]
return [
("dashboard", "/", "picked|watched|run"),
("rows", "/rows", "Picked for You"),
("row edit", f"/rows/{row_id}", "Schedule|Audience|Name"),
("row rename", f"/rows/{row_id}/rename", "name|Rename"),
("users", "/users", "sarah"),
("privacy", "/privacy", "Privacy"),
("user detail", f"/users/{sarah}", "Because you watched|sarah"),
("runs", "/runs", "succeeded|ok"),
# "AI tokens|Summary|user" matched nothing this page renders — it shows DURATION,
# ROWS BUILT, PEOPLE. It only ever "passed" by falling through the 8s timeout.
("run detail", f"/runs/{run_id}", "ROWS BUILT|DURATION|Run #"),
("requests", "/requests", "request"),
("activity jobs", "/activity?tab=jobs", "Schedules|job|Backup"),
("activity history", "/activity?tab=history", "job|run|history"),
("activity log", "/activity?tab=log", "log|level"),
("activity changes", "/activity?tab=changes", "Plex|change"),
("settings", "/settings", "Connections"),
("settings defaults", "/settings/defaults", "Title sources"),
("settings system", "/settings/system", "Advanced|API access|Danger"),
("uninstall", "/settings/uninstall", "Uninstall|remove"),
]
def _audit(page: Page, label: str, path: str, wait: str | None) -> tuple[list, list]:
page.goto(path) # no networkidle: the app holds an SSE stream open, so it never goes idle
if wait:
try:
# Scoped to <main>, and that scope is load-bearing rather than tidiness. Unscoped, the
# match ran against the WHOLE page, where the nav rail (app-shell.tsx:320,
# `hidden ... md:flex`) and the settings sub-nav (settings-nav.tsx:43) still exist in
# the DOM while hidden below `md`. So on a phone `.first` picked the hidden nav link —
# "run" hit Runs, "user" hit Users, "job" hit Jobs, "log" hit Logs, "request" hit
# Requests — and wait_for(state="visible") then waited out the full 8s on something
# that is never going to be visible at this width.
#
# Six of thirteen routes did that: 9.6s each instead of 0.5s, and the audit then ran on
# whatever had rendered by then, so these routes were never really waiting for their
# content at all. Measured 64.4s for this test at 390px, ~110s across both widths.
page.locator("main").get_by_text(re.compile(wait, re.I)).first.wait_for(timeout=8000)
except Exception:
page.wait_for_timeout(1200) # audit whatever rendered; this is a layout check, not a content test
else:
page.wait_for_timeout(800)
page.wait_for_timeout(400) # let entrance/slide animations settle — mid-transform is not a layout
scroll = page.evaluate(PAGE_SCROLLS)
wide = page.evaluate(FIND_OVERFLOW) if scroll["overflowBy"] > SLOP else []
return (scroll, wide), page.evaluate(FIND_SMALL_TAPS)
def _report(findings: dict[str, list], kind: str) -> str:
lines = []
for label, items in findings.items():
lines.append(f"\n {label}:")
for item in items:
lines.append(f" {item}")
return f"{kind} on {len(findings)} route(s):" + "".join(lines)
@pytest.mark.parametrize("width", [PHONE["width"], NARROW["width"]], ids=["390px", "320px"])
def test_no_page_scrolls_sideways_on_a_phone(browser: Browser, app: ShortlistApp, width: int) -> None:
build_real_rows(app) # real rows, picks and history, so pages carry real-length content
context = _phone(browser, app, width=width)
page = context.new_page()
overflow: dict[str, list] = {}
taps: dict[str, list] = {}
try:
for label, path, wait in _routes(app):
(scroll, wide), small = _audit(page, label, path, wait)
if scroll["overflowBy"] > SLOP:
overflow[label] = [f"page scrolls {scroll['overflowBy']}px past {scroll['clientWidth']}px", *wide]
if small:
taps[label] = small
finally:
context.close()
# Tap targets are reported but not failed on: the threshold is a judgement call and a 38px icon
# button beside a 44px one is a design nit, not a broken page. Overflow is not a judgement call.
if taps:
print("\nTAP TARGETS UNDER {}px — {}".format(MIN_TAP, _report(taps, "small controls")))
if width not in ENFORCED_WIDTHS:
if overflow:
print(f"\nKNOWN AT {width}px — {_report(overflow, 'horizontal overflow')}")
return
assert not overflow, _report(overflow, "HORIZONTAL OVERFLOW")
def test_the_wizard_does_not_scroll_sideways_on_a_phone(browser: Browser, fresh_app: ShortlistApp) -> None:
"""Setup is the first thing anyone sees, and plenty of people will run it from the sofa."""
context = browser.new_context(base_url=fresh_app.url, viewport=PHONE, is_mobile=True, has_touch=True)
page = context.new_page()
try:
(scroll, wide), small = _audit(page, "wizard", "/setup", "Shortlist|Welcome|Get started")
if small:
print(f"\nTAP TARGETS UNDER {MIN_TAP}px — wizard: {small}")
assert scroll["overflowBy"] <= SLOP, _report({"wizard": wide}, "HORIZONTAL OVERFLOW")
finally:
context.close()
def test_the_mobile_drawer_opens_and_covers_the_nav(browser: Browser, app: ShortlistApp) -> None:
"""The sidebar is hidden below `md`, so the drawer is the ONLY way to navigate on a phone. If it
fails to open, every page past the dashboard is unreachable — the worst mobile failure there is."""
context = _phone(browser, app)
page = context.new_page()
try:
page.goto("/")
page.get_by_role("button", name="Open menu").click()
# Scoped to the drawer, not the page. Unscoped, ANY link reading "Rows" satisfied this —
# and the dashboard behind the drawer now has one (the health strip's Rows chip), so the
# assertion would have passed with the drawer stuck shut.
drawer = page.get_by_role("dialog", name="Main menu")
drawer_links = drawer.get_by_role("link")
drawer_links.first.wait_for(timeout=4000)
labels = [t.strip() for t in drawer_links.all_inner_texts() if t.strip()]
assert any("Rows" in t for t in labels), f"drawer opened without the main nav: {labels}"
page.wait_for_timeout(500) # the panel slides in; measuring mid-transform reports a phantom
scroll = page.evaluate(PAGE_SCROLLS)
assert scroll["overflowBy"] <= SLOP, _report(
{"drawer open": [scroll, *page.evaluate(FIND_OVERFLOW)]}, "HORIZONTAL OVERFLOW"
)
finally:
context.close()
#: (label, route, the control that opens it, text proving it opened). Dialogs are the blind spot a
#: route sweep cannot reach: they mount over the page, size themselves independently of it, and a
#: footer of buttons is exactly the shape that runs off a narrow screen. `{row}` is the first row's
#: id: a row's three dialogs open from its editor (Live on Plex and the Danger zone), not the list.
DIALOGS: list[tuple[str, str, str | re.Pattern[str], str]] = [
("rename a row", "/rows/{row}", "Rename on Plex…", "Rename|name"),
("delete a row", "/rows/{row}", re.compile(r"^Delete "), "Delete|permanently|for good"),
("remove a row from Plex", "/rows/{row}", re.compile(r"^Remove .* from Plex$"), "Remove|Plex"),
("run selected rows", "/runs", "Run selected rows…", "Run|rows|select"),
]
@pytest.mark.parametrize("width", [PHONE["width"], NARROW["width"]], ids=["390px", "320px"])
def test_no_dialog_scrolls_sideways_on_a_phone(browser: Browser, app: ShortlistApp, width: int) -> None:
"""Open each confirm/edit dialog and measure it.
Dialogs are `w-full max-w-lg`, so the panel itself cannot overflow — but its CONTENTS can, and a
dialog that scrolls sideways can put its confirm button out of reach with no way to scroll to it
on a touch screen. Nothing else in this file opens one.
"""
build_real_rows(app)
row_id = app.api("GET", "/api/collections").json()[0]["id"]
context = _phone(browser, app, width=width)
page = context.new_page()
overflow: dict[str, list] = {}
audited: list[str] = []
skipped: list[str] = []
try:
for label, path, opener, proof in DIALOGS:
page.goto(path.format(row=row_id))
button = page.get_by_role("button", name=opener).first
try:
# Replaces a blind 1200ms sleep, and is stricter too: that sleep was the only thing
# stopping a slow render from reading count()==0 and skipping the dialog silently.
# Kept short: a control absent at phone width pays this budget in full on every run.
button.wait_for(timeout=1500)
except Exception:
skipped.append(label)
continue # the control is not on this page in this state; the route sweep covers that
try:
button.click(timeout=5000)
# Scoped to the dialog for the same reason the route sweep scopes to <main>: matched
# against the whole page, "Run|rows|select" hit the nav rail's own "Runs"/"Rows"
# links, which are in the DOM but hidden below `md`. That resolved instantly and the
# test then measured the PAGE while believing it had a dialog open.
dialog = page.get_by_role("dialog")
dialog.get_by_text(re.compile(proof, re.I)).first.wait_for(timeout=5000)
except Exception:
continue # could not open it; not this test's job to assert the interaction
audited.append(label)
page.wait_for_timeout(400) # the panel zooms in — measuring mid-transform reports a phantom
scroll = page.evaluate(PAGE_SCROLLS)
if scroll["overflowBy"] > SLOP:
overflow[label] = [
f"page scrolls {scroll['overflowBy']}px past {scroll['clientWidth']}px",
*page.evaluate(FIND_OVERFLOW),
]
page.keyboard.press("Escape")
page.wait_for_timeout(200)
finally:
context.close()
# Every `continue` above is a dialog that went unmeasured. Without this the test could audit
# nothing at all and still pass green, which is exactly how the nav-link match hid for so long.
assert audited, f"opened no dialogs at {width}px — expected {[d[0] for d in DIALOGS]}"
# Printed rather than asserted: this test measures layout, and which controls a phone exposes is
# a product question. But it should be VISIBLE, not silently skipped as it was before.
if skipped:
print(f"\nNOT REACHABLE at {width}px, so unmeasured: {skipped}")
assert not overflow, _report(overflow, "HORIZONTAL OVERFLOW (dialogs)")