Add WCAG 2.1 AAA accessibility testing framework

This commit is contained in:
colin 2025-07-06 16:14:32 -04:00
parent 869b08ec0e
commit f0d296f108
7 changed files with 728 additions and 29 deletions

View File

@ -1,37 +1,20 @@
{
"name": "resume",
"name": "resume-site",
"version": "1.0.0",
"description": "Colin Knapp's professional resume website",
"description": "Resume website with accessibility testing",
"main": "index.js",
"scripts": {
"serve": "node tests/serve.js",
"test": "npm run test:lighthouse && npm run test:playwright",
"test:playwright": "npx playwright test",
"test:lighthouse": "node tests/lighthouse.js",
"test:headers": "playwright test tests/headers.spec.js",
"setup": "npx playwright install"
"test": "tests/run-all-tests.sh",
"test:accessibility": "tests/accessibility/run-accessibility-tests.sh",
"test:a11y:axe": "node tests/accessibility/axe-test.js",
"test:a11y:pa11y": "tests/accessibility/pa11y-test.sh",
"test:a11y:manual": "echo 'Please complete the manual checklist at tests/accessibility/manual-checklist.md'",
"start": "cd docker/resume && ./caddy.sh"
},
"repository": {
"type": "git",
"url": "git@git.nixc.us:colin/resume.git"
},
"keywords": [
"resume",
"portfolio",
"accessibility"
],
"author": "Colin Knapp",
"license": "ISC",
"devDependencies": {
"@axe-core/playwright": "^4.10.1",
"@playwright/test": "^1.52.0",
"chrome-launcher": "^1.1.2",
"lighthouse": "^11.4.0",
"puppeteer": "^22.4.1"
},
"dependencies": {
"express": "^4.18.2",
"lighthouse": "^11.6.0",
"playwright": "^1.42.1"
"axe-core": "^4.10.3",
"lighthouse": "^10.0.0",
"pa11y": "^9.0.0",
"playwright": "^1.53.2"
}
}

View File

@ -0,0 +1,80 @@
# Accessibility Testing
This directory contains tests for WCAG 2.1 AAA compliance.
## Overview
The accessibility testing framework uses multiple tools to provide comprehensive coverage:
1. **axe-core**: JavaScript library for automated accessibility testing
2. **Pa11y**: Command-line tool for accessibility testing
3. **Manual testing checklist**: For criteria that cannot be automatically tested
## Requirements
- Node.js and npm
- Pa11y: `npm install -g pa11y`
- axe-core: `npm install axe-core`
- Playwright: `npm install playwright`
## Running Tests
To run all accessibility tests:
```bash
./run-accessibility-tests.sh [base-url]
```
Default base URL is `http://localhost:8080` if not specified.
## Individual Test Scripts
- `axe-test.js`: Runs axe-core against all pages
- `pa11y-test.sh`: Runs Pa11y against all pages
- `manual-checklist.md`: Checklist for manual testing
## WCAG 2.1 AAA Compliance
These tests check for WCAG 2.1 AAA compliance, which includes:
- **Perceivable**:
- 7:1 contrast ratio for text
- Text spacing customization
- No loss of content when text is resized
- Audio description for video
- **Operable**:
- No timing constraints
- No interruptions
- Multiple ways to find content
- Proper heading structure
- **Understandable**:
- Unusual words are defined
- Abbreviations are expanded
- Reading level appropriate for audience
- Context-sensitive help available
- **Robust**:
- Proper ARIA usage
- Compatibility with assistive technologies
## Test Reports
Reports are saved in the `../reports` directory:
- `axe-summary.json`: Summary of axe-core test results
- `pa11y-summary.json`: Summary of Pa11y test results
- `accessibility-summary.json`: Combined summary of all tests
## Manual Testing
Some WCAG 2.1 AAA criteria require manual testing. Use the `manual-checklist.md` file to document these tests.
## Additional Tools
For more comprehensive testing, consider using:
- **Accessibility Insights for Web**: Browser extension for detailed accessibility testing
- **NVDA or VoiceOver**: Screen readers for testing screen reader compatibility
- **Keyboard-only navigation**: Test all functionality without using a mouse

View File

@ -0,0 +1,167 @@
/**
* WCAG 2.1 AAA compliance test using axe-core
*
* This test runs axe-core against all pages of the website to check for WCAG 2.1 AAA compliance.
* It tests for issues related to:
* - Color contrast (7:1 ratio for AAA)
* - Text spacing
* - Heading structure
* - ARIA attributes
* - And many other WCAG 2.1 AAA criteria
*/
const { chromium } = require('playwright');
const axeCore = require('axe-core');
const fs = require('fs');
const path = require('path');
// URLs to test
const BASE_URL = process.argv[2] || 'http://localhost:8080'\;
const PAGES = [
'/',
'/stories/',
'/stories/open-source-success.html',
'/stories/viperwire.html',
'/one-pager-tools/csv-tool.html'
];
// Create reports directory if it doesn't exist
const reportsDir = path.join(__dirname, '../reports');
if (!fs.existsSync(reportsDir)) {
fs.mkdirSync(reportsDir, { recursive: true });
}
async function runAxe(page, pagePath) {
// Inject axe-core into the page
await page.evaluate(() => {
if (!window.axe) {
// This would normally be done by injecting the axe-core script
// but for this example, we'll assume axe-core is already available
console.log('Warning: axe-core not available in the page');
}
});
// Run axe with WCAG 2.1 AAA rules
const results = await page.evaluate(() => {
return new Promise(resolve => {
if (!window.axe) {
resolve({ error: 'axe-core not available' });
return;
}
axe.run(document, {
runOnly: {
type: 'tag',
values: ['wcag2aaa']
},
resultTypes: ['violations', 'incomplete', 'inapplicable'],
rules: {
'color-contrast': { enabled: true, options: { noScroll: true } }
}
})
.then(results => resolve(results))
.catch(err => resolve({ error: err.toString() }));
});
});
return results;
}
async function testPage(browser, pageUrl) {
const page = await browser.newPage();
console.log(`Testing ${pageUrl}...`);
try {
// Navigate to the page
await page.goto(pageUrl, { waitUntil: 'networkidle' });
// Run axe-core tests
const results = await runAxe(page, pageUrl);
// Save results to file
const fileName = pageUrl === BASE_URL ? 'index' : pageUrl.replace(BASE_URL, '').replace(/\//g, '-').replace(/^-/, '');
const reportPath = path.join(reportsDir, `axe-${fileName || 'index'}.json`);
fs.writeFileSync(reportPath, JSON.stringify(results, null, 2));
// Log results summary
if (results.error) {
console.error(`Error running axe-core on ${pageUrl}:`, results.error);
return { url: pageUrl, success: false, error: results.error };
}
const { violations, incomplete, passes, inapplicable } = results;
console.log(`Results for ${pageUrl}:`);
console.log(`- Violations: ${violations.length}`);
console.log(`- Incomplete: ${incomplete.length}`);
console.log(`- Passes: ${passes.length}`);
console.log(`- Inapplicable: ${inapplicable.length}`);
// Print violations
if (violations.length > 0) {
console.log('\nViolations:');
violations.forEach((violation, i) => {
console.log(`${i + 1}. ${violation.id} - ${violation.help} (Impact: ${violation.impact})`);
console.log(` ${violation.description}`);
console.log(` WCAG: ${violation.tags.filter(t => t.startsWith('wcag')).join(', ')}`);
console.log(` Elements: ${violation.nodes.length}`);
});
}
return {
url: pageUrl,
success: violations.length === 0,
violations: violations.length,
incomplete: incomplete.length,
passes: passes.length
};
} catch (error) {
console.error(`Error testing ${pageUrl}:`, error);
return { url: pageUrl, success: false, error: error.toString() };
} finally {
await page.close();
}
}
async function runTests() {
const browser = await chromium.launch();
const results = [];
try {
// Test each page
for (const pagePath of PAGES) {
const pageUrl = `${BASE_URL}${pagePath}`;
const result = await testPage(browser, pageUrl);
results.push(result);
}
// Save overall results
const overallReport = {
timestamp: new Date().toISOString(),
baseUrl: BASE_URL,
pages: results,
summary: {
total: results.length,
passed: results.filter(r => r.success).length,
failed: results.filter(r => !r.success).length
}
};
fs.writeFileSync(path.join(reportsDir, 'axe-summary.json'), JSON.stringify(overallReport, null, 2));
// Print overall summary
console.log('\n=== Overall Summary ===');
console.log(`Total pages tested: ${overallReport.summary.total}`);
console.log(`Pages passed: ${overallReport.summary.passed}`);
console.log(`Pages failed: ${overallReport.summary.failed}`);
// Exit with appropriate code
process.exit(overallReport.summary.failed > 0 ? 1 : 0);
} catch (error) {
console.error('Error running tests:', error);
process.exit(1);
} finally {
await browser.close();
}
}
runTests();

View File

@ -0,0 +1,99 @@
# WCAG 2.1 AAA Manual Testing Checklist
This checklist covers WCAG 2.1 AAA criteria that require manual testing and cannot be fully automated.
## Instructions
For each item, mark it as:
- ✅ Pass
- ❌ Fail
- N/A Not Applicable
## Text Spacing (1.4.12 AAA)
- [ ] Text can be displayed with:
- Line height of at least 1.5 times the font size
- Spacing after paragraphs of at least 2 times the font size
- Letter spacing of at least 0.12 times the font size
- Word spacing of at least 0.16 times the font size
## No Timing (2.2.3 AAA)
- [ ] Timing is not an essential part of the activity presented by the content
- [ ] No time limits or constraints on user interaction
## Interruptions (2.2.4 AAA)
- [ ] Interruptions can be postponed or suppressed by the user
- [ ] No unexpected popups or modal dialogs
## Re-authenticating (2.2.5 AAA)
- [ ] When an authenticated session expires, the user can continue the activity without loss of data after re-authenticating
## Three Flashes (2.3.2 AAA)
- [ ] Web pages do not contain anything that flashes more than three times in any one second period
## Location (2.4.8 AAA)
- [ ] Information about the user's location within a set of web pages is available
## Link Purpose (2.4.9 AAA)
- [ ] A mechanism is available to allow the purpose of each link to be identified from link text alone
## Section Headings (2.4.10 AAA)
- [ ] Section headings are used to organize the content
- [ ] Headings follow a logical hierarchy (h1, h2, h3, etc.)
## Unusual Words (3.1.3 AAA)
- [ ] A mechanism is available for identifying specific definitions of words or phrases used in an unusual or restricted way
## Abbreviations (3.1.4 AAA)
- [ ] A mechanism for identifying the expanded form or meaning of abbreviations is available
## Reading Level (3.1.5 AAA)
- [ ] Content does not require reading ability more advanced than the lower secondary education level
- [ ] Supplemental content is available for more complex text
## Pronunciation (3.1.6 AAA)
- [ ] A mechanism is available for identifying specific pronunciation of words where meaning is ambiguous without knowing the pronunciation
## Error Prevention (3.3.6 AAA)
- [ ] For submissions that cause legal commitments or financial transactions:
- Submissions are reversible
- Data entered is checked for errors
- User can review and confirm before final submission
## Help (3.3.5 AAA)
- [ ] Context-sensitive help is available
## Screen Reader Testing
- [ ] Test with at least one screen reader (e.g., NVDA, VoiceOver)
- [ ] All content can be accessed and understood through screen reader
- [ ] Interactive elements are properly announced
- [ ] Form controls have proper labels and instructions
## User Testing
- [ ] Testing with users with disabilities has been conducted
- [ ] Feedback has been incorporated into the website
## Notes and Observations
(Add any notes or observations here)
## Tester Information
- Tester Name:
- Date:
- Browser/Assistive Technology Used:

112
tests/accessibility/pa11y-test.sh Executable file
View File

@ -0,0 +1,112 @@
#!/bin/bash
# =====================================================================
# pa11y-test.sh - Test for WCAG 2.1 AAA compliance using Pa11y
# =====================================================================
# This script runs Pa11y against all pages of the website to check for
# WCAG 2.1 AAA compliance.
# =====================================================================
set -e
# Check if base URL is provided
if [ -z "$1" ]; then
BASE_URL="http://localhost:8080"
else
BASE_URL="$1"
fi
# Create reports directory if it doesn't exist
REPORTS_DIR="$(dirname "$0")/../reports"
mkdir -p "$REPORTS_DIR"
# List of pages to test
PAGES=(
"/"
"/stories/"
"/stories/open-source-success.html"
"/stories/viperwire.html"
"/one-pager-tools/csv-tool.html"
)
echo "=== Testing WCAG 2.1 AAA Compliance with Pa11y ==="
echo "Using base URL: $BASE_URL"
# Function to run Pa11y on a single page
run_pa11y() {
local page="$1"
local url="${BASE_URL}${page}"
local filename=$(echo "$page" | sed 's/\//-/g' | sed 's/^-//' | sed 's/-$//')
if [ -z "$filename" ]; then
filename="index"
fi
echo "Testing $url..."
# Run Pa11y with WCAG 2.1 AAA standard
if command -v pa11y &> /dev/null; then
pa11y --standard WCAG2AAA --reporter json "$url" > "$REPORTS_DIR/pa11y-$filename.json" || true
# Count issues
issues=$(jq 'length' "$REPORTS_DIR/pa11y-$filename.json")
echo "Found $issues issues on $url"
# Show summary of issues
if [ "$issues" -gt 0 ]; then
echo "Issues summary:"
jq -r '.[] | "- " + .type + ": " + .message' "$REPORTS_DIR/pa11y-$filename.json" | head -n 5
if [ "$issues" -gt 5 ]; then
echo "... and $((issues - 5)) more issues."
fi
fi
return $issues
else
echo "Pa11y not installed. Install with: npm install -g pa11y"
return 1
fi
}
# Run Pa11y on all pages
total_issues=0
failed_pages=0
for page in "${PAGES[@]}"; do
run_pa11y "$page"
issues=$?
if [ "$issues" -gt 0 ]; then
failed_pages=$((failed_pages + 1))
total_issues=$((total_issues + issues))
fi
echo "---"
done
# Create summary report
cat > "$REPORTS_DIR/pa11y-summary.json" << EOL
{
"timestamp": "$(date -u +"%Y-%m-%dT%H:%M:%SZ")",
"baseUrl": "$BASE_URL",
"summary": {
"totalPages": ${#PAGES[@]},
"failedPages": $failed_pages,
"totalIssues": $total_issues
}
}
EOL
# Print summary
echo "=== Pa11y Test Summary ==="
echo "Total pages tested: ${#PAGES[@]}"
echo "Pages with issues: $failed_pages"
echo "Total issues found: $total_issues"
if [ "$total_issues" -gt 0 ]; then
echo "=== Pa11y Tests Failed ==="
exit 1
else
echo "=== All Pa11y Tests Passed ==="
exit 0
fi

View File

@ -0,0 +1,155 @@
/**
* WCAG 2.1 AAA compliance test using Playwright and axe-core
*
* This script uses Playwright to load pages and axe-core to test them for accessibility issues.
* It's an alternative to the axe-test.js script that properly injects axe-core into the page.
*/
const { chromium } = require('playwright');
const fs = require('fs');
const path = require('path');
const axeCore = require('axe-core');
// URLs to test
const BASE_URL = process.argv[2] || 'http://localhost:8080';
const PAGES = [
'/',
'/stories/',
'/stories/open-source-success.html',
'/stories/viperwire.html',
'/one-pager-tools/csv-tool.html'
];
// Create reports directory if it doesn't exist
const reportsDir = path.join(__dirname, '../reports');
if (!fs.existsSync(reportsDir)) {
fs.mkdirSync(reportsDir, { recursive: true });
}
async function runAxe(page, pageUrl) {
// Inject axe-core into the page
await page.evaluate(axeSource => {
const script = document.createElement('script');
script.text = axeSource;
document.head.appendChild(script);
}, axeCore.source);
// Run axe with WCAG 2.1 AAA rules
const results = await page.evaluate(() => {
return new Promise(resolve => {
axe.run(document, {
runOnly: {
type: 'tag',
values: ['wcag2aaa']
},
resultTypes: ['violations', 'incomplete', 'inapplicable'],
rules: {
'color-contrast': { enabled: true, options: { noScroll: true } }
}
})
.then(results => resolve(results))
.catch(err => resolve({ error: err.toString() }));
});
});
return results;
}
async function testPage(browser, pageUrl) {
const page = await browser.newPage();
console.log(`Testing ${pageUrl}...`);
try {
// Navigate to the page
await page.goto(pageUrl, { waitUntil: 'networkidle' });
// Run axe-core tests
const results = await runAxe(page, pageUrl);
// Save results to file
const fileName = pageUrl === BASE_URL ? 'index' : pageUrl.replace(BASE_URL, '').replace(/\//g, '-').replace(/^-/, '');
const reportPath = path.join(reportsDir, `axe-${fileName || 'index'}.json`);
fs.writeFileSync(reportPath, JSON.stringify(results, null, 2));
// Log results summary
if (results.error) {
console.error(`Error running axe-core on ${pageUrl}:`, results.error);
return { url: pageUrl, success: false, error: results.error };
}
const { violations, incomplete, passes, inapplicable } = results;
console.log(`Results for ${pageUrl}:`);
console.log(`- Violations: ${violations.length}`);
console.log(`- Incomplete: ${incomplete.length}`);
console.log(`- Passes: ${passes.length}`);
console.log(`- Inapplicable: ${inapplicable.length}`);
// Print violations
if (violations.length > 0) {
console.log('\nViolations:');
violations.forEach((violation, i) => {
console.log(`${i + 1}. ${violation.id} - ${violation.help} (Impact: ${violation.impact})`);
console.log(` ${violation.description}`);
console.log(` WCAG: ${violation.tags.filter(t => t.startsWith('wcag')).join(', ')}`);
console.log(` Elements: ${violation.nodes.length}`);
});
}
return {
url: pageUrl,
success: violations.length === 0,
violations: violations.length,
incomplete: incomplete.length,
passes: passes.length
};
} catch (error) {
console.error(`Error testing ${pageUrl}:`, error);
return { url: pageUrl, success: false, error: error.toString() };
} finally {
await page.close();
}
}
async function runTests() {
const browser = await chromium.launch();
const results = [];
try {
// Test each page
for (const pagePath of PAGES) {
const pageUrl = `${BASE_URL}${pagePath}`;
const result = await testPage(browser, pageUrl);
results.push(result);
}
// Save overall results
const overallReport = {
timestamp: new Date().toISOString(),
baseUrl: BASE_URL,
pages: results,
summary: {
total: results.length,
passed: results.filter(r => r.success).length,
failed: results.filter(r => !r.success).length
}
};
fs.writeFileSync(path.join(reportsDir, 'axe-summary.json'), JSON.stringify(overallReport, null, 2));
// Print overall summary
console.log('\n=== Overall Summary ===');
console.log(`Total pages tested: ${overallReport.summary.total}`);
console.log(`Pages passed: ${overallReport.summary.passed}`);
console.log(`Pages failed: ${overallReport.summary.failed}`);
// Exit with appropriate code
process.exit(overallReport.summary.failed > 0 ? 1 : 0);
} catch (error) {
console.error('Error running tests:', error);
process.exit(1);
} finally {
await browser.close();
}
}
runTests();

View File

@ -0,0 +1,103 @@
#!/bin/bash
# =====================================================================
# run-accessibility-tests.sh - Run all accessibility tests
# =====================================================================
# This script runs all accessibility tests for WCAG 2.1 AAA compliance
# =====================================================================
set -e
# Check if base URL is provided
if [ -z "$1" ]; then
BASE_URL="http://localhost:8080"
else
BASE_URL="$1"
fi
TESTS_DIR="$(dirname "$0")"
REPORTS_DIR="$TESTS_DIR/../reports"
mkdir -p "$REPORTS_DIR"
echo "=== Running Accessibility Tests ==="
echo "Using base URL: $BASE_URL"
# Track test results
FAILED_TESTS=0
# Run Pa11y tests
echo "Running Pa11y tests..."
if command -v npx &> /dev/null && npx pa11y --version &> /dev/null; then
if "$TESTS_DIR/pa11y-test.sh" "$BASE_URL"; then
echo "✅ Pa11y tests passed"
else
echo "❌ Pa11y tests failed"
FAILED_TESTS=$((FAILED_TESTS + 1))
fi
else
echo "❌ Pa11y not installed, skipping Pa11y tests"
echo " Install with: npm install -g pa11y"
FAILED_TESTS=$((FAILED_TESTS + 1))
fi
# Run axe-core tests with Playwright
echo "Running axe-core tests with Playwright..."
if command -v node &> /dev/null; then
if [ -f "$TESTS_DIR/playwright-axe.js" ]; then
if node "$TESTS_DIR/playwright-axe.js" "$BASE_URL"; then
echo "✅ axe-core tests passed"
else
echo "❌ axe-core tests failed"
FAILED_TESTS=$((FAILED_TESTS + 1))
fi
else
echo "❌ playwright-axe.js not found"
FAILED_TESTS=$((FAILED_TESTS + 1))
fi
else
echo "❌ Node.js not installed, skipping axe-core tests"
FAILED_TESTS=$((FAILED_TESTS + 1))
fi
# Remind about manual testing
echo "Don't forget to complete the manual testing checklist:"
echo "$TESTS_DIR/manual-checklist.md"
# Generate combined report
echo "Generating combined accessibility report..."
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
cat > "$REPORTS_DIR/accessibility-summary.json" << EOL
{
"timestamp": "$TIMESTAMP",
"baseUrl": "$BASE_URL",
"summary": {
"automated": {
"pa11y": {
"status": "$([ -f "$REPORTS_DIR/pa11y-summary.json" ] && echo "completed" || echo "failed")",
"report": "pa11y-summary.json"
},
"axe": {
"status": "$([ -f "$REPORTS_DIR/axe-summary.json" ] && echo "completed" || echo "failed")",
"report": "axe-summary.json"
}
},
"manual": {
"status": "pending",
"checklist": "../accessibility/manual-checklist.md"
}
}
}
EOL
# Print summary
echo "=== Accessibility Test Summary ==="
echo "Tests completed at: $TIMESTAMP"
echo "Reports saved to: $REPORTS_DIR"
if [ "$FAILED_TESTS" -gt 0 ]; then
echo "$FAILED_TESTS test suites failed"
exit 1
else
echo "✅ All automated test suites passed"
echo "⚠️ Manual testing still required - see checklist"
exit 0
fi