Files
mb-grid-solutions.com/scripts/wcag-sitemap.ts
Marc Mintel 73cb55ee8d
Some checks failed
🚀 Build & Deploy / 🔍 Prepare (push) Successful in 8s
Nightly QA / call-qa-workflow (push) Failing after 1m34s
🚀 Build & Deploy / 🧪 QA (push) Has been cancelled
🚀 Build & Deploy / 🚀 Deploy (push) Has been cancelled
🚀 Build & Deploy / 🏗️ Build (push) Has been cancelled
🚀 Build & Deploy / 🧪 Post-Deploy Verification (push) Has been cancelled
🚀 Build & Deploy / 🔔 Notify (push) Has been cancelled
chore(qa): restore reusable workflow with Gitea compatibility fixes
2026-04-25 19:13:49 +02:00

242 lines
7.3 KiB
TypeScript

import axios from "axios";
import * as cheerio from "cheerio";
import { execSync } from "child_process";
import * as fs from "fs";
import * as path from "path";
import { AxiosError } from "axios";
interface Pa11yConfig {
defaults: {
threshold?: number;
runners?: string[];
ignore?: string[];
chromeLaunchConfig?: {
executablePath?: string;
args?: string[];
};
headers?: Record<string, string>;
timeout?: number;
};
urls?: string[];
}
interface Pa11yResult {
type?: string;
message?: string;
code?: string;
context?: string;
selector?: string;
}
/**
* WCAG Audit Script
*
* 1. Fetches sitemap.xml from the target URL
* 2. Extracts all URLs
* 3. Runs pa11y-ci on those URLs
*/
const targetUrl =
process.argv[2] ||
process.env.NEXT_PUBLIC_BASE_URL ||
"http://localhost:3000";
const limit = process.env.PAGESPEED_LIMIT
? parseInt(process.env.PAGESPEED_LIMIT)
: 20;
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || "mbgrid";
async function main() {
console.log(`\n🚀 Starting WCAG Audit for: ${targetUrl}`);
console.log(`📊 Limit: ${limit} pages\n`);
try {
// 1. Fetch Sitemap
const sitemapUrl = `${targetUrl.replace(/\/$/, "")}/sitemap.xml`;
console.log(`📥 Fetching sitemap from ${sitemapUrl}...`);
const response = await axios.get(sitemapUrl, {
headers: {
Cookie: `mintel_gatekeeper_session=${gatekeeperPassword}`,
},
validateStatus: (status) => status < 400,
});
const $ = cheerio.load(response.data, { xmlMode: true });
let urls = $("url loc")
.map((i, el) => $(el).text())
.get();
// Cleanup, filter and normalize domains to targetUrl
const urlPattern = /https?:\/\/[^\/]+/;
urls = [...new Set(urls)]
.filter((u) => u.startsWith("http"))
.map((u) => u.replace(urlPattern, targetUrl.replace(/\/$/, "")))
.sort();
console.log(`✅ Found ${urls.length} URLs in sitemap.`);
if (urls.length === 0) {
console.error("❌ No URLs found in sitemap. Is the site up?");
process.exit(1);
}
if (urls.length > limit) {
console.log(
`⚠️ Too many pages (${urls.length}). Limiting to ${limit} representative pages.`,
);
const home = urls.filter(
(u) => u.endsWith("/de") || u.endsWith("/en") || u === targetUrl,
);
const others = urls.filter((u) => !home.includes(u));
urls = [...home, ...others.slice(0, limit - home.length)];
}
console.log(`🧪 Pages to be tested:`);
urls.forEach((u) => console.log(` - ${u}`));
// 2. Prepare pa11y-ci config
const baseConfigPath = path.join(process.cwd(), ".pa11yci.json");
let baseConfig: Pa11yConfig = { defaults: {} };
if (fs.existsSync(baseConfigPath)) {
baseConfig = JSON.parse(fs.readFileSync(baseConfigPath, "utf8"));
}
// Extract domain for cookie (not currently used)
// const urlObj = new URL(targetUrl);
// domain is not used, so remove or keep if needed later? Linter says unused.
// const domain = urlObj.hostname;
// Update config with discovered URLs and gatekeeper cookie
const tempConfig = {
...baseConfig,
defaults: {
...baseConfig.defaults,
threshold: 0, // Force threshold to 0 so all errors are shown in JSON
runners: ["axe"],
ignore: [...(baseConfig.defaults?.ignore || []), "color-contrast"],
chromeLaunchConfig: {
...baseConfig.defaults?.chromeLaunchConfig,
...(process.env.CHROME_PATH
? { executablePath: process.env.CHROME_PATH }
: {}),
args: [
...(baseConfig.defaults?.chromeLaunchConfig?.args || []),
"--no-sandbox",
"--disable-setuid-sandbox",
],
},
headers: {
Cookie: `mintel_gatekeeper_session=${gatekeeperPassword}`,
},
timeout: 60000, // Increase timeout for slower pages
},
urls: urls,
};
// Create output directory
const outputDir = path.join(process.cwd(), ".pa11yci");
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
const tempConfigPath = path.join(outputDir, "config.temp.json");
const reportPath = path.join(outputDir, "report.json");
fs.writeFileSync(tempConfigPath, JSON.stringify(tempConfig, null, 2));
// 3. Execute pa11y-ci
console.log(`\n💻 Executing pa11y-ci...`);
const pa11yCommand = `npx pa11y-ci --config .pa11yci/config.temp.json --reporter json > .pa11yci/report.json`;
try {
execSync(pa11yCommand, {
encoding: "utf8",
stdio: "inherit",
});
} catch {
// pa11y-ci exits with non-zero if issues are found, which is expected
}
// 4. Summarize Results
if (fs.existsSync(reportPath)) {
const reportData = JSON.parse(fs.readFileSync(reportPath, "utf8"));
console.log(`\n📊 WCAG Audit Summary:\n`);
const summaryTable = Object.keys(reportData.results).map((url) => {
const results = reportData.results[url];
// Results might have errors or just a top level message if it crashed
let errors = 0;
let warnings = 0;
let notices = 0;
if (Array.isArray(results)) {
// pa11y action execution errors come as objects with a message but no type
const actionErrors = results.filter(
(r: Pa11yResult) => !r.type && r.message,
).length;
errors =
results.filter((r: Pa11yResult) => r.type === "error").length +
actionErrors;
warnings = results.filter(
(r: Pa11yResult) => r.type === "warning",
).length;
notices = results.filter(
(r: Pa11yResult) => r.type === "notice",
).length;
}
// Clean URL for display
const displayUrl = url.replace(targetUrl, "") || "/";
return {
URL:
displayUrl.length > 50
? displayUrl.substring(0, 47) + "..."
: displayUrl,
Errors: errors,
Warnings: warnings,
Notices: notices,
Status: errors === 0 ? "✅" : "❌",
};
});
console.table(summaryTable);
const totalErrors = summaryTable.reduce(
(acc, curr) => acc + curr.Errors,
0,
);
const totalPages = summaryTable.length;
const cleanPages = summaryTable.filter((p) => p.Errors === 0).length;
console.log(
`\n📈 Result: ${cleanPages}/${totalPages} pages are error-free.`,
);
if (totalErrors > 0) {
console.log(` Total Errors discovered: ${totalErrors}`);
process.exitCode = 1;
}
}
console.log(`\n✨ WCAG Audit completed!`);
} catch (error: unknown) {
console.error(`\n❌ Error during WCAG Audit:`);
if (error instanceof AxiosError) {
console.error(`Status: ${error.response?.status}`);
console.error(`URL: ${error.config?.url}`);
} else {
const errorBody = error instanceof Error ? error.message : String(error);
console.error(errorBody);
}
process.exit(1);
} finally {
// Clean up temp config file, keep report
const tempConfigPath = path.join(
process.cwd(),
".pa11yci/config.temp.json",
);
if (fs.existsSync(tempConfigPath)) fs.unlinkSync(tempConfigPath);
}
}
main();