=== AXE RUNNER === 1 /* Runs axe-core (Deque Systems, MPL-2.0, loaded from axe.min.js beside this file) on the page this frame holds, 2 for the WCAG A/AA rules the aiwebpageseo Accessibility check does not cover itself, and posts the result to the 3 tool page. The page in this frame is a copy of the audited page with all of its own scripts taken out. */ 4 (function () { 5 try { parent.postMessage({ a11yAxe: 2 }, '*'); } catch (e) {} // started: the tool page stops waiting if this never arrives 6 var RULES = ['input-image-alt', 'nested-interactive', 'list', 'listitem', 'definition-list', 'dlitem', 'meta-viewport', 7 'aria-required-parent', 'aria-required-children', 'aria-valid-attr', 'aria-valid-attr-value', 'aria-allowed-attr', 8 'aria-prohibited-attr', 'aria-required-attr', 'aria-roles', 'aria-hidden-body', 'aria-input-field-name', 9 'aria-toggle-field-name', 'aria-command-name', 'aria-meter-name', 'aria-progressbar-name', 'aria-tooltip-name', 10 'aria-braille-equivalent', 'aria-conditional-attr', 'aria-deprecated-role', 'scrollable-region-focusable', 11 'link-in-text-block', 'svg-img-alt', 'role-img-alt', 'object-alt', 'area-alt', 'input-button-name', 'summary-name', 12 'document-title', 'avoid-inline-spacing', 'server-side-image-map', 'blink', 'marquee', 'td-headers-attr', 13 'th-has-data-cells', 'valid-lang', 'html-xml-lang-mismatch', 'autocomplete-valid', 'target-size']; 14 // Best-practice rules (axe-core's own "best-practice" tag): not WCAG requirements, so the tool page reports any that 15 // fail as unscored notes. Left out: heading-order, page-has-heading-one and skip-link (the tool's own checks cover 16 // them) and frame-tested (it only reports frames axe could not enter). 17 var BEST = ['region', 'landmark-one-main', 'landmark-unique', 'landmark-no-duplicate-banner', 18 'landmark-no-duplicate-contentinfo', 'landmark-no-duplicate-main', 'landmark-banner-is-top-level', 19 'landmark-contentinfo-is-top-level', 'landmark-main-is-top-level', 'landmark-complementary-is-top-level', 20 'empty-heading', 'empty-table-header', 'tabindex', 'accesskeys', 'image-redundant-alt', 'label-title-only', 21 'aria-allowed-role', 'aria-dialog-name', 'aria-treeitem-name', 'aria-text', 'presentation-role-conflict', 22 'scope-attr-valid', 'table-duplicate-name', 'meta-viewport-large']; 23 var sent = false, started = false; 24 function send(m) { if (sent) return; sent = true; m.a11yAxe = 1; try { parent.postMessage(m, '*'); } catch (e) {} } 25 function go() { 26 if (sent || started) return; 27 started = true; 28 if (!window.axe) return send({ ok: false, error: 'the checker did not load' }); 29 var known = axe.getRules().map(function (r) { return r.ruleId; }); 30 var rules = RULES.concat(BEST).filter(function (r) { return known.indexOf(r) >= 0; }); 31 axe.run(document, { runOnly: { type: 'rule', values: rules }, resultTypes: ['violations'] }).then(function (r) { 32 send({ ok: true, version: axe.version, violations: r.violations.map(function (v) { 33 return { id: v.id, impact: v.impact, help: v.help, description: v.description, tags: v.tags, n: v.nodes.length, bp: BEST.indexOf(v.id) >= 0, 34 ex: v.nodes.slice(0, 3).map(function (n) { return { html: String(n.html || '').slice(0, 200), fix: String(n.failureSummary || '').slice(0, 300) }; }) }; 35 }) }); 36 }, function (e) { send({ ok: false, error: String((e && e.message) || e).slice(0, 200) }); }); 37 } 38 if (document.readyState === 'complete') go(); else { window.addEventListener('load', go); setTimeout(go, 8000); } 39 })(); === RENDERED AUDIT: ENTRY POINTS === 33:const TIMEOUT_MS = 60000; // whole check (contrast + the axe-core checks), then the caller falls back to the raw-HTML check 34:// axe-core and its runner, asked for by the tool page's check frame at this address, are served from this 35:// server's own copy (public/a11y-axe/), so the full audit does not depend on fetching them over the network. 36:const AXE_URL = 'https://aiwebpageseo.com/a11y-axe/'; 37:const AXE_FILES = { 'axe.min.js': path.join(__dirname, '..', 'public', 'a11y-axe', 'axe.min.js'), 'run.js': path.join(__dirname, '..', 'public', 'a11y-axe', 'run.js') }; 41:function getBrowser() { 47: }).then(b => { b.on('disconnected', () => { browserP = null; }); return b; }) 50: return browserP; 57:function kbAcquire() { if (kbActive < KB_MAX) { kbActive++; return Promise.resolve(); } return new Promise(r => kbWaiting.push(r)); } 58:function kbRelease() { const next = kbWaiting.shift(); if (next) next(); else kbActive--; } 59:function ownBrowser() { return puppeteer.launch({ headless: 'new', executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || undefined, args: ['--disable-dev-shm-usage'] }); } 64:function acquire() { if (active < MAX_PAGES) { active++; return Promise.resolve(); } return new Promise(r => waiting.push(r)); } 65:function release() { const next = waiting.shift(); if (next) next(); else active--; } 68:function privateIp(ip) { 71: return a === 0 || a === 10 || a === 127 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || 75: if (v === '::' || v === '::1') return true; 76: const m = v.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/); if (m) return privateIp(m[1]); 77: return /^(fe[89ab]|f[cd]|ff)/.test(v); 80:async function publicHost(host) { 82: if (host === 'localhost' || host.endsWith('.local') || host.endsWith('.internal')) return false; 83: if (net.isIP(host)) return !privateIp(host); 84: if (hostCache.has(host)) return hostCache.get(host); 88: return ok; 96:function pageSize(html) { 100: return { words: words, links: links }; 102:function nearlyEmpty(html) { const z = pageSize(html); return z.words < 40 && z.links <= 1; } 103:function botCheck(html) { const z = pageSize(html); return BOT_SURE.test(html) || (BOT_MAYBE.test(html) && z.words < 300 && z.links <= 5); } 105:// Load a page with its own scripts running (sandboxed) and return a copy of the finished page: 121:function withKbLock(fn) { const run = kbChain.then(fn, fn); kbChain = run.catch(() => {}); return run; } 133:async function hoverCheck(page) { 134: const ids = await kbWithin(page.evaluate(() => { 135: const vis = e => { const r = e.getBoundingClientRect(), st = getComputedStyle(e); return r.width > 0 && r.height > 0 && st.visibility === 'visible' && st.display !== 'none' && parseFloat(st.opacity) > 0.1; }; 136: const hidden = e => { for (let a = e; a; a = a.parentElement) { const st = getComputedStyle(a); if (st.display === 'none' || st.visibility !== 'visible' || parseFloat(st.opacity) < 0.1) return true; if (a.tagName === 'LI') break; } const r = e.getBoundingClientRect(); return r.width === 0 || r.height === 0; }; 149: return out; 152: const markVisible = () => page.evaluate(() => { window.__kseen = new WeakSet(); for (const e of document.querySelectorAll('body *')) { const r = e.getBoundingClientRect(), st = getComputedStyle(e); if (r.width > 0 && r.height > 0 && st.visibility === 'visible' && parseFloat(st.opacity) > 0.1) window.__kseen.add(e); } }); 153: const findNew = i => page.evaluate(i => { const trig = document.querySelector('[data-khov="' + i + '"]'); const out = []; for (const e of document.querySelectorAll('body *')) { if (window.__kseen.has(e) || (trig && (trig === e || trig.contains(e)))) continue; const r = e.getBoundingClientRect(), st = getComputedStyle(e); if (r.width < 30 || r.height < 10 || st.visibility !== 'visible' || parseFloat(st.opacity) <= 0.1 || !(e.innerText || '').trim()) continue; e.dataset.knew = i; let ov = false; for (let a = e, k = 0; a && k < 5; a = a.parentElement, k++) if (/absolute|fixed/.test(getComputedStyle(a).position)) { ov = true; break; } out.push({ l: r.left, r: r.right, t: r.top, h: r.height, ov }); } return { n: out.length, at: out[0] || null, overlay: out.some(o => o.ov), name: trig ? (trig.innerText || trig.getAttribute('aria-label') || '').replace(/\s+/g, ' ').trim().slice(0, 40) : '' }; }, i); 154: const stillShown = i => page.evaluate(i => [...document.querySelectorAll('[data-knew="' + i + '"]')].some(e => { const r = e.getBoundingClientRect(), st = getComputedStyle(e); return r.width > 0 && r.height > 0 && st.visibility === 'visible' && parseFloat(st.opacity) > 0.1 && st.display !== 'none'; }), i); 178: return { tried: ids.length, noEscape: { count: noEsc.length, examples: noEsc.slice(0, 5) }, noHover: { count: noHover.length, examples: noHover.slice(0, 5) } }; 180:async function flashCheck(page, frames) { 182: if (fr.length < 2) return { checked: true, count: 0, examples: [], fps: 0 }; 188: const res = await kbWithin(blank.evaluate(async fr => { 189: const lin = c => { c /= 255; return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); }; 213: return { bad, W }; 215: if (!res) return null; 219: for (const b of res.bad.slice(0, 6)) { const nm = await page.evaluate((x, y) => { const e = document.elementFromPoint(x, y); if (!e) return ''; const a = e.closest('video, canvas, img, svg, iframe, section, div') || e; return (a.getAttribute('aria-label') || a.getAttribute('alt') || (a.innerText || '').replace(/\s+/g, ' ').trim().slice(0, 50) || a.tagName.toLowerCase() + (typeof a.className === 'string' && a.className.trim() ? '.' + a.className.trim().split(/\s+/)[0].slice(0, 30) : '')); }, b.x * scale, b.y * scale).catch(() => ''); if (nm && !names.includes(nm)) names.push(nm); } 220: return { checked: true, count: res.bad.length ? Math.max(1, names.length) : 0, examples: names.map(n => ({ name: n })), fps: Math.round(fps) }; 224:async function layoutChecks(page) { 245: out.motion = await kbWithin(page.evaluate(async () => { 250: const st = getComputedStyle(e); if (st.visibility !== 'visible') continue; m.set(e, [r.left, r.top, st.transform, parseFloat(st.opacity), e.scrollLeft, e.scrollTop]); } return m; }; 252: if (Math.abs(x[0] - y[0]) > 8 || Math.abs(x[1] - y[1]) > 8 || x[2] !== y[2] || Math.abs(x[3] - y[3]) > 0.2 || Math.abs(x[4] - y[4]) > 8 || Math.abs(x[5] - y[5]) > 8) out.add(e); } return out; }; 266: const ancOp = e => { let o = 1; for (let a = e.parentElement; a && a.nodeType === 1; a = a.parentElement) o = Math.min(o, parseFloat(getComputedStyle(a).opacity)); return o; }; 268: moving = [...new Set(moving)].filter(e => { const r = e.getBoundingClientRect(); return r.right > 0 && r.left < innerWidth && ancOp(e) >= 0.1 && ownOp(e) >= 0.1; }); 270: const vids = [...document.querySelectorAll('video')].filter(v => { const r = v.getBoundingClientRect(); return !v.paused && (v.loop || v.duration > 5) && r.width >= 150 && r.height >= 80 && r.bottom > 0 && r.top < H && getComputedStyle(v).visibility === 'visible'; }); 276: if ([...a.querySelectorAll('button, [role="button"], a, input[type="button"]')].some(c => PAUSE.test(nameOf(c)) || c.hasAttribute('aria-pressed'))) return true; } return false; }; 277: const label = e => { const t = (e.innerText || e.getAttribute('aria-label') || e.getAttribute('alt') || '').replace(/\s+/g, ' ').trim().slice(0, 50); return t || (e.tagName.toLowerCase() + (typeof e.className === 'string' && e.className.trim() ? '.' + e.className.trim().split(/\s+/)[0].slice(0, 30) : '')); }; 288: const seen = new Set(); const uniq = bad.filter(b => { if (seen.has(b.name)) return false; seen.add(b.name); return true; }); 289: return { count: uniq.length, examples: uniq.slice(0, 5) }; 298: out.spacing = await kbWithin(page.evaluate(async () => { 300: const faded = e => { for (let a = e; a && a.nodeType === 1; a = a.parentElement) if (parseFloat(getComputedStyle(a).opacity) < 0.1) return true; return false; }; 302: const boxes = [...document.querySelectorAll('body *')].filter(e => { const s = getComputedStyle(e); if (!/hidden|clip/.test(s.overflowY + ' ' + s.overflowX) || s.visibility !== 'visible' || e.clientHeight < 10 || e.clientWidth < 10 || (e.innerText || '').trim().length < 2) return false; 303: const r = e.getBoundingClientRect(); if (r.right <= 0 || r.left >= docW || r.bottom + scrollY <= 0) return false; return !faded(e); }).slice(0, 3000); 323: return { count: hit.length, examples: hit.slice(0, 6).map(e => ({ name: (e.innerText || '').replace(/\s+/g, ' ').trim().slice(0, 50) })) }; 332: const clipState = () => kbWithin(page.evaluate(() => { 333: const faded = e => { for (let a = e; a && a.nodeType === 1; a = a.parentElement) if (parseFloat(getComputedStyle(a).opacity) < 0.1) return true; return false; }; 339: const hasNav = e => { for (let a = e, i = 0; a && i < 4 && a !== document.body; a = a.parentElement, i++) for (const c of a.querySelectorAll('button, [role="button"], a')) { if (NAV.test((c.getAttribute('aria-label') || '') + ' ' + (c.textContent || '').slice(0, 40) + ' ' + (typeof c.className === 'string' ? c.className.replace(/[-_]/g, ' ') : ''))) return true; } return false; }; 340: const linkedElsewhere = (el, box) => { const a = el.closest('a[href]'); if (!a || !a.href || /^javascript:|#$/.test(a.getAttribute('href'))) return false; for (const o of document.querySelectorAll('a[href]')) if (o !== a && !box.contains(o) && o.href === a.href) return true; return false; }; 341: const cardLink = box => { for (let a = box, i = 0; a && i < 5 && a !== document.body; a = a.parentElement, i++) if (a.matches('a[href]') || a.querySelector('a[href]')) return true; return false; }; 343: const hx = /hidden|clip/.test(st.overflowX), hy = /hidden|clip/.test(st.overflowY); if (!hx && !hy) return 0; 344: if (!(hy && e.scrollHeight > e.clientHeight + 2) && !(hx && e.scrollWidth > e.clientWidth + 2)) return 0; 359: return n; }; 365: return out; }), 15000); 372: for (const x of hit.slice(0, 20)) { try { const ok = await kbWithin(page.evaluate(id => { const e = document.querySelector('[data-kz="' + id + '"]'); if (!e) return false; e.scrollIntoView({ block: 'center' }); const r = e.getBoundingClientRect(); const t = document.elementFromPoint(Math.min(Math.max(r.left + r.width / 2, 1), innerWidth - 2), Math.min(Math.max(r.top + Math.min(r.height, 40) / 2, 1), innerHeight - 2)); return !!t && (t === e || e.contains(t)); }, x[0]), 3000); if (ok) seenHit.push(x); } catch (e) {} } 374: try { const keep = await kbWithin(page.evaluate(ids => ids.filter(id => { const e = document.querySelector('[data-kz="' + id + '"]'); return !ids.some(o => o !== id && e && e.contains(document.querySelector('[data-kz="' + o + '"]'))); }), seenHit.map(x => x[0])), 3000); inner = seenHit.filter(x => keep.includes(x[0])); } catch (e) {} 376: await page.evaluate(() => window.scrollTo(0, 0)).catch(() => {}); 381: out.reflow = await kbWithin(page.evaluate(() => { 391: const insideScroller = e => { for (let a = e.parentElement; a && a !== document.body && a !== de; a = a.parentElement) { if (!/visible/.test(getComputedStyle(a).overflowX)) return true; } return false; }; 397: const nm = e => { const t = (e.innerText || e.getAttribute('aria-label') || e.getAttribute('alt') || '').replace(/\s+/g, ' ').trim().slice(0, 40); return t || (e.tagName.toLowerCase() + (typeof e.className === 'string' && e.className.trim() ? '.' + e.className.trim().split(/\s+/)[0].slice(0, 30) : '')); }; 398: return { width: W, pageWidth: sw, scrolls: scrolls && offenders.length > 0, exemptOnly: scrolls && !offenders.length, examples: offenders.slice(0, 5).map(e => ({ name: nm(e), w: Math.round(e.getBoundingClientRect().right) })) }; 405: const read = () => kbWithin(page.evaluate(() => { const t = (document.body.innerText || '').replace(/\s+/g, ' '); return { len: t.length, rotate: /\b(rotate|turn) (your )?(device|phone|screen|tablet)\b|\b(portrait|landscape) (mode|orientation) (only|is required|required)\b|\bplease use (portrait|landscape)\b/i.test(t) }; }), 5000); 412: try { await page.setViewport({ width: 1280, height: 900 }); await new Promise(r => setTimeout(r, 900)); await page.evaluate(() => window.scrollTo(0, 0)); } catch (e) {} 414: return out; 421:async function siteInfo(page) { 422: return kbWithin(page.evaluate(() => { 423: const norm = h => { try { const u = new URL(h, location.href); if (!/^https?:$/.test(u.protocol)) return ''; u.hash = ''; return u.origin.toLowerCase() + (u.pathname.replace(/\/+$/, '') || '') + u.search; } catch (e) { return ''; } }; 425: const same = u => { try { return new URL(u).hostname.replace(/^www\./, '') === host; } catch (e) { return false; } }; 429: const away = a => { const raw = (a.getAttribute('href') || '').trim(); if (!raw || raw.startsWith('#') || /^javascript:/i.test(raw)) return ''; const h = norm(raw); return h && !(raw.includes('#') && h === here) ? h : ''; }; 439: const region = e => { if (e.closest('footer, [role="contentinfo"]')) return 'footer'; if (e.closest('header, [role="banner"]')) return 'header'; 440: for (let a = e; a && a.nodeType === 1; a = a.parentElement) { const p = getComputedStyle(a).position; if (p === 'fixed' || p === 'sticky') return 'fixed'; } return e.closest('nav') ? 'menu' : 'main'; }; 457: return { url: here, nav: nav.map(n => ({ h: n.h, t: n.t })), blocks, help, inner }; 461:async function answerConsent(page) { 465: const hit = await fr.evaluate((CONSENT, src) => { 468: const vis = e => { const r = e.getBoundingClientRect(), s = getComputedStyle(e); return r.width > 2 && r.height > 2 && s.visibility === 'visible' && s.display !== 'none'; }; 469: const all = sel => { const out = [...document.querySelectorAll(sel)]; const walk = n => { for (const e of n.querySelectorAll('*')) if (e.shadowRoot) { out.push(...e.shadowRoot.querySelectorAll(sel)); walk(e.shadowRoot); } }; walk(document); return out; }; 470: const pinned = e => { for (let a = e; a && a.nodeType === 1; a = a.parentElement || (a.getRootNode && a.getRootNode().host)) { const p = getComputedStyle(a).position; if (p === 'fixed' || p === 'sticky') return true; } return false; }; 475: if (!b) return false; 476: b.click(); return true; 483: return answered; 485:async function keyboardWalk(page, answered) { 493: const total = await page.evaluate(() => { 496: return [...document.querySelectorAll('a[href], button, input:not([type=hidden]), select, textarea, [tabindex]:not([tabindex="-1"]), summary')].filter(e => e.getClientRects().length && getComputedStyle(e).visibility === 'visible' && !e.disabled).length; 507: await page.evaluate(() => document.addEventListener('focusin', e => { try { const el = e.target; if (window.__kfocused) window.__kfocused((el.getAttribute('aria-label') || el.textContent || el.value || '').replace(/\s+/g, ' ').trim()); } catch (x) {} }, true)).catch(() => {}); 514: const s = await kbWithin(page.evaluate(() => { 516: if (!el || el === document.body || el === document.documentElement || el.id === '__kstart') return { none: true }; 531: const paints = t => { for (let e = t; e && e !== document.body && !e.contains(el); e = e.parentElement || (e.getRootNode && e.getRootNode().host)) { const st = getComputedStyle(e); if (/^(IMG|VIDEO|CANVAS|SVG|IFRAME|PICTURE)$/i.test(e.tagName) || st.backgroundImage !== 'none') return true; const m = (st.backgroundColor.match(/[\d.]+/g) || []).map(Number); if (m.length >= 3 && (m.length < 4 || m[3] >= 0.9)) return true; } return false; }; 532: const deep = (x, y) => { let t = document.elementFromPoint(x, y); while (t && t.shadowRoot) { const u = t.shadowRoot.elementFromPoint(x, y); if (!u || u === t) break; t = u; } return t; }; 536: return { kid: el.__kid, name, x: r.left, y: r.top, w: r.width, h: r.height, inDialog, clipBy, frame: el.tagName === 'IFRAME' || (/^(VIDEO|AUDIO)$/.test(el.tagName) && el.controls), typing: /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName) || el.isContentEditable, pts: pts.length, covered, pinnedCover, by }; 546: const r2 = await kbWithin(page.evaluate(() => { let el = document.activeElement; while (el && el.shadowRoot && el.shadowRoot.activeElement) el = el.shadowRoot.activeElement; if (!el) return null; const r = el.getBoundingClientRect(); return { x: r.left, y: r.top, w: r.width, h: r.height }; }), 6000).catch(() => null); 557: await page.evaluate(() => { let el = document.activeElement; while (el && el.shadowRoot && el.shadowRoot.activeElement) el = el.shadowRoot.activeElement; window.__kf = el; if (el && el.blur) el.blur(); }); 560: await page.evaluate(() => { if (window.__kf) window.__kf.focus({ preventScroll: true }); }); 562: const n = await kbWithin(blank.evaluate(async (a, b, cut, box) => { 563: const load = async src => { const im = new Image(); im.src = 'data:image/png;base64,' + src; await im.decode(); const c = document.createElement('canvas'); c.width = Math.max(1, cut.w); c.height = Math.max(1, cut.h); const x = c.getContext('2d'); x.drawImage(im, -cut.x, -cut.y); return x.getImageData(0, 0, c.width, c.height).data; }; 565: const lin = c => { c /= 255; return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); }; 574: return { k, strong, inside }; 587: const once = kind => { const m = new Map(); for (const s of stops) if (s.kind === kind && !m.has(s.kid)) m.set(s.kid, s); return [...m.values()]; }; 592: return { checked: true, stops: stops.length, distinct: seen.size, total, ended, answered, 600:async function renderWithScripts(url, opts) { 601: let target; try { target = new URL(url); } catch (e) { return { ok: false, html: '', error: 'bad url' }; } 602: if (!/^https?:$/.test(target.protocol) || !(await publicHost(target.hostname))) return { ok: false, html: '', error: 'blocked' }; 615: if (/^(data|about|blob):/i.test(u)) return req.continue().catch(() => {}); 616: let host; try { const p = new URL(u); if (!/^https?:$/.test(p.protocol)) return req.abort().catch(() => {}); host = p.hostname; } catch (e) { return req.abort().catch(() => {}); } 617: if (BLOCK_HOSTS.test(host)) return req.abort().catch(() => {}); 642: await page.evaluate(async () => { 654: try { const shown = await page.evaluate(() => { const e = performance.getEntriesByType('navigation')[0]; return (e && e.responseStatus) || 0; }); if (shown) status = shown; } catch (e) {} 657: const html = await page.evaluate(cssMap => { 664: const abs = (u, base) => { try { return new URL(u, base).href; } catch (e) { return u; } }; 668: if (depth < 3 && cssMap[full] != null) { const inner = expand(cssMap[full], full, depth + 1); return media.trim() ? '@media ' + media.trim() + '{' + inner + '}' : inner; } 669: return '@import url("' + full + '")' + (media.trim() ? ' ' + media.trim() : '') + ';'; 711: return '' + shell.slice(0, cut) + de.getHTML({ serializableShadowRoots: true, shadowRoots: roots }) + shell.slice(cut); } 714: return '' + de.outerHTML; 727: return k; 731: return { ok: true, status: status, html: html, finalUrl: shownUrl, keyboard: keyboard, site: site }; 733: return { ok: false, html: '', error: String(e && e.message || e) }; 745:async function toolReport(html, url, builtByScripts, keyboard, fetchCss, inner) { 757: if (!f) return req.abort().catch(() => {}); 758: return fs.promises.readFile(f).then(body => req.respond({ status: 200, contentType: 'application/javascript', body })).catch(() => req.abort().catch(() => {})); 762: if (/^file:/i.test(u) && u.split('#')[0].split('?')[0] !== TOOL) return req.abort().catch(() => {}); 763: return req.continue().catch(() => {}); 766: try { const p = new URL(u); if (!/^https?:$/.test(p.protocol)) return req.abort().catch(() => {}); host = p.hostname; } catch (e) { return req.abort().catch(() => {}); } 767: if (BLOCK_HOSTS.test(host)) return req.abort().catch(() => {}); 773: if (!/^https?:$/.test(p.protocol) || !(await publicHost(p.hostname))) return { ok: false, html: '', error: 'blocked' }; 774: return await fetchCss(u, ref || null); 775: } catch (e) { return { ok: false, html: '', error: String(e && e.message || e) }; } 780: // axe-core and its runner, from this server's own copy, for the tool page's extra WCAG checks (see a11yAxe). 781: let axeSrc = null; 782: try { axeSrc = { axe: await fs.promises.readFile(AXE_FILES['axe.min.js'], 'utf8'), run: await fs.promises.readFile(AXE_FILES['run.js'], 'utf8') }; } catch (e) { axeSrc = null; } 783: const run = page.evaluate(async (h, base, byScripts, axeSrc, kb, inner) => { 784: if (axeSrc) window.__a11yAxeSrc = axeSrc; 793: return window.a11yLastReport; 794: }, html, url || '', builtByScripts, axeSrc, keyboard, inner || null); 796: if (!report || !Array.isArray(report.issues)) throw new Error('rendered accessibility check returned no report'); 798: return report; 809:async function innerReports(homeUrl, urls, fetchCss, onProgress) { 811: let homeHost = ''; try { homeHost = new URL(homeUrl).hostname.replace(/^www\./, ''); } catch (e) { return pages; } 825: pages.push({ url: r.finalUrl || u, issues: rep.issues, errorCount: rep.errorCount, warnCount: rep.warnCount, site: r.site || null }); 829: return pages; 832:async function runRenderedAccessibilityCheck(html, url, fetchCss, onProgress) { 836: return { 837: issues: [], 857: return toolReport(html, url, builtByScripts, keyboard, fetchCss, inner); 862:async function keyboardCheck(url) { 864: if (!r.ok || (r.status && (r.status < 200 || r.status >= 300)) || nearlyEmpty(r.html) || botCheck(r.html)) return null; 866: return r.keyboard || null; 869:module.exports = { runRenderedAccessibilityCheck, renderWithScripts, keyboardCheck, innerReports, nearlyEmpty, botCheck }; === RENDERED AUDIT: FIRST 240 LINES === 1 // Accessibility check for the full audit, drawn the way a browser draws it. 2 // 3 // The Accessibility page tool (public/accessibility-audit.html) runs its checks on the page as Chrome 4 // draws it with the site's own CSS: it measures colour contrast and control edges, and it knows which 5 // text the CSS hides. lib/accessibility-checker.js reads the raw HTML only. This module runs the page 6 // tool's own check (checkA11y) in one shared headless Chrome on the HTML the full audit already fetched, 7 // so /api/audit-full gives customers the same answers as the page tool. Any failure (Chrome missing, 8 // time-out, crash) rejects, and the caller falls back to lib/accessibility-checker.js. 9 // 10 // Every page is first loaded with its own scripts running, in Chrome's sandbox, and a copy of the finished 11 // page is audited (renderWithScripts), so the audit sees what visitors see after the page loads (content, 12 // colours and banners added by scripts, and pages built entirely by JavaScript). A bot-check page is never 13 // run with scripts: a site that blocks checkers is respected, not worked around. If the page cannot be loaded 14 // that way (error status, bot-check, or it comes back almost empty), the fetched HTML is audited instead. 15 // 16 // Safety: Chrome runs with its sandbox ON (it runs other sites' scripts). Every request Chrome makes (stylesheets go through the app's own guarded fetch; images and 17 // fonts the audited page asks for) is checked first and blocked if it points at a private, loopback or 18 // link-local address, so an audited page cannot make the server call itself or its network. The tool 19 // page's own analytics are blocked so server-side runs are not counted as visits. 20 'use strict'; 21 const path = require('path'); 22 const fs = require('fs'); 23 const dns = require('dns').promises; 24 const net = require('net'); 25 const puppeteer = require('puppeteer'); 26 27 const TOOL = 'file://' + path.join(__dirname, '..', 'public', 'accessibility-audit.html'); 28 // The tool page keeps its checker and styles in its own files; they are added to the page from this server's copy 29 // after it opens (the page's own /accessibility-audit.js and .css links do not resolve from a file:// address). 30 const TOOL_JS = path.join(__dirname, '..', 'public', 'accessibility-audit.js'); 31 const TOOL_CSS = path.join(__dirname, '..', 'public', 'accessibility-audit.css'); 32 const MAX_PAGES = 3; // pages drawn at once; more wait their turn 33 const TIMEOUT_MS = 60000; // whole check (contrast + the axe-core checks), then the caller falls back to the raw-HTML check 34 // axe-core and its runner, asked for by the tool page's check frame at this address, are served from this 35 // server's own copy (public/a11y-axe/), so the full audit does not depend on fetching them over the network. 36 const AXE_URL = 'https://aiwebpageseo.com/a11y-axe/'; 37 const AXE_FILES = { 'axe.min.js': path.join(__dirname, '..', 'public', 'a11y-axe', 'axe.min.js'), 'run.js': path.join(__dirname, '..', 'public', 'a11y-axe', 'run.js') }; 38 const BLOCK_HOSTS = /(^|\.)(googletagmanager\.com|google-analytics\.com|analytics\.google\.com|doubleclick\.net|clarity\.ms|facebook\.net|hotjar\.com)$/i; 39 40 let browserP = null; 41 function getBrowser() { 42 if (!browserP) { 43 browserP = puppeteer.launch({ 44 headless: 'new', 45 executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || undefined, 46 args: ['--disable-dev-shm-usage'] // sandbox on: other sites' scripts may run in it 47 }).then(b => { b.on('disconnected', () => { browserP = null; }); return b; }) 48 .catch(e => { browserP = null; throw e; }); 49 } 50 return browserP; 51 } 52 53 // A keyboard check photographs the page as focus moves, and Chrome only draws the front tab, so each keyboard check 54 // gets a Chrome of its own (closed when it ends) and up to KB_MAX run at once; more wait their turn. 55 const KB_MAX = 5; 56 let kbActive = 0; const kbWaiting = []; 57 function kbAcquire() { if (kbActive < KB_MAX) { kbActive++; return Promise.resolve(); } return new Promise(r => kbWaiting.push(r)); } 58 function kbRelease() { const next = kbWaiting.shift(); if (next) next(); else kbActive--; } 59 function ownBrowser() { return puppeteer.launch({ headless: 'new', executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || undefined, args: ['--disable-dev-shm-usage'] }); } 60 61 // pages drawn at once 62 let active = 0; 63 const waiting = []; 64 function acquire() { if (active < MAX_PAGES) { active++; return Promise.resolve(); } return new Promise(r => waiting.push(r)); } 65 function release() { const next = waiting.shift(); if (next) next(); else active--; } 66 67 // private / loopback / link-local / shared / multicast / unspecified addresses 68 function privateIp(ip) { 69 if (net.isIPv4(ip)) { 70 const [a, b] = ip.split('.').map(Number); 71 return a === 0 || a === 10 || a === 127 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || 72 (a === 192 && b === 168) || (a === 100 && b >= 64 && b <= 127) || a >= 224; 73 } 74 const v = ip.toLowerCase(); 75 if (v === '::' || v === '::1') return true; 76 const m = v.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/); if (m) return privateIp(m[1]); 77 return /^(fe[89ab]|f[cd]|ff)/.test(v); 78 } 79 const hostCache = new Map(); 80 async function publicHost(host) { 81 host = host.replace(/^\[|\]$/g, '').toLowerCase(); 82 if (host === 'localhost' || host.endsWith('.local') || host.endsWith('.internal')) return false; 83 if (net.isIP(host)) return !privateIp(host); 84 if (hostCache.has(host)) return hostCache.get(host); 85 let ok = false; 86 try { const addrs = await dns.lookup(host, { all: true }); ok = addrs.length > 0 && addrs.every(a => !privateIp(a.address)); } catch (e) { ok = false; } 87 hostCache.set(host, ok); if (hostCache.size > 2000) hostCache.clear(); 88 return ok; 89 } 90 91 // Same test as the page tool: almost no words and at most one link = the HTML is not the page visitors see. 92 // Bot-check pages (same rule as the page tool). Clear signs always mean a bot-check. Protection and form-captcha 93 // script names also appear on ordinary full pages, so alone they mean a bot-check only on a tiny page. 94 const BOT_SURE = /cf-chl-(?!widget)|cf_chl_opt|px-captcha|Pardon Our Interruption|Attention Required!? \| Cloudflare|]*>\s*(Just a moment|Access Denied|Attention Required|Request (unsuccessful|rejected)|Are you a robot|Checking your browser|Making sure you|Vercel Security Checkpoint|Just a quick security check|Access to this page has been denied|Sorry, you have been blocked|Your access has been blocked)/i; 95 const BOT_MAYBE = /challenge-platform|cf-turnstile|cf-chl-widget|_Incapsula_Resource|perimeterx|g-recaptcha|h-captcha|datadome/i; 96 function pageSize(html) { 97 const body = String(html || '').replace(/<(script|style|noscript|template)\b[\s\S]*?<\/\1\s*>/gi, ' '); 98 const words = body.replace(/<[^>]*>/g, ' ').replace(/&[a-z#0-9]+;/gi, ' ').trim().split(/\s+/).filter(Boolean).length; 99 const links = (body.match(/]*\bhref\s*=/gi) || []).length; 100 return { words: words, links: links }; 101 } 102 function nearlyEmpty(html) { const z = pageSize(html); return z.words < 40 && z.links <= 1; } 103 function botCheck(html) { const z = pageSize(html); return BOT_SURE.test(html) || (BOT_MAYBE.test(html) && z.words < 300 && z.links <= 5); } 104 105 // Load a page with its own scripts running (sandboxed) and return a copy of the finished page: 106 // { ok, status, html, finalUrl }. Styles that scripts put straight into the browser (CSS-in-JS) are 107 // written into the copy, and scripts are taken out, so the copy draws the same with scripts off. 108 // Keyboard check (WCAG 2.4.7 Focus Visible, 2.4.11 Focus Not Obscured, 2.1.2 No Keyboard Trap), on the live page 109 // after its copy has been taken: the page is moved through with the Tab key only, as a keyboard user does, for up to 110 // KB_STOPS tab stops. A cookie / consent pop-up holding focus is answered first (as a visitor answers it), so the page 111 // itself is reached. At each stop: the window is photographed with the control focused and again with focus taken 112 // off it; no pixel changing in and around the control means nothing shows where focus is. A control whose every 113 // point is covered by a fixed or sticky box (sticky header, cookie bar) and shows no change is hidden behind it. 114 // Focus on something with no size, or mostly outside the window after the browser scrolled to it, cannot be seen. 115 // Text fields are left out of "no focus shown" (the text cursor shows where focus is). Returns null on failure. 116 const KB_STOPS = 40, KB_TIME_MS = 30000; 117 const KB_CONSENT = '#onetrust-accept-btn-handler, #onetrust-reject-all-handler, #CybotCookiebotDialogBodyLevelButtonLevelOptinAllowAll, #CybotCookiebotDialogBodyButtonDecline, .fc-cta-consent, .fc-button.fc-cta-do-not-consent, #truste-consent-button, .osano-cm-accept-all, .osano-cm-denyAll, #didomi-notice-agree-button, [data-testid="uc-accept-all-button"], .cky-btn-accept, #hs-eu-confirmation-button, .cm-btn-success, #ccc-recommended-settings, #ccc-notify-accept, .qc-cmp2-summary-buttons button, button[mode="primary"]'; 118 const KB_WORDS = /^\s*(agree (and|&) proceed|accept (and|&) continue|i understand|allow|accept all( cookies)?|accept( cookies)?|allow all( cookies)?|allow cookies|agree( and close)?|i agree|i accept|yes,? i('|’)m happy|got it|ok(ay)?|continue|reject all|decline( all)?|only necessary|necessary only|essential only)\s*$/i; 119 // One keyboard check at a time: the page checked must stay the front tab while it is photographed. 120 let kbChain = Promise.resolve(); 121 function withKbLock(fn) { const run = kbChain.then(fn, fn); kbChain = run.catch(() => {}); return run; } 122 const kbWithin = (p, ms) => Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error('keyboard check step timed out')), ms))]); 123 // Layout checks on the live page (before the keyboard check): 124 // - text spacing (WCAG 1.4.12): the spacing WCAG sets (line height 1.5, letter spacing 0.12em, word spacing 0.16em, 125 // paragraph spacing 2em) is applied; a block that hides its overflow and did not cut its text off before, but does 126 // with that spacing, loses text. Blocks already cut off before (closed menus, truncated teasers) are left out. 127 // - reflow (WCAG 1.4.10): at 320px wide (a phone, or a desktop browser zoomed to 400%) the page must not need 128 // sideways scrolling. The outermost boxes wider than the screen are named; tables, preformatted text, video, 129 // maps, frames and drawings (2D content WCAG exempts) and what is inside scrolling boxes are left out. 130 // Content shown on hover or focus (WCAG 1.4.13): a pop-up that appears when the pointer rests on something (a menu, a 131 // tooltip) must close with Escape without moving the pointer, and must stay while the pointer moves onto it. Up to 6 132 // menu items and tooltip owners near the top of the page are tried; only new visible content with text counts. 133 async function hoverCheck(page) { 134 const ids = await kbWithin(page.evaluate(() => { 135 const vis = e => { const r = e.getBoundingClientRect(), st = getComputedStyle(e); return r.width > 0 && r.height > 0 && st.visibility === 'visible' && st.display !== 'none' && parseFloat(st.opacity) > 0.1; }; 136 const hidden = e => { for (let a = e; a; a = a.parentElement) { const st = getComputedStyle(a); if (st.display === 'none' || st.visibility !== 'visible' || parseFloat(st.opacity) < 0.1) return true; if (a.tagName === 'LI') break; } const r = e.getBoundingClientRect(); return r.width === 0 || r.height === 0; }; 137 const out = []; 138 for (const li of document.querySelectorAll('nav li, header li, [role="menubar"] > *')) { 139 if (out.length >= 4) break; 140 const trig = li.querySelector(':scope > a, :scope > button, :scope > span > a, :scope > div > a'); const sub = li.querySelector(':scope ul, :scope [role="menu"], :scope div'); 141 if (!trig || !sub || !vis(trig) || !hidden(sub) || !sub.querySelector('a')) continue; 142 const r = trig.getBoundingClientRect(); if (r.top < 0 || r.bottom > innerHeight) continue; 143 trig.dataset.khov = String(out.length); out.push(out.length); 144 } 145 for (const el of document.querySelectorAll('[aria-describedby]')) { 146 if (out.length >= 6) break; const t = document.getElementById((el.getAttribute('aria-describedby') || '').split(/\s+/)[0]); if (!t || !vis(el) || !hidden(t)) continue; 147 const r = el.getBoundingClientRect(); if (r.top < 0 || r.bottom > innerHeight) continue; el.dataset.khov = String(out.length); out.push(out.length); 148 } 149 return out; 150 }), 8000); 151 const noEsc = [], noHover = []; 152 const markVisible = () => page.evaluate(() => { window.__kseen = new WeakSet(); for (const e of document.querySelectorAll('body *')) { const r = e.getBoundingClientRect(), st = getComputedStyle(e); if (r.width > 0 && r.height > 0 && st.visibility === 'visible' && parseFloat(st.opacity) > 0.1) window.__kseen.add(e); } }); 153 const findNew = i => page.evaluate(i => { const trig = document.querySelector('[data-khov="' + i + '"]'); const out = []; for (const e of document.querySelectorAll('body *')) { if (window.__kseen.has(e) || (trig && (trig === e || trig.contains(e)))) continue; const r = e.getBoundingClientRect(), st = getComputedStyle(e); if (r.width < 30 || r.height < 10 || st.visibility !== 'visible' || parseFloat(st.opacity) <= 0.1 || !(e.innerText || '').trim()) continue; e.dataset.knew = i; let ov = false; for (let a = e, k = 0; a && k < 5; a = a.parentElement, k++) if (/absolute|fixed/.test(getComputedStyle(a).position)) { ov = true; break; } out.push({ l: r.left, r: r.right, t: r.top, h: r.height, ov }); } return { n: out.length, at: out[0] || null, overlay: out.some(o => o.ov), name: trig ? (trig.innerText || trig.getAttribute('aria-label') || '').replace(/\s+/g, ' ').trim().slice(0, 40) : '' }; }, i); 154 const stillShown = i => page.evaluate(i => [...document.querySelectorAll('[data-knew="' + i + '"]')].some(e => { const r = e.getBoundingClientRect(), st = getComputedStyle(e); return r.width > 0 && r.height > 0 && st.visibility === 'visible' && parseFloat(st.opacity) > 0.1 && st.display !== 'none'; }), i); 155 for (const i of ids) { 156 const h = await page.$('[data-khov="' + i + '"]'); if (!h) continue; 157 await page.mouse.move(1279, 899); await new Promise(r => setTimeout(r, 400)); 158 await kbWithin(markVisible(), 5000); 159 await kbWithin(h.hover(), 3000).catch(() => {}); await new Promise(r => setTimeout(r, 600)); 160 const nw = await kbWithin(findNew(i), 5000); 161 if (!nw.n || !nw.at) continue; 162 await page.keyboard.press('Escape'); await new Promise(r => setTimeout(r, 800)); 163 // Escape is only required when the pop-up covers other content (laid over the page), as WCAG 1.4.13 says 164 if (nw.overlay && await kbWithin(stillShown(i), 3000)) noEsc.push({ name: nw.name }); 165 await page.mouse.move(1279, 899); await new Promise(r => setTimeout(r, 500)); 166 await kbWithin(h.hover(), 3000).catch(() => {}); await new Promise(r => setTimeout(r, 600)); 167 if (!(await kbWithin(stillShown(i), 3000))) continue; 168 // straight down from the item into the pop-up (a diagonal line crosses the next menu item and opens its menu 169 // instead; Ruggable, Sainsbury's, Superdry), then a short way in 170 const tb = await h.boundingBox(); if (!tb) continue; 171 const cx = tb.x + tb.width / 2, tx = Math.min(Math.max(cx, nw.at.l + 5), nw.at.r - 5), ty = nw.at.t + Math.min(10, nw.at.h / 2); 172 await page.mouse.move(cx, tb.y + tb.height - 2); await page.mouse.move(cx, Math.max(ty, tb.y + tb.height), { steps: 6 }); 173 if (Math.abs(tx - cx) > 1) await page.mouse.move(tx, ty, { steps: 6 }); 174 await new Promise(r => setTimeout(r, 500)); 175 if (!(await kbWithin(stillShown(i), 3000))) noHover.push({ name: nw.name }); 176 } 177 await page.mouse.move(1279, 899).catch(() => {}); await page.keyboard.press('Escape').catch(() => {}); await new Promise(r => setTimeout(r, 300)); 178 return { tried: ids.length, noEscape: { count: noEsc.length, examples: noEsc.slice(0, 5) }, noHover: { count: noHover.length, examples: noHover.slice(0, 5) } }; 179 } 180 async function flashCheck(page, frames) { 181 const fr = frames.filter(f => f.t && f.d); 182 if (fr.length < 2) return { checked: true, count: 0, examples: [], fps: 0 }; 183 const dur = fr[fr.length - 1].t - fr[0].t; 184 // a static page sends few frames (only when something is redrawn): with nothing changing there is nothing to flash 185 const fps = dur > 0 ? (fr.length - 1) / dur : 0; 186 const blank = await page.browser().newPage(); 187 try { 188 const res = await kbWithin(blank.evaluate(async fr => { 189 const lin = c => { c /= 255; return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); }; 190 const T = new Float32Array(256); for (let i = 0; i < 256; i++) T[i] = lin(i); 191 let W = 0, H = 0, S = 0, cols = 0, rows = 0; const series = []; 192 for (const f of fr) { 193 const im = new Image(); im.src = 'data:image/jpeg;base64,' + f.d; try { await im.decode(); } catch (e) { continue; } 194 if (!W) { W = im.width; H = im.height; S = Math.round(Math.sqrt(21824 * (1280 * 900) / (1024 * 768)) * W / 1280); cols = Math.max(1, Math.floor((W - S) / (S / 2)) + 1); rows = Math.max(1, Math.floor((H - S) / (S / 2)) + 1); } 195 const c = document.createElement('canvas'); c.width = W; c.height = H; const x = c.getContext('2d'); x.drawImage(im, 0, 0, W, H); const d = x.getImageData(0, 0, W, H).data; 196 const cells = new Float32Array(cols * rows); 197 for (let r = 0; r < rows; r++) for (let q = 0; q < cols; q++) { let sum = 0, n = 0; const x0 = Math.round(q * S / 2), y0 = Math.round(r * S / 2); 198 for (let y = y0; y < Math.min(H, y0 + S); y += 2) for (let xx = x0; xx < Math.min(W, x0 + S); xx += 2) { const i = (y * W + xx) * 4; sum += 0.2126 * T[d[i]] + 0.7152 * T[d[i + 1]] + 0.0722 * T[d[i + 2]]; n++; } 199 cells[r * cols + q] = n ? sum / n : 0; } 200 series.push({ t: f.t, cells }); 201 } 202 // per square: brightness changes of 10% or more (darker state under 0.8) that go the opposite way to the last 203 const bad = []; 204 for (let k = 0; k < cols * rows; k++) { 205 const ch = []; let ref = null, dir = 0; 206 for (const s of series) { const L = s.cells[k]; if (ref === null) { ref = L; continue; } 207 const dlt = L - ref; 208 if (Math.abs(dlt) >= 0.12 && Math.min(L, ref) < 0.8 && Math.sign(dlt) !== dir) { ch.push(s.t); dir = Math.sign(dlt); ref = L; } 209 else if (Math.sign(dlt) === dir && Math.abs(L - ref) > 0 && ((dir > 0 && L > ref) || (dir < 0 && L < ref))) ref = L; } 210 // more than 3 flashes (more than 6 changes) inside any one second 211 for (let i = 0; i + 6 < ch.length; i++) if (ch[i + 6] - ch[i] <= 1) { bad.push({ k, x: (k % cols) * S / 2 + S / 2, y: Math.floor(k / cols) * S / 2 + S / 2, n: ch.length }); break; } 212 } 213 return { bad, W }; 214 }, fr), 30000); 215 if (!res) return null; 216 const scale = 1280 / (res.W || 640); 217 // name what is drawn where it flashes (the page is still scrolled to the top, as it was while recorded) 218 const names = []; 219 for (const b of res.bad.slice(0, 6)) { const nm = await page.evaluate((x, y) => { const e = document.elementFromPoint(x, y); if (!e) return ''; const a = e.closest('video, canvas, img, svg, iframe, section, div') || e; return (a.getAttribute('aria-label') || a.getAttribute('alt') || (a.innerText || '').replace(/\s+/g, ' ').trim().slice(0, 50) || a.tagName.toLowerCase() + (typeof a.className === 'string' && a.className.trim() ? '.' + a.className.trim().split(/\s+/)[0].slice(0, 30) : '')); }, b.x * scale, b.y * scale).catch(() => ''); if (nm && !names.includes(nm)) names.push(nm); } 220 return { checked: true, count: res.bad.length ? Math.max(1, names.length) : 0, examples: names.map(n => ({ name: n })), fps: Math.round(fps) }; 221 } finally { await blank.close().catch(() => {}); } 222 } 223 224 async function layoutChecks(page) { 225 const out = { spacing: null, reflow: null, motion: null, orientation: null, zoom: null, hover: null, flash: null }; 226 // Moving content (WCAG 2.2.2 Pause, Stop, Hide): content that starts moving by itself and keeps moving for more than 227 // 5 seconds (an auto-rotating carousel, a scrolling strip, a looping animation or video) needs a way to pause, stop or 228 // hide it. The page is left alone for 11 seconds and boxes that move (position, transform, fade, scroll) in both 229 // halves are found - a box that moves once (an image loading in) is not counted. Running animations that loop or 230 // last more than 5 seconds and playing looping videos are found directly. A pause / stop / play control in or 231 // around the moving area (or a video's own controls) counts as the way to pause it. 232 // Flashing (WCAG 2.3.1 Three Flashes or Below Threshold): while the page is watched for moving content its screen is 233 // recorded frame by frame. A flash is a pair of opposite changes in brightness of at least 10% of the full range 234 // where the darker state is below 80% brightness; a change is counted only when it is clearly over that line (12%), 235 // as the recording is compressed and a change right at 10% cannot be told apart from its error (NME's cover page). The screen is split into overlapping squares the size of the 236 // W3C general flash area (21,824 pixels on a 1024 x 768 screen, scaled to this one); a square that flashes more 237 // than 3 times in any one second fails (a page that is not redrawn sends no frames: nothing on it changes). 238 let cast = null; const frames = []; 239 try { 240 cast = await page.target().createCDPSession();