Ctrl K
ComunidadGuías…

tests/e2e/test_mobile_audit.py

Rama dev · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""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)")