Skip to content

Commit

Permalink
[MWPW-161236][NALA] Nala Accessibility Test Bot (A11y Bot) (#3109)
Browse files Browse the repository at this point in the history
* 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
11 people authored Oct 30, 2024
1 parent b149849 commit 22ac880
Show file tree
Hide file tree
Showing 6 changed files with 700 additions and 785 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ logs/*
**/mas/*/stats.json
test-html-results/
test-results/
test-a11y-results/
133 changes: 133 additions & 0 deletions nala/utils/a11y-bot.js
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,
};
249 changes: 249 additions & 0 deletions nala/utils/a11y-report.js
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 '&amp;';
case '<': return '&lt;';
case '>': return '&gt;';
case "'": return '&#39;';
case '"': return '&quot;';
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;
Loading

0 comments on commit 22ac880

Please sign in to comment.