"""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))