Files
decePubClient/tests/e2e/Mobile/fit.js
T
thepraandClaude Opus 5.5 63d83810d7 Phones in the end-to-end tests
The browser fixture opens phones from Playwright's own device presets (360x800, iPhone SE 375x667, Pixel 7 412x839,
and the Pixel 7 held sideways), with touch, a coarse pointer and reduced motion, and a phone session fails at once if
the page doesn't see a touch screen. Mobile/fit.js measures a page one screen at a time down its main scroller:
sideways scroll, boxes past the screen's sides, open menus and dialogs cut or outside the screen, targets without a
44px hit area (hit-tested at their centre and 21px around it, so a crowding neighbour fails them too) and control text
under 12px. RouteSweepTests runs every feed tab, threads of each kind the town holds, profiles, search, Explore,
lists, groups, filters, scheduled posts, settings and administration (every section opened) and the login page on the
three portrait phones, as client.mobile.fit.* and client.mobile.touch.* cells; screenshots, findings and a contact
sheet go to tests/e2e/out/mobile/. DesktopUnchangedTests holds desktop's control sizes. Category=Mobile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 22:40:28 +02:00

268 lines
10 KiB
JavaScript

// The phone tests' measurements (Mobile/Fit.cs), added to every page of a phone session as an init script. Each call
// returns plain data, so no element handle outlives a scroll: Virtualize recycles the feed's cards.
// settle() waits for fonts, for the page to stop changing, two frames and the pictures in view
// primary() marks the page's main scroller, the one the sweep steps through
// step(f) scrolls it by f of its height; false at its end
// measure() what doesn't fit: sideways overflow, boxes beyond the screen, cut popovers, small targets, small text
window.__fit = (() => {
const scrolls = value => value === "auto" || value === "scroll"
const shown = el => el.checkVisibility({ opacityProperty: true, visibilityProperty: true })
let styles = new Map()
const style = el => {
let found = styles.get(el)
if (!found)
styles.set(el, found = getComputedStyle(el))
return found
}
// a short name for an element: its id, else its first data-* attribute, else tags and classes up to three levels
function name(el, depth = 0) {
if (el === document.scrollingElement || el === document.documentElement || el === document.body)
return "document"
if (el.id)
return "#" + el.id
const data = [...el.attributes].find(a => a.name.startsWith("data-") && !a.name.startsWith("data-fit"))
const own = el.tagName.toLowerCase() + (data ? `[${data.name}]` : [...el.classList].slice(0, 3).map(c => "." + c).join(""))
const parent = el.parentElement
return data || depth >= 2 || !parent || parent === document.body ? own : `${name(parent, depth + 1)} > ${own}`
}
const text = el => (el.getAttribute("aria-label") || el.getAttribute("title") || el.innerText || el.value || "")
.trim().replace(/\s+/g, " ").slice(0, 40)
// the card an element is in (its ActivityPub id), for finding it again
const card = el => el.closest("[data-uri]")?.getAttribute("data-uri") ?? null
function describe(el, box) {
return {
element: name(el), text: text(el), card: card(el),
box: [box.left, box.top, box.width, box.height].map(Math.round)
}
}
function scrollers() {
const found = [document.scrollingElement]
for (const el of document.body.querySelectorAll("*")) {
const s = style(el)
if ((scrolls(s.overflowX) || scrolls(s.overflowY)) && el.clientWidth > 0 && el.clientHeight > 0 && shown(el))
found.push(el)
}
return found
}
// the part of an element's box that can be seen: cut by every ancestor that clips, up to the screen for a fixed one
function visibleRect(el) {
const own = el.getBoundingClientRect()
const box = { left: own.left, top: own.top, right: own.right, bottom: own.bottom }
if (style(el).position !== "fixed")
for (let a = el.parentElement; a && a !== document.body && a !== document.documentElement; a = a.parentElement) {
const s = style(a)
if (s.overflowX !== "visible" || s.overflowY !== "visible") {
const clip = a.getBoundingClientRect()
if (s.overflowX !== "visible") {
box.left = Math.max(box.left, clip.left)
box.right = Math.min(box.right, clip.right)
}
if (s.overflowY !== "visible") {
box.top = Math.max(box.top, clip.top)
box.bottom = Math.min(box.bottom, clip.bottom)
}
}
if (s.position === "fixed")
break
}
return box
}
// sideways scroll in the document or in a scroller that isn't meant to scroll sideways (data-scroll-x), with the
// element reaching furthest right
function overflow() {
const found = []
for (const scroller of scrollers()) {
if (scroller.hasAttribute?.("data-scroll-x"))
continue
const extra = scroller.scrollWidth - scroller.clientWidth
if (extra <= 1)
continue
const limit = scroller === document.scrollingElement ? innerWidth : scroller.getBoundingClientRect().right
let worst = null, right = limit + 0.5
for (const el of scroller.querySelectorAll("*")) {
const r = el.getBoundingClientRect()
if (r.width > 0 && r.right > right && !el.closest("[data-scroll-x]")) {
worst = el
right = r.right
}
}
found.push({
element: name(scroller), extra: Math.round(extra),
culprit: worst ? { ...describe(worst, worst.getBoundingClientRect()), past: Math.round(right - limit) } : null
})
}
return found
}
// what can be seen of it lies past the screen's sides: the outermost such element only
function beyond() {
const found = [], reported = []
for (const el of document.body.querySelectorAll("*")) {
if (found.length >= 30)
break
if (el.closest("[data-scroll-x]") !== null && !el.hasAttribute("data-scroll-x"))
continue
const own = el.getBoundingClientRect()
if (own.width <= 1 && own.height <= 1 || reported.some(r => r.contains(el)) || !shown(el))
continue
const seen = visibleRect(el)
if (seen.right - seen.left <= 0.5 || seen.bottom - seen.top <= 0.5)
continue
if (seen.right > innerWidth + 1 || seen.left < -1) {
reported.push(el)
found.push(describe(el, own))
}
}
return found
}
// open menus, dialogs, sheets and tips must be whole: inside the screen and not cut by what holds them
function popovers() {
const found = []
for (const el of document.querySelectorAll(".dropdown-content, .modal-open > .modal-box, [data-sheet], [data-nav-panel], .tooltip-content")) {
if (!shown(el))
continue
const own = el.getBoundingClientRect()
if (own.width < 1 || own.height < 1)
continue
const seen = visibleRect(el)
const outside = own.left < -1 || own.right > innerWidth + 1 || own.top < -1 || own.bottom > innerHeight + 1
const cut = seen.left > own.left + 1 || seen.right < own.right - 1 || seen.top > own.top + 1 || seen.bottom < own.bottom - 1
if (outside || cut)
found.push({ ...describe(el, own), outside, cut })
}
return found
}
const candidates = "a[href], button, select, textarea, summary, [role=button], [role=tab], [role=link], [tabindex='0'], "
+ "input:not([type=hidden]), label:has(> input[type=checkbox]), label:has(> input[type=radio]), label:has(> .toggle)"
function exempt(el) {
return el.tagName === "A" && el.closest(".post-content") !== null
|| el.matches("video, audio, :disabled, [aria-disabled=true], .btn-disabled, .modal-backdrop > button")
|| el.closest("[aria-hidden=true], [data-touch-exempt], [inert]") !== null
// a checkbox, radio or toggle inside a label is measured as its label
|| el.matches("input[type=checkbox], input[type=radio]") && el.closest("label") !== null
}
// a tap at this point reaches the element: on it, inside it, on its label's control, or on a label holding it
function reaches(el, x, y) {
const hit = document.elementFromPoint(x, y)
if (!hit)
return false
if (hit === el || el.contains(hit))
return true
if (el.tagName === "LABEL" && el.control && (hit === el.control || el.control.contains(hit)))
return true
const label = hit.closest("label")
return label !== null && label.contains(el)
}
// a target needs a 44x44 CSS px hit area (Apple's guideline, WCAG 2.5.5): the centre and four points 21px away must
// reach it, so a neighbour reaching into that square fails it as much as a small box does. Only targets a tap can reach
// now are judged; one under an overlay or out of view is judged at another step
function targets() {
const small = [], tiny = []
for (const el of document.querySelectorAll(candidates)) {
if (exempt(el) || !shown(el))
continue
const own = el.getBoundingClientRect()
if (own.width < 1 || own.height < 1)
continue
const x = own.left + own.width / 2, y = own.top + own.height / 2
if (x < 0 || x >= innerWidth || y < 0 || y >= innerHeight)
continue
const seen = visibleRect(el)
if (x < seen.left || x > seen.right || y < seen.top || y > seen.bottom || !reaches(el, x, y))
continue
if (el.matches("input[type=range]")) {
if (own.height < 28 || own.width < 120)
small.push({ ...describe(el, own), failed: ["range"] })
continue
}
const failed = []
for (const [dx, dy, side] of [[-21, 0, "left"], [21, 0, "right"], [0, -21, "up"], [0, 21, "down"]]) {
const px = Math.min(innerWidth - 1, Math.max(0, x + dx)), py = Math.min(innerHeight - 1, Math.max(0, y + dy))
if (!reaches(el, px, py))
failed.push(side)
}
if (failed.length > 0)
small.push({ ...describe(el, own), failed })
if (text(el) && el.innerText?.trim() && parseFloat(style(el).fontSize) < 12)
tiny.push({ ...describe(el, own), fontSize: parseFloat(style(el).fontSize) })
}
return { small, tiny }
}
function measure() {
styles = new Map()
const { small, tiny } = targets()
const result = { url: location.pathname + location.search, overflow: overflow(), beyond: beyond(), popovers: popovers(), small, tiny }
styles = new Map()
return result
}
function primary() {
styles = new Map()
document.querySelectorAll("[data-fit-primary]").forEach(e => e.removeAttribute("data-fit-primary"))
let best = null, area = 0
for (const el of scrollers()) {
if (el.scrollHeight <= el.clientHeight + 4)
continue
const size = el.clientWidth * el.clientHeight
if (size > area) {
best = el
area = size
}
}
best ??= document.scrollingElement
best.setAttribute("data-fit-primary", "")
styles = new Map()
return name(best)
}
function step(fraction) {
const el = document.querySelector("[data-fit-primary]") ?? document.scrollingElement
const before = el.scrollTop
el.scrollTo({ top: before + el.clientHeight * fraction, behavior: "instant" })
return el.scrollTop > before + 1
}
async function settle() {
await document.fonts.ready
await new Promise(resolve => {
let timer
const done = () => {
observer.disconnect()
clearTimeout(timer)
clearTimeout(cap)
resolve()
}
const observer = new MutationObserver(() => {
clearTimeout(timer)
timer = setTimeout(done, 400)
})
observer.observe(document.body, { subtree: true, childList: true, characterData: true, attributes: true })
timer = setTimeout(done, 400)
const cap = setTimeout(done, 8000)
})
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))
const until = Date.now() + 3000
const inView = img => {
const r = img.getBoundingClientRect()
return r.bottom > 0 && r.top < innerHeight && r.right > 0 && r.left < innerWidth
}
while (Date.now() < until && [...document.images].some(img => inView(img) && !img.complete))
await new Promise(resolve => setTimeout(resolve, 100))
}
return { settle, primary, step, measure }
})()