generated from adobe/aem-boilerplate
-
Notifications
You must be signed in to change notification settings - Fork 169
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
[MWPW-161236][NALA] Nala Accessibility Test Bot (A11y Bot) (#3109)
* Revert "MWPW-156749: Fix video CLS " (#2899) (#2900) Revert "MWPW-156749: Fix video CLS (#2849)" This reverts commit d4134c8. * [MWPW-159903] Fix quiz video marquees (#3009) (#3013) fix quiz marquees * [MWPW-159328] handle a case where there are not placeholders availabl… (#3014) [MWPW-159328] handle a case where there are not placeholders available (#2998) * [MWPW-159328] handle a case where there are not palceholders availble * fixed typos --------- Co-authored-by: Denys Fedotov <denlight@gmail.com> Co-authored-by: Denys Fedotov <dfedotov@Denyss-MacBook-Pro.local> * MWPW-146211 [MILO][MEP] Option to select all elements (#2976) (#3023) * stash * stash * stash * working well * set updated command list for inline * remove querySelector function * unit test and custom block fix * updates for in-block * merch-card-collection unit test fixed * unit test updates * more unit test repair * linting errors * more linting * Fix Invalid selector test * add coverage * force git checks to refire * remove comment * pass rootEl to getSelectedElements for use when needed (gnav) * skip if clause in codecov --------- Co-authored-by: Vivian A Goodrich <101133187+vgoodric@users.noreply.github.com> Co-authored-by: markpadbe <markp@adobe.com> * MWPW-158455: Promobar overlays with localnav elements in devices (#2991) * Revert "MWPW-156749: Fix video CLS " (#2899) (#2900) Revert "MWPW-156749: Fix video CLS (#2849)" This reverts commit d4134c8. * Changing z-index of promobar and popup * Changing z-index of promobar and popup * Reverting z-index to 4 for promobar --------- Co-authored-by: milo-pr-merge[bot] <169241390+milo-pr-merge[bot]@users.noreply.github.com> Co-authored-by: Okan Sahin <39759830+mokimo@users.noreply.github.com> Co-authored-by: Blaine Gunn <Blainegunn@gmail.com> Co-authored-by: Akansha Arora <> * a11y-bot --------- Co-authored-by: milo-pr-merge[bot] <169241390+milo-pr-merge[bot]@users.noreply.github.com> Co-authored-by: Okan Sahin <39759830+mokimo@users.noreply.github.com> Co-authored-by: Blaine Gunn <Blainegunn@gmail.com> Co-authored-by: Denys Fedotov <denlight@gmail.com> Co-authored-by: Denys Fedotov <dfedotov@Denyss-MacBook-Pro.local> Co-authored-by: Rares Munteanu <overmyheadandbody@gmail.com> Co-authored-by: Vivian A Goodrich <101133187+vgoodric@users.noreply.github.com> Co-authored-by: markpadbe <markp@adobe.com> Co-authored-by: Akansha Arora <akanshaarora090@gmail.com> Co-authored-by: Santoshkumar Sharanappa Nateekar <nateekar@santoshumarsmbp.corp.adobe.com>
- Loading branch information
1 parent
b149849
commit 22ac880
Showing
6 changed files
with
700 additions
and
785 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -10,3 +10,4 @@ logs/* | |
**/mas/*/stats.json | ||
test-html-results/ | ||
test-results/ | ||
test-a11y-results/ |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,133 @@ | ||
const fs = require('fs'); | ||
const { chromium } = require('playwright'); | ||
const AxeBuilder = require('@axe-core/playwright'); | ||
const chalk = require('chalk'); | ||
const generateA11yReport = require('./a11y-report.js'); | ||
|
||
/** | ||
* Run accessibility test for legal compliance (WCAG 2.0/2.1 A & AA) | ||
* @param {Object} page - The page object. | ||
* @param {string} [testScope='body'] - Optional scope for the accessibility test. Default is 'body'. | ||
* @param {string[]} [includeTags=['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']] - WCAG compliance tags. | ||
* @param {number} [maxViolations=0] - Maximum violations before test fails. Default is 0. | ||
* @returns {Object} - Results containing violations or success message. | ||
*/ | ||
async function runAccessibilityTest(page, testScope = 'body', includeTags = [], maxViolations = 0) { | ||
const result = { | ||
url: page.url(), | ||
testScope, | ||
violations: [], | ||
}; | ||
|
||
const wcagTags = includeTags.length > 0 ? includeTags : ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']; | ||
|
||
try { | ||
const testElement = testScope === 'body' ? 'body' : testScope; | ||
|
||
console.log(chalk.blue('Accessibility Test Scope:'), testScope); | ||
console.log(chalk.blue('WCAG Tags:'), wcagTags); | ||
|
||
const axe = new AxeBuilder({ page }) | ||
.withTags(wcagTags) | ||
.include(testElement) | ||
.analyze(); | ||
|
||
result.violations = (await axe).violations; | ||
const violationCount = result.violations.length; | ||
|
||
if (violationCount > maxViolations) { | ||
let violationsDetails = `${violationCount} accessibility violations found:\n`; | ||
result.violations.forEach((violation, index) => { | ||
violationsDetails += ` | ||
${chalk.red(index + 1)}. Violation: ${chalk.yellow(violation.description)} | ||
- Rule ID: ${chalk.cyan(violation.id)} | ||
- Severity: ${chalk.magenta(violation.impact)} | ||
- Fix: ${chalk.cyan(violation.helpUrl)} | ||
`; | ||
|
||
violation.nodes.forEach((node, nodeIndex) => { | ||
violationsDetails += ` Node ${nodeIndex + 1}: ${chalk.yellow(node.html)}\n`; | ||
}); | ||
}); | ||
|
||
throw new Error(violationsDetails); | ||
} else { | ||
console.info(chalk.green('No accessibility violations found.')); | ||
} | ||
} catch (err) { | ||
console.error(chalk.red(`Accessibility test failed: ${err.message}`)); | ||
} | ||
|
||
return result; | ||
} | ||
|
||
/** | ||
* Opens a browser, navigates to a page, runs accessibility test, and returns results. | ||
* @param {string} url - The URL to test. | ||
* @param {Object} options - Test options (scope, tags, maxViolations). | ||
* @returns {Object} - Accessibility test results. | ||
*/ | ||
async function runA11yTestOnPage(url, options = {}) { | ||
const { scope = 'body', tags, maxViolations = 0 } = options; | ||
const browser = await chromium.launch(); | ||
const context = await browser.newContext({ | ||
extraHTTPHeaders: { | ||
'sec-ch-ua': '"Chromium"', | ||
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4606.71 Safari/537.36', | ||
}, | ||
}); | ||
|
||
const page = await context.newPage(); | ||
let result; | ||
|
||
try { | ||
console.log(chalk.blue(`Testing URL: ${url}`)); | ||
await page.goto(url, { timeout: 60000, waitUntil: 'domcontentloaded' }); | ||
result = await runAccessibilityTest(page, scope, tags, maxViolations); | ||
} finally { | ||
await browser.close(); | ||
} | ||
|
||
return result; | ||
} | ||
|
||
/** | ||
* Processes URLs from a file and generates accessibility report. | ||
* @param {string} filePath - Path to file with URLs. | ||
* @param {Object} options - Test options. | ||
*/ | ||
async function processUrlsFromFile(filePath, options = {}) { | ||
const urls = fs.readFileSync(filePath, 'utf-8').split('\n').filter(Boolean); | ||
console.log(chalk.blue('Processing URLs from file:'), urls); | ||
const results = []; | ||
|
||
for (const url of urls) { | ||
const result = await runA11yTestOnPage(url, options); | ||
if (result && result.violations.length > 0) results.push(result); | ||
} | ||
|
||
await generateA11yReport(results, options.outputDir || './test-a11y-results'); | ||
} | ||
|
||
/** | ||
* Processes URLs directly from command-line arguments and generates report. | ||
* @param {string[]} urls - Array of URLs. | ||
* @param {Object} options - Test options. | ||
*/ | ||
async function processUrlsFromCommand(urls, options = {}) { | ||
console.log(chalk.blue('Processing URLs from command-line input:'), urls); | ||
const results = []; | ||
|
||
for (const url of urls) { | ||
const result = await runA11yTestOnPage(url, options); | ||
if (result && result.violations.length > 0) results.push(result); | ||
} | ||
|
||
await generateA11yReport(results, options.outputDir || './reports'); | ||
} | ||
|
||
module.exports = { | ||
runA11yTestOnPage, | ||
processUrlsFromFile, | ||
processUrlsFromCommand, | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,249 @@ | ||
const path = require('path'); | ||
const fs = require('fs').promises; | ||
|
||
// Pretty print HTML with proper indentation | ||
function prettyPrintHTML(html) { | ||
const tab = ' '; // Define the indentation level | ||
let result = ''; | ||
let indentLevel = 0; | ||
|
||
html.split(/>\s*</).forEach((element) => { | ||
if (element.match(/^\/\w/)) { | ||
// Closing tag | ||
indentLevel -= 1; | ||
} | ||
result += `${tab.repeat(indentLevel)}<${element}>\n`; | ||
if (element.match(/^<?\w[^>]*[^/]$/)) { | ||
// Opening tag | ||
indentLevel += 1; | ||
} | ||
}); | ||
return result.trim(); | ||
} | ||
|
||
function escapeHTML(html) { | ||
return html.replace(/[&<>'"]/g, (char) => { | ||
switch (char) { | ||
case '&': return '&'; | ||
case '<': return '<'; | ||
case '>': return '>'; | ||
case "'": return '''; | ||
case '"': return '"'; | ||
default: return char; | ||
} | ||
}); | ||
} | ||
|
||
async function generateA11yReport(report, outputDir) { | ||
const time = new Date(); | ||
const reportName = `nala-a11y-report-${time | ||
.toISOString() | ||
.replace(/[:.]/g, '-')}.html`; | ||
|
||
const reportPath = path.resolve(outputDir, reportName); | ||
|
||
// Ensure the output directory exists | ||
try { | ||
await fs.mkdir(outputDir, { recursive: true }); | ||
} catch (err) { | ||
console.error(`Failed to create directory ${outputDir}: ${err.message}`); | ||
return; | ||
} | ||
|
||
try { | ||
const files = await fs.readdir(outputDir); | ||
for (const file of files) { | ||
if (file.startsWith('nala-a11y-report') && file.endsWith('.html')) { | ||
await fs.unlink(path.resolve(outputDir, file)); | ||
} | ||
} | ||
} catch (err) { | ||
console.error(`Failed to delete the old report files in ${outputDir}: ${err.message}`); | ||
} | ||
|
||
// Check if the report contains violations | ||
if (!report || report.length === 0) { | ||
console.error('No accessibility violations to report.'); | ||
return; | ||
} | ||
|
||
const totalViolations = report.reduce( | ||
(sum, result) => sum + (result.violations ? result.violations.length : 0), | ||
0, | ||
); | ||
|
||
const severityCount = { | ||
critical: 0, | ||
serious: 0, | ||
moderate: 0, | ||
minor: 0, | ||
}; | ||
|
||
report.forEach((result) => { | ||
result.violations?.forEach((violation) => { | ||
if (violation.impact) { | ||
severityCount[violation.impact] += 1; | ||
} | ||
}); | ||
}); | ||
|
||
// Inline CSS for the report with wrapping for pre blocks | ||
const inlineCSS = ` | ||
<style> | ||
body { font-family: Arial, sans-serif; margin: 20px; background-color: #f9f9f9; color: #333; } | ||
.banner { background: linear-gradient(135deg, #a45db3, #f0d4e2); padding: 30px; text-align: center; color: white; border-radius: 10px; margin-bottom: 30px; } | ||
.banner h1 { font-size: 2.5em; margin: 0; } | ||
.metadata-container { background-color: #e6f2ff; padding: 20px; border-radius: 10px; margin-bottom: 20px; } | ||
.metadata-container p { margin: 0; font-size: 1.1em; } | ||
.filters { text-align: center; margin-bottom: 20px; } | ||
.filters button { padding: 10px 20px; margin: 5px; border: none; background-color: #003366; color: #fff; border-radius: 5px; cursor: pointer; } | ||
.filters button:hover { background-color: #00509e; } | ||
.violation-section { background-color: #f0f0f0; padding: 20px; border-radius: 10px; margin-bottom: 30px; } | ||
table { width: 100%; border-collapse: collapse; margin: 20px 0; } | ||
th, td { padding: 12px; text-align: left; border-bottom: 1px solid #eee; } | ||
th { background-color: #003366; color: white; } | ||
tr:nth-child(even) { background-color: #f2f2f2; } | ||
.severity-critical { color: red; font-weight: bold; } | ||
.severity-serious { color: orange; font-weight: bold; } | ||
.severity-moderate { color: #e6c600; font-weight: bold; } | ||
.severity-minor { color: green; font-weight: bold; } | ||
.collapsible { background-color: #f1f1f1; border: none; text-align: left; outline: none; font-size: 14px; cursor: pointer; } | ||
.collapsible::after { content: ' ▼'; } | ||
.collapsible.active::after { content: ' ▲'; } | ||
.content { display: none; padding: 10px; background-color: #f9f9f9; } | ||
td.fixed-column { max-width: 150px; white-space: pre-wrap; word-wrap: break-word; } | ||
td.centered { text-align: center; } | ||
.content pre { margin: 2px 0; white-space: pre-wrap; word-wrap: break-word; overflow-wrap: break-word; | ||
} | ||
</style>`; | ||
|
||
// Inline JavaScript for collapsible functionality and filtering | ||
const inlineJS = ` | ||
<script> | ||
document.addEventListener('DOMContentLoaded', function () { | ||
const collapsibles = document.querySelectorAll('.collapsible'); | ||
collapsibles.forEach(collapsible => { | ||
collapsible.addEventListener('click', function () { | ||
this.classList.toggle('active'); | ||
const content = this.nextElementSibling; | ||
content.style.display = content.style.display === 'block' ? 'none' : 'block'; | ||
}); | ||
}); | ||
// Filtering function | ||
function filterBySeverity(severity) { | ||
const rows = document.querySelectorAll('.violation-row'); | ||
rows.forEach(row => { | ||
const severityCell = row.querySelector('.severity-column'); | ||
if (severityCell) { | ||
row.style.display = severity && severityCell.textContent.toLowerCase() !== severity.toLowerCase() ? 'none' : ''; | ||
} | ||
}); | ||
} | ||
document.querySelectorAll('.filter-button').forEach(button => { | ||
button.addEventListener('click', () => { | ||
const severity = button.getAttribute('data-severity'); | ||
filterBySeverity(severity); | ||
}); | ||
}); | ||
}); | ||
</script>`; | ||
|
||
let htmlContent = ` | ||
<html> | ||
<head> | ||
<title>Nala Accessibility Test Report</title> | ||
${inlineCSS} | ||
</head> | ||
<body> | ||
<div class="banner"> | ||
<h1>Nala Accessibility Test Report</h1> | ||
<p style="font-size: 0.8em; line-height: 1.5;"> | ||
<i class="icon">ℹ️</i><strong>Nala leverages the @axe-core/playwright</strong> library for accessibility testing, enabling developers to quickly identify and resolve issues. | ||
<br> | ||
Axe-core evaluates compliance with <a href="https://www.w3.org/WAI/WCAG21/quickref/" target="_blank">WCAG 2.0, 2.1, and 2.2</a> standards across levels A, AA, and AAA. | ||
<br> | ||
This ensures web pages meet accessibility requirements across regions like the United States and European Union, | ||
<br> | ||
fostering inclusivity for individuals with disabilities. | ||
</p> | ||
</div> | ||
<div class="metadata-container"> | ||
<p>Total Violations: ${totalViolations}</p> | ||
</div> | ||
<div class="filters"> | ||
<button class="filter-button" data-severity="">All</button> | ||
<button class="filter-button" data-severity="critical">Critical (${severityCount.critical})</button> | ||
<button class="filter-button" data-severity="serious">Serious (${severityCount.serious})</button> | ||
<button class="filter-button" data-severity="moderate">Moderate (${severityCount.moderate})</button> | ||
<button class="filter-button" data-severity="minor">Minor (${severityCount.minor})</button> | ||
</div>`; | ||
|
||
// Test details section | ||
report.forEach((result, resultIndex) => { | ||
htmlContent += ` | ||
<div class="violation-section"> | ||
<h2>#${resultIndex + 1} Test URL: <a href="${result.url}" target="_blank">${result.url}</a></h2> | ||
<table> | ||
<thead> | ||
<tr> | ||
<th>#</th> | ||
<th>Violation</th> | ||
<th>Axe Rule ID</th> | ||
<th>Severity</th> | ||
<th>WCAG Tags</th> | ||
<th>Nodes Affected</th> | ||
<th>Fix</th> | ||
</tr> | ||
</thead> | ||
<tbody>`; | ||
|
||
result.violations.forEach((violation, index) => { | ||
const severityClass = `severity-${violation.impact.toLowerCase()}`; | ||
const wcagTags = Array.isArray(violation.tags) ? violation.tags.join(', ') : 'N/A'; | ||
const nodesAffected = violation.nodes | ||
.map((node) => `<p><pre><code>${prettyPrintHTML(escapeHTML(node.html))}</pre></code></p>`) | ||
.join('\n'); | ||
const possibleFix = violation.helpUrl ? `<a href="${violation.helpUrl}" target="_blank">Fix</a>` : 'N/A'; | ||
|
||
htmlContent += ` | ||
<tr class="violation-row"> | ||
<td>${index + 1}</td> | ||
<td class="fixed-column">${violation.description}</td> | ||
<td>${violation.id}</td> | ||
<td class="severity-column ${severityClass}">${violation.impact}</td> | ||
<td class="fixed-column">${wcagTags}</td> | ||
<td class="fixed-column"> | ||
<button class="collapsible">Show Nodes</button> | ||
<!--<div class="content"><pre><code>${nodesAffected}</code></pre></div> --> | ||
<div class="content">${nodesAffected} | ||
</div> | ||
</td> | ||
<td class="centered">${possibleFix}</td> | ||
</tr>`; | ||
}); | ||
|
||
htmlContent += ` | ||
</tbody> | ||
</table> | ||
</div>`; | ||
}); | ||
|
||
htmlContent += ` | ||
${inlineJS} | ||
</body> | ||
</html>`; | ||
|
||
// Write the HTML report to file | ||
try { | ||
await fs.writeFile(reportPath, htmlContent); | ||
console.info(`Accessibility report saved at: ${reportPath}`); | ||
// eslint-disable-next-line consistent-return | ||
return reportPath; | ||
} catch (err) { | ||
console.error(`Failed to save accessibility report: ${err.message}`); | ||
} | ||
} | ||
|
||
module.exports = generateA11yReport; |
Oops, something went wrong.