#!/usr/bin/env python3 """Announcements, contrast and focus — S10 / S11 / S12 (T4.5, T4.6, T4.7). Wave 0 counted zero aria-live regions app-wide, helper text at 3.32:1, and `outline: none` in six places. login.html's role="alert" / role="status" pair was the only correct example of any of it in the codebase. T4.5 every toast and banner announces; errors interrupt, confirmations do not T4.6 helper and hint text measures >= 4.5:1 against its REAL background T4.7 every interactive element shows a visible ring on keyboard focus, >= 3:1, and a mouse click leaves none Contrast is measured against the background actually painted behind the text, walking up the ancestors for the first non-transparent one — not against an assumed white, which is how "it passes on paper" and "it fails on the page" end up disagreeing. Exit 0 all passed, 1 a failure, 2 could not run. """ import os import subprocess import sys import tempfile import time sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import cdp # noqa: E402 from browser_check import seed, start_server, chk, _PASS, _FAIL, _c # noqa: E402 PAGES = [("login", "/login.html", None), ("launcher", "/index.html", "root"), ("sop", "/work-package-suite.html?project=projA", "root"), ("creator", "/wp-creation-index.html?project=projA", "root"), ("admin", "/admin.html", "root"), ("users", "/users.html", "root"), ("field", "/field.html?project=projA", "root")] # Effective background + contrast, computed in the page. CONTRAST_JS = r""" (() => { const lum = (c) => { const m = c.match(/[\d.]+/g); if (!m) return null; const [r,g,b] = m.slice(0,3).map(Number); const a = m.length > 3 ? Number(m[3]) : 1; if (a === 0) return null; const f = (v) => { v /= 255; return v <= 0.03928 ? v/12.92 : Math.pow((v+0.055)/1.055, 2.4); }; return 0.2126*f(r) + 0.7152*f(g) + 0.0722*f(b); }; const bgOf = (el) => { let n = el; while (n && n.nodeType === 1) { const c = getComputedStyle(n).backgroundColor; const l = lum(c); if (l !== null) return {color: c, lum: l}; n = n.parentElement; } return {color: 'rgb(255,255,255)', lum: 1}; }; const ratio = (a, b) => { const hi = Math.max(a,b), lo = Math.min(a,b); return (hi+0.05)/(lo+0.05); }; // Helper/hint text: the classes that carry it, plus anything at <= 12px that is // real text. Hidden elements are skipped - they have no contrast to measure. const sel = '.field-hint, .note, .sub, small, .field small, .dm-label, .prog-sub, ' + '.wp-nav-subj, .cmt-note, .req-hint, .empty-hint, .me-tag, .card-status'; const out = []; for (const el of document.querySelectorAll(sel)) { const r = el.getBoundingClientRect(); if (!r.width || !r.height) continue; const txt = (el.textContent || '').trim(); if (!txt) continue; const cs = getComputedStyle(el); if (cs.visibility === 'hidden' || cs.opacity === '0') continue; const fl = lum(cs.color); if (fl === null) continue; const bg = bgOf(el); const size = parseFloat(cs.fontSize); const bold = parseInt(cs.fontWeight, 10) >= 700; // WCAG "large text": >=24px, or >=18.66px bold. const large = size >= 24 || (bold && size >= 18.66); out.push({ cls: el.className || el.tagName, size: size, large: large, fg: cs.color, bg: bg.color, ratio: +ratio(fl, bg.lum).toFixed(2), floor: large ? 3.0 : 4.5, text: txt.slice(0, 40), }); } return JSON.stringify(out); })() """ FOCUS_JS = r""" (() => { const lum = (c) => { const m = c.match(/[\d.]+/g); if (!m) return null; const [r,g,b] = m.slice(0,3).map(Number); if (m.length > 3 && Number(m[3]) === 0) return null; const f = (v) => { v /= 255; return v <= 0.03928 ? v/12.92 : Math.pow((v+0.055)/1.055, 2.4); }; return 0.2126*f(r) + 0.7152*f(g) + 0.0722*f(b); }; const bgOf = (el) => { let n = el; while (n && n.nodeType === 1) { const l = lum(getComputedStyle(n).backgroundColor); if (l !== null) return l; n = n.parentElement; } return 1; }; const ratio = (a, b) => { const hi = Math.max(a,b), lo = Math.min(a,b); return (hi+0.05)/(lo+0.05); }; const els = [...document.querySelectorAll( 'a[href], button, input:not([type=hidden]), select, textarea, [tabindex]:not([tabindex="-1"])')] .filter(el => { const r = el.getBoundingClientRect(); return r.width && r.height && !el.disabled; }); const bad = []; let checked = 0; for (const el of els.slice(0, 120)) { el.focus(); // focus() on a visibility:hidden or inert control does nothing, and a control // nobody can reach has no focus ring to measure. Ask whether the focus actually // landed rather than assuming it did — a closed drawer still has layout, so a // bounding box is not evidence that a user can get to its contents. if (document.activeElement !== el) { continue; } if (!el.matches(':focus-visible')) { el.blur(); continue; } // not keyboard-focusable here checked++; const cs = getComputedStyle(el); const hasOutline = cs.outlineStyle !== 'none' && parseFloat(cs.outlineWidth) > 0; let ok = false, detail = ''; if (hasOutline) { const ol = lum(cs.outlineColor); // WHICH background the ring is actually drawn on depends on the offset. A // positive offset puts it outside the border box, on whatever the PARENT // paints; a negative one puts it over the element's own fill. Measuring both // against the element is how a blue ring on a blue primary button reads as // 8.6:1 on paper and is invisible on screen. // WHICH surface the ring is drawn against depends on the offset the browser // ends up using — not the one the stylesheet asked for. Chromium redraws a // low-contrast author ring in white or black at offset 0 on a filled control, // which is MORE contrast than was asked for, not less. // offset > 0 outside the border box, on whatever the parent paints // offset < 0 inset, over the element's own fill // offset = 0 flush against the edge, touching both — visible if it // contrasts with either const off = parseFloat(cs.outlineOffset) || 0; const own = bgOf(el); const par = el.parentElement ? bgOf(el.parentElement) : own; let r; if (ol === null) r = 0; else if (off > 0) r = ratio(ol, par); else if (off < 0) r = ratio(ol, own); else r = Math.max(ratio(ol, own), ratio(ol, par)); ok = r >= 3.0; detail = cs.outlineWidth + ' ' + cs.outlineColor + ' offset ' + cs.outlineOffset + ' @ ' + r.toFixed(2) + ':1'; } else { // A component may ring its SHELL instead of the control — the chrome's search // field is a borderless input inside a bordered box that outlines on // :focus-within. Ringing both would draw two rectangles, so an ancestor ring // counts, as long as it is really there while this element has focus. let n = el.parentElement, anc = null; while (n && n.nodeType === 1 && !anc) { const acs = getComputedStyle(n); if (acs.outlineStyle !== 'none' && parseFloat(acs.outlineWidth) > 0) anc = { n: n, cs: acs }; n = n.parentElement; } if (anc) { const ol = lum(anc.cs.outlineColor); const off = parseFloat(anc.cs.outlineOffset) || 0; const surface = off >= 0 && anc.n.parentElement ? bgOf(anc.n.parentElement) : bgOf(anc.n); const r = ol === null ? 0 : ratio(ol, surface); ok = r >= 3.0; detail = 'ancestor ' + (anc.n.className || anc.n.tagName) + ' @ ' + r.toFixed(2) + ':1'; } else { detail = (cs.boxShadow && cs.boxShadow !== 'none') ? 'box-shadow only: ' + cs.boxShadow.slice(0, 50) : 'no indicator'; } } if (!ok) { var chain = [], n2 = el; while (n2 && n2.nodeType === 1 && chain.length < 4) { chain.push(n2.tagName.toLowerCase() + (n2.id ? '#' + n2.id : '') + (typeof n2.className === 'string' && n2.className.trim() ? '.' + n2.className.trim().split(/\s+/)[0] : '')); n2 = n2.parentElement; } bad.push({ tag: el.tagName.toLowerCase(), cls: (el.className||'').toString().slice(0,40), detail: detail, where: chain.join(' < '), text: (el.textContent||'').trim().slice(0,24) }); } el.blur(); } return JSON.stringify({ checked, bad }); })() """ def main(): exe = cdp.find_browser() if not exe: print("no headless-capable browser found; set WP_BROWSER.") return 2 tmpdir = tempfile.mkdtemp(prefix="wpsuite-a11y-") db_path = os.path.join(tmpdir, "check.db") server = None try: tok = seed(db_path) port = cdp.free_port() base = "http://127.0.0.1:%d" % port server = start_server(port, db_path) if server is None: print("the test server would not start.") return 2 print("\nAnnouncements, contrast and focus — S10/S11/S12\nTarget: %s" % base) browser = cdp.Browser(exe) page = browser.page() # Without focus emulation the headless page is not the focused document, # :focus-visible never matches, and every focus reading comes back clean — # which looks like a pass and is not one. page.ws.call("Emulation.setFocusEmulationEnabled", {"enabled": True}) try: import json print("\nT4.6 — helper text contrast against its real background") worst = [] for label, path, user in PAGES: page.clear_cookies() if user: page.set_cookie("wp_session", tok[user]) page.goto(base + path) time.sleep(1.4) rows = json.loads(page.eval(CONTRAST_JS)) fails = [r for r in rows if r["ratio"] < r["floor"]] if rows: worst.append((label, min(r["ratio"] for r in rows), len(rows))) chk("%-9s %d helper/hint elements, all >= their floor" % (label, len(rows)), not fails, "; ".join("%s %.2f:1 (needs %.1f) %r" % (f["cls"][:24], f["ratio"], f["floor"], f["text"]) for f in fails[:3])) for label, w, n in worst: print(" %-9s tightest %.2f:1 across %d elements" % (label, w, n)) print("\nT4.5 — toasts and banners announce") page.clear_cookies() page.set_cookie("wp_session", tok["root"]) page.goto(base + "/admin.html") time.sleep(1.6) roles = json.loads(page.eval( "JSON.stringify([...document.querySelectorAll('.banner,[id$=\"-banner\"]')]" ".map(e => ({cls: e.className, role: e.getAttribute('role')})))")) chk("admin banners all carry a role", bool(roles) and all(r["role"] in ("alert", "status") for r in roles), [r for r in roles if r["role"] not in ("alert", "status")][:3]) chk("...an error banner interrupts (role=alert)", page.eval("""(() => { const b = document.getElementById('health-banner'); b.className = 'banner bad'; b.textContent = 'probe'; return new Promise(res => setTimeout(() => res(b.getAttribute('role')), 120)); })()""") == "alert") chk("...a success banner does not (role=status)", page.eval("""(() => { const b = document.getElementById('health-banner'); b.className = 'banner ok'; b.textContent = 'probe'; return new Promise(res => setTimeout(() => res(b.getAttribute('role')), 120)); })()""") == "status") chk("...and a banner added later is caught too", page.eval("""(() => { const d = document.createElement('div'); d.className = 'banner bad'; d.textContent = 'late'; document.querySelector('.wrap').appendChild(d); return new Promise(res => setTimeout(() => res(d.getAttribute('role')), 120)); })()""") == "alert") page.goto(base + "/wp-creation-index.html?project=projA") for _ in range(30): if page.eval("!!window.wpCreatorReady"): break time.sleep(0.3) time.sleep(0.8) chk("the creator's toast announces politely by default", page.eval("toast('probe'); document.getElementById('toast').getAttribute('role')") == "status") chk("...and interrupts when told to", page.eval("toast('probe','alert'); document.getElementById('toast').getAttribute('role')") == "alert") chk("the sync badge announces politely", page.eval("""(() => { const b = document.getElementById('wp-sync-badge'); return b ? b.getAttribute('role') : 'status'; })()""") == "status") print("\nT4.7 — a visible focus ring on every interactive element") for label, path, user in PAGES: page.clear_cookies() if user: page.set_cookie("wp_session", tok[user]) page.goto(base + path) time.sleep(1.4) res = json.loads(page.eval(FOCUS_JS)) chk("%-9s %d focusable elements, all ring at >= 3:1" % (label, res["checked"]), not res["bad"], "; ".join("%r %s | %s | %s" % (b.get("text",""), b["cls"][:24], b["detail"], b.get("where","")) for b in res["bad"][:3])) print("\nT4.7 — a mouse click leaves no ring") page.goto(base + "/admin.html") time.sleep(1.2) clicked = page.eval("""(() => { const b = document.querySelector('button'); if (!b) return 'none'; b.dispatchEvent(new MouseEvent('mousedown', {bubbles:true})); b.focus(); b.dispatchEvent(new MouseEvent('mouseup', {bubbles:true})); b.dispatchEvent(new MouseEvent('click', {bubbles:true})); return b.matches(':focus-visible') ? 'ring' : 'no-ring'; })()""") chk("a mouse-focused button shows no persistent ring", clicked in ("no-ring", "none"), clicked) finally: page.close() browser.close() finally: if server: server.kill() try: server.wait(timeout=10) except subprocess.TimeoutExpired: pass try: from server.db import engine engine.dispose() except Exception: pass import shutil for _ in range(10): shutil.rmtree(tmpdir, ignore_errors=True) if not os.path.exists(tmpdir): break time.sleep(0.3) total = len(_PASS) + len(_FAIL) print("\n%s\n%d/%d checks passed." % ("-" * 54, len(_PASS), total)) if _FAIL: for f in _FAIL: print(" - " + f) return 1 print("\nResult: " + _c("ALL PASS — it announces, it is legible, focus is visible.", "32") + "\n") return 0 if __name__ == "__main__": sys.exit(main())