diff --git a/apps/dashboard/test/accessibility_helper.rb b/apps/dashboard/test/accessibility_helper.rb new file mode 100644 index 0000000000..a3d6010675 --- /dev/null +++ b/apps/dashboard/test/accessibility_helper.rb @@ -0,0 +1,123 @@ +# Accessibility checks to be performed on every page visit. +class ActiveSupport::TestCase + CONTRAST_WATCH_SCRIPT = <<~HEREDOC + if (!window.__contrastViolations) { + window.__contrastViolations = []; + + function getLuminance(r, g, b) { + const [rs, gs, bs] = [r, g, b].map(c => { + c /= 255; + return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); + }); + return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs; + } + + function parse(color) { + const inner = color.replace('rgb(', '').replace(')', ''); + values = inner.split(', '); + return values; + } + + function getContrastRatio(color1, color2) { + const [r1,g1,b1] = parse(color1); + const [r2,g2,b2] = parse(color2); + const l1 = getLuminance(r1,g1,b1); + const l2 = getLuminance(r2,g2,b2); + const lighter = Math.max(l1, l2); + const darker = Math.min(l1, l2); + return (lighter + 0.05) / (darker + 0.05); + } + + function isVisible(el) { + const style = window.getComputedStyle(el); + return style.display !== 'none' + && style.visibility !== 'hidden' + && style.opacity !== '0' + && el.offsetWidth > 0; + } + + function hasText(el){ + var textFound = false; + el.childNodes.forEach((ch) => { + if (ch.nodeType === 3) { + if (ch.textContent.trim() !== '') { + textFound = true; + } + } + }); + return textFound; + } + + function checkElement(el) { + if (!isVisible(el) || !hasText(el) || el.nodeType !== Node.ELEMENT_NODE) return; + + if (el.classList.contains('sr-only') || el.classList.contains('visually-hidden')) return; + + const style = window.getComputedStyle(el); + const fg = style.color; + var bg = style.backgroundColor; + if (!fg || !bg) return; + + // ascend tree to get first defined background + var current = el + while (bg === 'rgba(0, 0, 0, 0)') { + let parent = current.parentElement; + if (!parent) { // assume white if no background found + bg = 'rgb(255, 255, 255)'; + } else { + if (parent.hasAttribute('disabled')) return; + + current = parent; + bg = window.getComputedStyle(parent).backgroundColor; + } + } + const ratio = getContrastRatio(fg, bg); + const fontSize = parseFloat(style.fontSize); + const isBold = parseInt(style.fontWeight) >= 700; + // WCAG AA: 4.5:1 normal, 3:1 large (18pt / 14pt bold) + const isLargeText = fontSize >= 24 || (isBold && fontSize >= 18.67); + const required = isLargeText ? 3.0 : 4.5; + + if (ratio < required) { + const contrastViolation = { + tag: el.tagName, + text: el.innerText?.slice(0, 50), + fg, bg, + ratio: Math.round(ratio * 100) / 100, + required, + path: el.closest('[id]')?.id || el.className + }; + throw `Contrast check failed. Failing element: ${JSON.stringify(contrastViolation)}`; + } + } + + function checkTree(root) { + root.querySelectorAll('*').forEach(checkElement); + } + + const observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + mutation.addedNodes.forEach(node => checkTree(node)); + if (mutation.type === 'attributes' && isVisible(mutation.target)) { + checkElement(mutation.target); + } + }); + }); + + observer.observe(document.body, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['style', 'class', 'hidden', 'aria-hidden'] + }); + + // Scan whatever's already on the page + checkTree(document.body); + } + HEREDOC + + def inject_contrast_observer + page.execute_script(CONTRAST_WATCH_SCRIPT) + end +end + diff --git a/apps/dashboard/test/application_system_test_case.rb b/apps/dashboard/test/application_system_test_case.rb index 689d514de7..e323d00c87 100644 --- a/apps/dashboard/test/application_system_test_case.rb +++ b/apps/dashboard/test/application_system_test_case.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'test_helper' +require 'accessibility_helper' class ApplicationSystemTestCase < ActionDispatch::SystemTestCase DOWNLOAD_DIRECTORY = Rails.root.join('tmp', 'downloads') @@ -19,6 +20,11 @@ class ApplicationSystemTestCase < ActionDispatch::SystemTestCase Selenium::WebDriver.logger.level = :debug unless ENV['DEBUG'].nil? + def visit(path) + super(path) + inject_contrast_observer + end + def find_option_style(ele, opt) find("##{bc_ele_id(ele)} option[value='#{opt}']")['style'].to_s end