Files
hermes-webui/tests/_layout_helpers.py
nesquena-hermes 903ace3f49 test(layout): don't flag scroll-reachable fields as off-viewport
The shared assert_layout_sane degenerate check flagged an interactive element
as 'off-viewport' when it sat below the fold at rest but was reachable by
scrolling an overflow-y:auto ancestor (tall dialog on a short viewport). This
produced a CI-environment-sensitive false positive on the Kanban board settings
modal at 480x320 German (the field is inside the .kanban-modal-overlay which is
overflow-y:auto), passing locally but failing on the CI runner's font render.

Add reachableByScrollableAncestor(): an off-viewport edge absorbed by a
scrollable (overflow auto/scroll, content-overflowing) ancestor is reachable and
not a defect. Genuinely-unreachable elements (no scroll escape, e.g. inside
overflow:hidden) are still flagged. +2 unit tests covering both directions.
2026-08-11 02:40:48 +00:00

406 lines
15 KiB
Python

"""Shared layout-assertion helpers for Playwright browser tests."""
from __future__ import annotations
import json
from typing import Any
_LAYOUT_LINT_JS = """
function collectRenderViolations(scopeSelector, options) {
var opts = options || {}
var win = opts.__window || window
var doc = opts.__document || win.document || document
var overlapTol = opts.overlapTolerancePx != null ? opts.overlapTolerancePx : 4
var clipTol = opts.clipTolerancePx != null ? opts.clipTolerancePx : 2
var escapeTol = opts.escapeTolerancePx != null ? opts.escapeTolerancePx : 2
var rawPat = new RegExp(opts.rawStringPattern || "^[a-z0-9]+(_[a-z0-9]+)+$")
var enabledChecks = opts.checks || ["overlap", "clip", "container-escape", "degenerate", "raw-string", "a11y"]
function enabled(name) {
return enabledChecks.indexOf(name) !== -1
}
var root
if (scopeSelector && typeof scopeSelector === "string") {
root = doc.querySelector(scopeSelector)
if (!root) {
return [{ type: "degenerate", detail: "scope selector matched no element", elements: [scopeSelector], rects: [] }]
}
} else {
root = doc.body || doc.documentElement
}
var violations = []
function cssPath(node) {
var parts = []
var cur = node
for (var i = 0; i < 4 && cur && cur.nodeType === 1; i++) {
var tag = cur.tagName.toLowerCase()
var id = cur.getAttribute("id")
if (id) {
parts.unshift(tag + "#" + id)
break
}
var cls = cur.getAttribute("class")
if (cls) {
parts.unshift(tag + "." + cls.split(/\\s+/)[0])
} else {
parts.unshift(tag)
}
cur = cur.parentElement
}
return parts.join(" > ")
}
function getComputed(node) {
return win.getComputedStyle(node)
}
function isVisible(node) {
if (node.nodeType !== 1) return false
var cs = getComputed(node)
if (cs.display === "none" || cs.visibility === "hidden") return false
var r = node.getBoundingClientRect()
return r.width > 0 && r.height > 0
}
function isVisibleForDegenerate(node) {
if (node.nodeType !== 1) return false
var cs = getComputed(node)
return cs.display !== "none" && cs.visibility !== "hidden"
}
function bearsText(node) {
var cn = node.childNodes
for (var i = 0; i < cn.length; i++) {
if (cn[i].nodeType === 3 && cn[i].data && cn[i].data.trim() !== "") return true
}
return false
}
function hasSubtreeText(node) {
if (node.nodeType === 3) return node.data && node.data.trim() !== ""
if (node.nodeType !== 1) return false
var cn = node.childNodes
for (var i = 0; i < cn.length; i++) {
if (hasSubtreeText(cn[i])) return true
}
return false
}
function getSubtreeText(node) {
if (node.nodeType === 3) return node.data || ""
if (node.nodeType !== 1) return ""
var t = ""
var cn = node.childNodes
for (var i = 0; i < cn.length; i++) {
t += getSubtreeText(cn[i])
}
return t
}
var INTERACTIVE_TAGS = { BUTTON: 1, INPUT: 1, SELECT: 1, TEXTAREA: 1 }
var CONTROL_TAGS = { BUTTON: 1, INPUT: 1, SELECT: 1, TEXTAREA: 1, A: 1, LABEL: 1 }
function isInteractive(node) {
if (INTERACTIVE_TAGS[node.tagName]) return true
if (node.tagName === "A" && node.getAttribute("href") != null) return true
if (node.getAttribute("role") === "button") return true
return false
}
function isTextOrControl(node) {
return bearsText(node) || !!CONTROL_TAGS[node.tagName]
}
function rectIntersection(a, b) {
var x = Math.max(a.left, b.left)
var y = Math.max(a.top, b.top)
var r = Math.min(a.right, b.right)
var bot = Math.min(a.bottom, b.bottom)
return { width: Math.max(0, r - x), height: Math.max(0, bot - y) }
}
function fullyContains(outer, inner) {
return outer.left <= inner.left && outer.top <= inner.top &&
outer.right >= inner.right && outer.bottom >= inner.bottom
}
function findOverflowHiddenAncestor(node) {
var cur = node.parentElement
while (cur && cur !== root.parentElement) {
var cs = getComputed(cur)
if (cs.overflowX === "hidden" || cs.overflowY === "hidden") return cur
cur = cur.parentElement
}
return null
}
// An interactive element that currently sits outside the viewport is NOT a
// "degenerate/off-viewport" defect when it lives inside a scrollable ancestor
// (overflow-y/x auto|scroll whose content actually overflows): it is reachable
// by scrolling that container. Flagging it produces a false positive for a
// valid pattern — e.g. a tall dialog with overflow-y:auto on a short viewport,
// where a lower field is momentarily below the fold but scrolls into view.
// Only report off-viewport when the element is genuinely unreachable (no
// scrollable ancestor can bring the off-axis edge back on-screen).
function reachableByScrollableAncestor(node, offLeft, offRight, offTop, offBottom) {
var cur = node.parentElement
while (cur && cur !== root.parentElement) {
var cs = getComputed(cur)
var scrollsY = (cs.overflowY === "auto" || cs.overflowY === "scroll") &&
cur.scrollHeight > cur.clientHeight + 1
var scrollsX = (cs.overflowX === "auto" || cs.overflowX === "scroll") &&
cur.scrollWidth > cur.clientWidth + 1
// A vertical off-viewport (top past the fold, or bottom above it) is
// absorbed by a Y-scrollable ancestor; likewise X off-viewport by an
// X-scrollable ancestor.
if ((offTop || offBottom) && scrollsY) return true
if ((offLeft || offRight) && scrollsX) return true
cur = cur.parentElement
}
return false
}
function walk(node) {
if (node.nodeType !== 1) return
var cs = getComputed(node)
if (cs.display === "none" || cs.visibility === "hidden") return
var children = node.childNodes
var visibleElements = []
var degenerateElements = []
for (var i = 0; i < children.length; i++) {
if (children[i].nodeType !== 1) continue
if (isVisible(children[i])) {
visibleElements.push(children[i])
} else if (isVisibleForDegenerate(children[i])) {
degenerateElements.push(children[i])
}
}
if (enabled("overlap")) {
for (var i = 0; i < visibleElements.length; i++) {
var ei = visibleElements[i]
var csi = getComputed(ei)
if (csi.position === "fixed") continue
if (!isTextOrControl(ei)) continue
var ri = ei.getBoundingClientRect()
for (var j = i + 1; j < visibleElements.length; j++) {
var ej = visibleElements[j]
var csj = getComputed(ej)
if (csj.position === "fixed") continue
if (!isTextOrControl(ej)) continue
var rj = ej.getBoundingClientRect()
var inter = rectIntersection(ri, rj)
if (inter.width > overlapTol && inter.height > overlapTol) {
if (fullyContains(ri, rj) || fullyContains(rj, ri)) {
if (!(bearsText(ei) && bearsText(ej))) continue
}
violations.push({
type: "overlap",
detail: "sibling elements overlap by " + Math.round(inter.width) + "x" + Math.round(inter.height) + "px",
elements: [cssPath(ei), cssPath(ej)],
rects: [ri, rj],
})
}
}
}
}
for (var i = 0; i < visibleElements.length; i++) {
var child = visibleElements[i]
if (enabled("clip") && bearsText(child)) {
var csc = getComputed(child)
if (child.scrollWidth > child.clientWidth + clipTol) {
if (csc.textOverflow.indexOf("ellipsis") === -1 &&
csc.overflowX !== "scroll" && csc.overflowX !== "auto") {
violations.push({
type: "clip",
detail: "text clipped: scrollWidth " + child.scrollWidth + " > clientWidth " + child.clientWidth,
elements: [cssPath(child)],
rects: [child.getBoundingClientRect()],
})
}
}
}
if (enabled("container-escape")) {
var ancestor = findOverflowHiddenAncestor(child)
if (ancestor) {
var cr = child.getBoundingClientRect()
var ar = ancestor.getBoundingClientRect()
var ancestorCs = getComputed(ancestor)
var escapeX = ancestorCs.overflowX === "hidden" && (cr.right - ar.right > escapeTol || ar.left - cr.left > escapeTol)
var escapeY = ancestorCs.overflowY === "hidden" && (cr.bottom - ar.bottom > escapeTol || ar.top - cr.top > escapeTol)
if (escapeX || escapeY) {
violations.push({
type: "container-escape",
detail: "element extends past overflow-hidden ancestor",
elements: [cssPath(child), cssPath(ancestor)],
rects: [cr, ar],
})
}
}
}
if (enabled("degenerate") && isInteractive(child)) {
if (child.tagName === "INPUT" && child.getAttribute("type") === "hidden") {
// skip
} else if (isVisibleForDegenerate(child)) {
var dr = child.getBoundingClientRect()
var zeroSize = dr.width === 0 || dr.height === 0
var offLeft = dr.right < 0
var offRight = dr.left > win.innerWidth
var offTop = dr.bottom < 0
var offBottom = dr.top > win.innerHeight
var offViewport = offLeft || offRight || offTop || offBottom
// Reachable-by-scroll inside a scrollable ancestor is not a defect.
if (offViewport && reachableByScrollableAncestor(child, offLeft, offRight, offTop, offBottom)) {
offViewport = false
}
if (zeroSize || offViewport) {
violations.push({
type: "degenerate",
detail: zeroSize ? "interactive element has zero-size rect" : "interactive element is off-viewport",
elements: [cssPath(child)],
rects: [dr],
})
}
}
}
if (enabled("raw-string")) {
var cn = child.childNodes
for (var k = 0; k < cn.length; k++) {
if (cn[k].nodeType === 3 && cn[k].data) {
var trimmed = cn[k].data.trim()
if (trimmed && rawPat.test(trimmed)) {
violations.push({
type: "raw-string",
detail: "text node matches raw-string pattern: " + trimmed,
elements: [cssPath(child)],
rects: [child.getBoundingClientRect()],
})
}
}
}
var RAW_ATTRS = ["title", "aria-label", "placeholder"]
for (var k = 0; k < RAW_ATTRS.length; k++) {
var val = child.getAttribute(RAW_ATTRS[k])
if (val && rawPat.test(val.trim())) {
violations.push({
type: "raw-string",
detail: RAW_ATTRS[k] + " attribute matches raw-string pattern: " + val.trim(),
elements: [cssPath(child)],
rects: [],
})
}
}
}
if (enabled("a11y") && isInteractive(child)) {
var ariaLabel = child.getAttribute("aria-label")
var title = child.getAttribute("title")
var labelledby = child.getAttribute("aria-labelledby")
var subtreeHasText = hasSubtreeText(child)
var hasName = (ariaLabel && ariaLabel.trim()) || (title && title.trim()) || (labelledby && labelledby.trim())
if (!subtreeHasText && !hasName) {
violations.push({
type: "missing-accessible-name",
detail: "interactive element has no accessible name",
elements: [cssPath(child)],
rects: [child.getBoundingClientRect()],
})
}
if (ariaLabel && ariaLabel.trim() && subtreeHasText) {
var visibleText = getSubtreeText(child).trim().toLowerCase()
if (visibleText && ariaLabel.trim().toLowerCase().indexOf(visibleText) === -1) {
violations.push({
type: "label-not-in-name",
detail: "aria-label \\"" + ariaLabel.trim() + "\\" does not contain visible text \\"" + visibleText + "\\"",
elements: [cssPath(child)],
rects: [child.getBoundingClientRect()],
})
}
}
}
}
if (enabled("degenerate")) {
for (var i = 0; i < degenerateElements.length; i++) {
var dNode = degenerateElements[i]
if (isInteractive(dNode)) {
if (dNode.tagName === "INPUT" && dNode.getAttribute("type") === "hidden") continue
var dr2 = dNode.getBoundingClientRect()
var zs = dr2.width === 0 || dr2.height === 0
var off2Left = dr2.right < 0
var off2Right = dr2.left > win.innerWidth
var off2Top = dr2.bottom < 0
var off2Bottom = dr2.top > win.innerHeight
var ov = off2Left || off2Right || off2Top || off2Bottom
if (ov && reachableByScrollableAncestor(dNode, off2Left, off2Right, off2Top, off2Bottom)) {
ov = false
}
if (zs || ov) {
violations.push({
type: "degenerate",
detail: zs ? "interactive element has zero-size rect" : "interactive element is off-viewport",
elements: [cssPath(dNode)],
rects: [dr2],
})
}
}
}
}
for (var i = 0; i < visibleElements.length; i++) {
walk(visibleElements[i])
}
}
walk(root)
return violations
}
"""
_DEFAULT_CHECKS = ["overlap", "clip", "container-escape", "degenerate", "raw-string"]
def _build_evaluate_script(scope_selector=None, checks=None):
"""Build a self-contained JS IIFE for page.evaluate()."""
checks_json = json.dumps(checks or _DEFAULT_CHECKS)
scope_json = json.dumps(scope_selector)
return "(" + _LAYOUT_LINT_JS + ")(" + scope_json + ", {checks: " + checks_json + "})"
def _format_violations(violations):
"""Format violation dicts into a readable assertion message."""
lines = []
for v in violations:
elements = ", ".join(v.get("elements", []))
lines.append(f"[{v['type']}] {v['detail']} ({elements})")
return f"{len(violations)} layout violation(s):\n" + "\n".join(lines)
def collect_layout_violations(page: Any, scope_selector: str | None = None, *, checks: list[str] | None = None) -> list[dict]:
"""Run geometry + raw-string checks in-browser. Returns list of {type, detail, elements, rects}."""
script = _build_evaluate_script(scope_selector, checks)
return page.evaluate(script)
def assert_layout_sane(page: Any, scope_selector: str | None = None, *, checks: list[str] | None = None) -> None:
"""Assert no layout violations. Raises AssertionError with formatted details."""
violations = collect_layout_violations(page, scope_selector, checks=checks)
if violations:
raise AssertionError(_format_violations(violations))
def assert_no_raw_i18n_keys(page: Any, scope_selector: str | None = None) -> None:
"""Assert no leaked raw i18n keys are visible."""
violations = collect_layout_violations(page, scope_selector, checks=["raw-string"])
if violations:
raise AssertionError(_format_violations(violations))