t12mobile.py 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114
  1. """The mobile-layout bundle (goal 41 T12): the server-side invariants
  2. of the mobile containment contract (docs/spry-conversion.md), plus —
  3. when chromium is present — the full browser audit (test/mobile_audit)
  4. that renders every route at 360/768/1280 and fails on any horizontal
  5. overflow or dialog-fit failure.
  6. CI without chromium: the browser part skips gracefully (print + pass
  7. a SKIP marker, never a FAIL) unless T12MOBILE_BROWSER=1 demands it."""
  8. import os
  9. import re
  10. import sys
  11. sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
  12. from harness import * # noqa: F401,F403
  13. WIDE_TABLE_RE = re.compile(r"<table[^>]*>", re.IGNORECASE)
  14. # Every page that ships a <table> in its compiled markup; the audit's
  15. # route set mirrors this list.
  16. PAGES_WITH_TABLES = ("/tasks", "/goals", "/projects", "/chats",
  17. "/saddles", "/users")
  18. def run(ctx):
  19. admin = ctx.auth
  20. print("== every wide table sits in .table-wrap ==")
  21. for uri in PAGES_WITH_TABLES:
  22. page = http_get_page(admin, uri)
  23. tables = WIDE_TABLE_RE.findall(page)
  24. check(bool(tables), "%s renders at least one table" % uri)
  25. for match in WIDE_TABLE_RE.finditer(page):
  26. before = page[:match.start()]
  27. # the NEAREST enclosing div before the table must be the
  28. # wrap (allow attributes between class names)
  29. opening = before.rfind("<div")
  30. enclosing = before[opening:match.start()] if opening != -1 else ""
  31. check("table-wrap" in enclosing,
  32. "%s: table inside .table-wrap" % uri)
  33. print("== no fixed pixel widths above the phone viewport ==")
  34. # Any width/min-width style attribute or CSS in the page markup
  35. # above 360px is a phone-overflow factory; the audit found them in
  36. # dialogs and hero forms.
  37. for uri in ("/", "/tasks", "/goals", "/projects", "/rules",
  38. "/stacks", "/discoveries", "/chats", "/saddles",
  39. "/users", "/models", "/profile",
  40. "/tasks/2", "/goals/1", "/projects/2"):
  41. page = http_get_page(admin, uri)
  42. offenders = re.findall(
  43. r'(?:min-width|width)\s*[:=]\s*"?(\d{4,})', page)
  44. check(not offenders,
  45. "%s: no fixed width >= 1000px in markup" % uri)
  46. print("== dialogs use the fitting width grammar ==")
  47. # spry's dialog rule is width: min(92vw, <cap>rem); the app never
  48. # overrides it with a fixed width. Assert the dialog markup keeps
  49. # the shared classes and app.css carries no fixed dialog width.
  50. status, css = http("GET", "/_statum/resource/app.css")
  51. check(status == 200, "app.css served")
  52. fixed = [line for line in css.splitlines()
  53. if "dialog" in line and re.search(r"width:\s*\d", line)]
  54. check(not fixed, "app.css sets no fixed dialog width: %s" % fixed[:2])
  55. for uri in ("/tasks", "/goals", "/rules", "/projects"):
  56. page = http_get_page(admin, uri)
  57. for dialog_id in re.findall(r'<dialog[^>]*id="([^"]+)"', page):
  58. check(bool(dialog_id), "dialog %s has an id" % dialog_id)
  59. check('class="dialog-sm"' in page or "<dialog" not in page,
  60. "%s dialogs carry the .dialog-sm width grammar" % uri)
  61. print("== the touch-target rules exist in the served css ==")
  62. check("@media (any-pointer: coarse) and (max-width: 640px)" in css,
  63. "the phone touch-target media block is served")
  64. check(re.search(r"\.nav \.nav-toggle[^}]*min-height:\s*44px", css)
  65. is not None,
  66. "nav toggles meet the 44px floor on phones")
  67. print("== browser audit (rendered layout at 360/768/1280) ==")
  68. chromium = None
  69. for name in ("chromium-browser", "chromium", "google-chrome",
  70. "google-chrome-stable"):
  71. import shutil
  72. chromium = chromium or shutil.which(name)
  73. if not chromium and not os.environ.get("T12MOBILE_BROWSER"):
  74. print("SKIP no chromium binary — the browser audit is skipped"
  75. " (set T12MOBILE_BROWSER=1 to require it)")
  76. return
  77. import mobile_audit
  78. findings = mobile_audit.run_audit(
  79. os.environ.get("T12MOBILE_APP") or APP, (360, 768, 1280))
  80. check(findings is not None, "the browser audit ran")
  81. if findings is None:
  82. return
  83. overflows = []
  84. dialogs_bad = []
  85. for route in findings:
  86. for width in findings[route]:
  87. data = findings[route][width]
  88. if data.get("error"):
  89. overflows.append((route, width, data["error"]))
  90. continue
  91. for offender in data.get("offenders") or []:
  92. overflows.append((route, width, offender["path"]))
  93. for dialog in data.get("dialogs") or []:
  94. if not dialog.get("fits", True):
  95. dialogs_bad.append((route, width, dialog))
  96. check(not overflows,
  97. "zero horizontal overflow at 360/768/1280 (%d findings: %s)"
  98. % (len(overflows), overflows[:3]))
  99. check(not dialogs_bad,
  100. "every dialog fits the viewport at phone width (%s)"
  101. % dialogs_bad[:2])