UNPKG

axe-playwright-report

Version:

Playwright + axe-core integration to run accessibility scans and build HTML dashboard reports.

254 lines (253 loc) 13.2 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.axeScan = axeScan; const playwright_1 = __importDefault(require("@axe-core/playwright")); const crypto_1 = require("crypto"); const fs_1 = __importDefault(require("fs")); /** * A decorator that runs an accessibility scan using Axe after executing the decorated method. * * **Usage Instructions**: * 1. This decorator should be assigned to methods within a **Page Object** class. * 2. The Page Object class should contain a parameter of type `Page` (otherwise, the accessibility scan will not be executed). * 3. The accessibility scan is performed after the body of the method is executed. The method will only be executed if the accessibility scan passes. * * The scan checks for common accessibility issues using the Axe-core library. * * @template This - The type of the class instance (or object) on which the decorated method is called. * @template Args - The type of arguments the decorated method takes. * @template Return - The return type of the decorated method. * * @param target - The method to be decorated. This method will be wrapped so that the Axe accessibility scan runs after it. * * @returns A new function that runs the accessibility scan after executing the original method. * It returns a `Promise` that resolves to the return value of the original method. * * @example * @axeScan * async someMethod() { * this.page.getByText('Hello World').click(); * } * } */ function axeScan() { return function actualDecorator(target) { async function scan(...args) { var _a; const result = await target.apply(this, args); const accessibilityConfig = loadEnvConfig(); if (accessibilityConfig.scan) { const page = Object.values(this).find((prop) => { var _a; return ((_a = prop === null || prop === void 0 ? void 0 : prop.constructor) === null || _a === void 0 ? void 0 : _a.name) === 'Page'; }); if (!page) { console.warn(`Page not found in context in args [${Object.values(this)}].\n(Make sure you are using the decorator on a method that has access to the Playwright Page);\nSkipping axe scan.\n`); return result; } let axeBuilder = new playwright_1.default({ page }); console.log(accessibilityConfig.withRules); if (accessibilityConfig.tags.length > 0) axeBuilder.withTags(accessibilityConfig.tags); if (accessibilityConfig.withRules.length > 0) axeBuilder.withRules(accessibilityConfig.withRules); if (accessibilityConfig.excludeRules.length > 0) axeBuilder.disableRules(accessibilityConfig.excludeRules); if (accessibilityConfig.exclude.length > 0) { accessibilityConfig.exclude.forEach(locator => axeBuilder.exclude(locator)); } if (accessibilityConfig.include.length > 0) { accessibilityConfig.include.forEach(locator => axeBuilder.include(locator)); } const accessibilityScanResults = await axeBuilder.analyze(); const id = (0, crypto_1.randomUUID)() + new Date().getTime().toString().slice(-4); accessibilityScanResults['id'] = id; let url = normalizeUrl(accessibilityScanResults.url, accessibilityConfig.customRegExp); accessibilityScanResults['newUrl'] = url; accessibilityScanResults['pagePath'] = url.replace((_a = process.env.URL) !== null && _a !== void 0 ? _a : "", ""); const violations = accessibilityScanResults.violations; const incomplete = accessibilityScanResults.incomplete; if (violations.length > 0 || incomplete.length > 0 || accessibilityScanResults.passes.length) { accessibilityScanResults['path'] = formatUrl(url); accessibilityScanResults['violationsScreenShot'] = `${id}_violations.png`; accessibilityScanResults['incompleteScreenShot'] = `${id}_incomplete.png`; accessibilityScanResults['inapplicableScreenShot'] = `${id}_inapplicable.png`; if (!fs_1.default.existsSync(accessibilityConfig.outputDir)) { fs_1.default.mkdirSync(accessibilityConfig.outputDir, { recursive: true }); } fs_1.default.writeFileSync(`${accessibilityConfig.outputDir}/${accessibilityScanResults['id']}.json`, JSON.stringify(accessibilityScanResults, null, 2)); if (accessibilityConfig.screenshots) { await highlightEachIssuesAndSaveScreenshot(violations, page, "red", id + "_violations", accessibilityConfig.outputDir); await highlightEachIssuesAndSaveScreenshot(incomplete, page, "red", id + "_incomplete", accessibilityConfig.outputDir); } } } return result; } return scan; }; } function formatUrl(inputUrl) { var _a; const relativePath = inputUrl.replace((_a = process.env.URL) !== null && _a !== void 0 ? _a : "", ""); const path = relativePath.replace(/[^a-zA-Z0-9]/g, "-").replace(/^-+|-+$/g, ""); return path.startsWith("/") ? path : `/${path}`; } async function highlightEachIssuesAndSaveScreenshot(issues, page, color, fileName, outputDir) { for (let i = 0; i < issues.length; i++) { for (let j = 0; j < issues[i].nodes.length; j++) { for (let k = 0; k < issues[i].nodes[j].target.length; k++) { try { const element = (await page.locator(issues[i].nodes[j].target[k]).all())[0]; await highlightElement(element, j, color); } catch (ignore) { } } } await page.screenshot({ path: `${outputDir}/${fileName}_${i + 1}.png` }); // Delete all highlighted elements for the current issue for (let j = 0; j < issues[i].nodes.length; j++) { for (let k = 0; k < issues[i].nodes[j].target.length; k++) { try { const element = (await page.locator(issues[i].nodes[j].target[k]).all())[0]; await element.evaluate((el) => { el.style.outline = "none"; const marker = el.querySelector(`[id^="marker-"]`); if (marker) { marker.textContent = ""; marker.remove(); } }, { timeout: 250 }); } catch (ignore) { } } } } } function loadEnvConfig(envPath = ".env.a11y") { const defaultConfig = { scan: true, outputDir: "axe-playwright-report/pages", screenshots: false, tags: [], withRules: [], excludeRules: [], include: [], exclude: [], customRegExp: [], }; if (!fs_1.default.existsSync(envPath)) return defaultConfig; const content = fs_1.default.readFileSync(envPath, "utf-8"); const env = {}; for (const line of content.split("\n")) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith("#")) continue; const [key, ...rest] = trimmed.split("="); if (!key || rest.length === 0) continue; env[key] = rest.join("=").trim().replace(/^['"]|['"]$/g, ""); } let regexPatterns; const raw = env["CUSTOM_REG_EXP"] || process.env.CUSTOM_REG_EXP; // raw string from env if (raw != undefined) { const patternStrings = raw.slice(1, -1).split('", "').map(s => s.replace(/^"|"$/g, '')); regexPatterns = patternStrings.map(pattern => { try { return new RegExp(pattern, "g"); // assumes no flags, raw pattern only } catch (e) { throw new Error(`Invalid regex pattern: ${pattern}${e}`); } }); } return { scan: env["SCAN"] ? env["SCAN"].toUpperCase() === "ON" : (process.env.SCAN ? process.env.SCAN.toUpperCase() === "ON" : defaultConfig.scan), outputDir: env["OUTPUT_DIR"] ? env["OUTPUT_DIR"] + "/pages" : (process.env.OUTPUT_DIR ? process.env.OUTPUT_DIR + "/pages" : defaultConfig.outputDir), screenshots: env["SCREENSHOT"] ? env["SCREENSHOT"].toUpperCase() === "ON" : (process.env.SCREENSHOT ? process.env.SCREENSHOT.toUpperCase() === "ON" : defaultConfig.screenshots), tags: env["TAGS"] ? env["TAGS"].split(",").map(t => t.trim()).filter(Boolean) : (process.env.TAGS ? process.env.TAGS.split(",").map(t => t.trim()).filter(Boolean) : defaultConfig.tags), withRules: env["WITH_RULES"] ? env["WITH_RULES"].split(",").map(t => t.trim()).filter(Boolean) : (process.env.WITH_RULES ? process.env.WITH_RULES.split(",").map(t => t.trim()).filter(Boolean) : defaultConfig.withRules), excludeRules: env["EXCLUDE_RULES"] ? env["EXCLUDE_RULES"].split(",").map(t => t.trim()).filter(Boolean) : (process.env.EXCLUDE_RULES ? process.env.EXCLUDE_RULES.split(",").map(t => t.trim()).filter(Boolean) : defaultConfig.excludeRules), include: env["INCLUDE"] ? env["INCLUDE"].split(",").map(t => t.trim()).filter(Boolean) : (process.env.INCLUDE ? process.env.INCLUDE.split(",").map(t => t.trim()).filter(Boolean) : defaultConfig.include), exclude: env["EXCLUDE"] ? env["EXCLUDE"].split(",").map(t => t.trim()).filter(Boolean) : (process.env.EXCLUDE ? process.env.EXCLUDE.split(",").map(t => t.trim()).filter(Boolean) : defaultConfig.exclude), customRegExp: regexPatterns }; } async function highlightElement(element, index, color) { try { await element.evaluate((el, args) => { el.scrollIntoView({ behavior: "auto", block: "center" }); el.style.position = "relative"; // Ensure proper placement el.style.outline = `2px solid ${args.color}`; // Highlight border with given color // Create a marker div const marker = document.createElement("div"); marker.id = `marker-${args.index}`; marker.textContent = `${args.index + 1}`; // Numbering // Style the marker marker.style.position = "absolute"; marker.style.top = "50%"; // Start at middle marker.style.left = "50%"; // Start at middle marker.style.transform = "translate(-50%, -50%)"; // Center correctly marker.style.width = "22px"; // Make a square marker.style.height = "22px"; marker.style.display = "flex"; marker.style.alignItems = "center"; marker.style.justifyContent = "center"; marker.style.background = args.color; // Use the passed color marker.style.color = "white"; marker.style.fontSize = "14px"; marker.style.fontWeight = "bold"; marker.style.borderRadius = "50%"; // Make it circular el.appendChild(marker); }, { index, color }, { timeout: 250 }); } catch (ignore) { } } function normalizeUrl(url, customPatterns = []) { const urlObj = new URL(url, "http://dummy.base"); let pathname = urlObj.pathname; let isCustomRegExp = false; let counter = 1; // Unique counter across all matches for (const pattern of customPatterns) { const matches = [...pathname.matchAll(pattern)]; if (matches.length > 0) { for (const match of matches) { pathname = pathname.replace(match[0], `/{$regExp${counter++}}`); } isCustomRegExp = true; } if (!pathname.includes("/", 0)) pathname += "/"; } if (isCustomRegExp) { return pathname + urlObj.search; } else { const normalizedPath = urlObj.pathname .split("/") .filter(Boolean) .map(segment => { if (segment.includes(".")) return segment; // e.g., inventory.html if (/^\d+$/.test(segment)) return ":id"; // numbers only if (/^(?=.*[a-zA-Z])(?=.*[0-9])[a-zA-Z0-9]{5,36}$/.test(segment)) return ":value"; if (/^(?=.*[a-zA-Z])(?=.*[0-9])[a-zA-Z0-9-]{4,11}$/.test(segment)) return ":slug_id"; if (/^(?=.*[a-zA-Z])(?=.*[0-9])[a-zA-Z0-9-]{12,36}$/.test(segment)) return ":uuid"; return segment; }) .join("/"); const params = Array.from(urlObj.searchParams.entries()) .map(([key]) => `${key}=*`) .sort(); const normalizedSearch = params.length > 0 ? `?${params.join("&")}` : ""; return "/" + normalizedPath + normalizedSearch; } }