├── .github ├── dependabot.yml └── workflows │ ├── npm-release.yml │ └── tests.yml ├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── docs ├── detailed-report-example.png ├── detailed-report-html-example.png ├── logo.png └── terminal_output_example.png ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── index.ts ├── reporter │ ├── defaultTerminalReporter.ts │ ├── junitReporter.ts │ └── terminalReporterV2.ts ├── types.ts └── utils.ts ├── test ├── a11y.spec.ts ├── site-no-accessibility-issues.html └── site.html └── tsconfig.json /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "23:30" 8 | open-pull-requests-limit: 10 9 | ignore: 10 | - dependency-name: "@types/node" 11 | versions: 12 | - 15.0.0 13 | -------------------------------------------------------------------------------- /.github/workflows/npm-release.yml: -------------------------------------------------------------------------------- 1 | name: NPM Release 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | versionType: 7 | description: 'Version type (major, minor, patch)' 8 | required: true 9 | default: 'patch' 10 | branches: 11 | - master 12 | 13 | jobs: 14 | npm-release: 15 | if: github.ref == 'refs/heads/master' 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Checkout code 19 | uses: actions/checkout@v2 20 | with: 21 | fetch-depth: 0 # fetch all history so that we can determine the version bump 22 | 23 | - name: Configure Git 24 | run: | 25 | git config --global user.name "${{ secrets.GIT_USER_NAME }}" 26 | git config --global user.email "${{ secrets.GIT_USER_EMAIL }}" 27 | 28 | - name: Setup Node.js 29 | uses: actions/setup-node@v2 30 | with: 31 | node-version: '16' 32 | registry-url: 'https://registry.npmjs.org/' 33 | 34 | - name: Install dependencies 35 | run: npm install 36 | 37 | # - name: Run Tests 38 | # run: | 39 | # npx playwright install --with-deps chromium 40 | # npm test 41 | 42 | - name: Build the package 43 | run: npm run build 44 | 45 | - name: Bump version 46 | run: npm version ${{ github.event.inputs.versionType }} -m "Upgrade to %s" 47 | 48 | - name: Publish to npm 49 | run: npm publish 50 | env: 51 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 52 | 53 | - name: Push changes and tags 54 | run: | 55 | git config user.name "${{ secrets.GIT_USER_NAME }}" 56 | git config user.email "${{ secrets.GIT_USER_EMAIL }}" 57 | git push 58 | git push --tags 59 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - uses: actions/setup-node@v2 14 | with: 15 | node-version: '16' 16 | cache: 'npm' 17 | - run: npm install 18 | - run: npx playwright install --with-deps chromium 19 | - run: npm test -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .cache 3 | dist 4 | .vscode 5 | .DS_Store 6 | .idea/ 7 | results 8 | artifacts 9 | 10 | 11 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | docs 2 | test 3 | node_modules 4 | package-lock.json 5 | .DS_Store 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Abhinaba Ghosh 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![logo](./docs/logo.png) 2 | 3 | # Axe-Playwright 4 | 5 | ![Test Status](https://github.com/abhinaba-ghosh/axe-playwright/actions/workflows/tests.yml/badge.svg?branch=master) 6 | ![Build Status](https://github.com/abhinaba-ghosh/axe-playwright/actions/workflows/npm-release.yml/badge.svg?branch=master) 7 | [![NPM release](https://img.shields.io/npm/v/axe-playwright.svg 'NPM release')](https://www.npmjs.com/package/axe-playwright) 8 | [![NPM Downloads](https://img.shields.io/npm/dt/axe-playwright.svg?style=flat-square)](https://www.npmjs.com/package/axe-playwright) 9 | 10 | Axe-core® is a powerful accessibility testing engine provided by Deque Systems that powers this package. Axe-Playwright provides simple commands to integrate the axe-core® library with your [Playwright](https://www.npmjs.com/package/playwright) tests. This integration functions seamlessly across all Playwright browsers: Chromium, Firefox, and WebKit. 11 | 12 | Axe-core® is a trademark of [Deque Systems, Inc.](https://www.deque.com/) in the US and other countries. This project is not formally affiliated with Deque, but we are big fans! Axe-core® is used here with permission. 13 | 14 | ## Install and configure 15 | 16 | ### Add as a dev dependency: 17 | 18 | ```sh 19 | npm i -D axe-playwright 20 | ``` 21 | 22 | ### Install peer dependencies: 23 | 24 | ```sh 25 | npm i -D playwright 26 | ``` 27 | 28 | NOTE: axe-core® is now bundled and doesn't need to be installed separately. 29 | 30 | ### Add Typings 31 | 32 | ```json 33 | //tsconfig.json 34 | { 35 | "compilerOptions": { 36 | "types": ["axe-playwright"] 37 | } 38 | } 39 | ``` 40 | 41 | ## Commands 42 | 43 | ### injectAxe 44 | 45 | This will inject the axe-core® runtime into the page under test. You must run this after a call to page.goto() and before you run the checkA11y command. 46 | 47 | You run this command with `injectAxe()` either in your test, or in a `beforeEach`, as long as the `visit` comes first. 48 | 49 | ```js 50 | beforeAll(async () => { 51 | browser = await chromium.launch() 52 | page = await browser.newPage() 53 | await page.goto(`http://localhost:3000/login`) 54 | await injectAxe(page) 55 | }) 56 | ``` 57 | 58 | ### configureAxe 59 | 60 | #### Purpose 61 | 62 | To configure the format of the data used by aXe. This can be used to add new rules, which must be registered with the library to execute. 63 | 64 | #### Description 65 | 66 | User specifies the format of the JSON structure passed to the callback of axe.run 67 | 68 | [Link - aXe Docs: axe.configure](https://www.deque.com/axe/documentation/api-documentation/#api-name-axeconfigure) 69 | 70 | ```js 71 | it('Has no detectable a11y violations on load (custom configuration)', async () => { 72 | // Configure aXe and test the page at initial load 73 | await configureAxe(page, { 74 | branding: { 75 | brand: String, 76 | application: String, 77 | }, 78 | reporter: 'option', 79 | checks: [Object], 80 | rules: [Object], 81 | locale: Object, 82 | }) 83 | await checkA11y() 84 | }) 85 | ``` 86 | 87 | ### checkA11y 88 | 89 | This will run axe against the document at the point in which it is called. This means you can call this after interacting with your page and uncover accessibility issues introduced as a result of rendering in response to user actions. 90 | 91 | #### Parameters on checkA11y (axe.run) 92 | 93 | ##### page (mandatory) 94 | 95 | The `page` instance of `playwright`. 96 | 97 | ##### context (optional) 98 | 99 | Defines the scope of the analysis - the part of the DOM that you would like to analyze. This will typically be the document or a specific selector such as class name, ID, selector, etc. 100 | 101 | ##### axeOptions (optional) 102 | 103 | Set of options passed into rules or checks, temporarily modifying them. This contrasts with axe.configure, which is more permanent. 104 | 105 | The keys consist of [those accepted by `axe.run`'s options argument](https://www.deque.com/axe/documentation/api-documentation/#parameters-axerun) as well as custom `includedImpacts`, `detailedReport`, `verbose`, and `detailedReportOptions` keys. 106 | 107 | The `includedImpacts` key is an array of strings that map to `impact` levels in violations. Specifying this array will only include violations where the impact matches one of the included values. Possible impact values are "minor", "moderate", "serious", or "critical". 108 | 109 | Filtering based on impact in combination with the `skipFailures` argument allows you to introduce `axe-playwright` into tests for a legacy application without failing in CI before you have an opportunity to address accessibility issues. Ideally, you would steadily move towards stricter testing as you address issues. 110 | e-effects, such as adding custom output to the terminal. 111 | 112 | **NOTE:** _This respects the `includedImpacts` filter and will only execute with violations that are included._ 113 | 114 | The `detailedReport` key is a boolean whether to print the more detailed report `detailedReportOptions` is an object with the shape 115 | 116 | ``` 117 | { 118 | html?: boolean // include the full html for the offending nodes 119 | } 120 | ``` 121 | 122 | The `verbose` key is a boolean to whether to print the message `No accessibility violations detected!` when there aren't accessibility violations present in the test. For the `DefaultTerminalReporter` this is true and for the `v2 Reporter` this is false. 123 | 124 | ##### skipFailures (optional, defaults to false) 125 | 126 | Disables assertions based on violations and only logs violations to the console output. If you set `skipFailures` as `true`, although accessibility check is not passed, your test will not fail. It will simply print the violations in the console, but will not make the test fail. 127 | 128 | ##### reporter (optional) 129 | 130 | A class instance that implements the `Reporter` interface or values `default`, `v2` and `html`. Custom reporter instances can be supplied to override default reporting behaviour dictated by `DefaultTerminalReporter` set by the value `default`. `v2` is the new TerminalReporter inspired by the reports from [jest-axe](https://github.com/nickcolley/jest-axe). `html` reporter will generate external HTML file. 131 | 132 | Note! `html` reporter will disable printed to logs violations. 133 | 134 | ##### options (dedicated for axe-html-reporter) 135 | 136 | Options dedicated for HTML reporter. 137 | [axe-html-reporter](https://www.npmjs.com/package/axe-html-reporter) 138 | 139 | ### getAxeResults 140 | 141 | This will run axe against the document at the point in which it is called, then returns the full set of results as reported by `axe.run`. 142 | 143 | #### Parameters on getAxeResults 144 | 145 | ##### page (mandatory) 146 | 147 | The `page` instance of `playwright`. 148 | 149 | ##### context (optional) 150 | 151 | Defines the scope of the analysis - the part of the DOM that you would like to analyze. This will typically be the document or a specific selector such as class name, ID, selector, etc. 152 | 153 | ##### options (optional) 154 | 155 | Set of options passed into rules or checks, temporarily modifying them. This contrasts with axe.configure, which is more permanent. 156 | 157 | The object is of the same type which is [accepted by `axe.run`'s options argument](https://www.deque.com/axe/documentation/api-documentation/#parameters-axerun) and directly forwarded to it. 158 | 159 | ### getViolations 160 | 161 | This will run axe against the document at the point in which it is called, then return you an array of accessibility violations (i.e. the `violations` array included in the `getAxeResults` result). 162 | 163 | #### Parameters on getViolations (axe.run) 164 | 165 | Identical to [parameters of getAxeResults](#parameters-on-getAxeResults). 166 | 167 | ### reportViolations 168 | 169 | Reports violations based on the `Reporter` concrete implementation behaviour. 170 | 171 | #### Parameters on reportViolations 172 | 173 | ##### violations (mandatory) 174 | 175 | An array of Axe violations to be printed. 176 | 177 | ##### reporter (mandatory) 178 | 179 | A class instance that implements the `Reporter` interface. Custom reporter instances can be supplied to override default reporting behaviour dictated by `DefaultTerminalReporter`. 180 | 181 | ### Examples 182 | 183 | #### Basic usage 184 | 185 | ```ts 186 | import { chromium, Browser, Page } from 'playwright' 187 | import { injectAxe, checkA11y, getViolations, reportViolations } from 'axe-playwright' 188 | 189 | let browser: Browser 190 | let page: Page 191 | 192 | describe('Playwright web page accessibility test', () => { 193 | beforeAll(async () => { 194 | browser = await chromium.launch() 195 | page = await browser.newPage() 196 | await page.goto(`file://${process.cwd()}/test/site.html`) 197 | await injectAxe(page) 198 | }) 199 | 200 | it('simple accessibility run', async () => { 201 | await checkA11y(page) 202 | }) 203 | 204 | it('check a11y for the whole page and axe run options', async () => { 205 | await checkA11y(page, null, { 206 | axeOptions: { 207 | runOnly: { 208 | type: 'tag', 209 | values: ['wcag2a'], 210 | }, 211 | }, 212 | }) 213 | }) 214 | 215 | it('check a11y for the specific element', async () => { 216 | await checkA11y(page, 'input[name="password"]', { 217 | axeOptions: { 218 | runOnly: { 219 | type: 'tag', 220 | values: ['wcag2a'], 221 | }, 222 | }, 223 | }) 224 | }) 225 | 226 | it('gets and reports a11y for the specific element', async () => { 227 | const violations = await getViolations(page, 'input[name="password"]', { 228 | runOnly: { 229 | type: 'tag', 230 | values: ['wcag2a'], 231 | }, 232 | }) 233 | 234 | reportViolations(violations, new YourAwesomeCsvReporter('accessibility-report.csv')) 235 | 236 | expect(violations.length).toBe(0) 237 | }) 238 | 239 | afterAll(async () => { 240 | await browser.close() 241 | }) 242 | }) 243 | ``` 244 | 245 | This custom logging behavior results in terminal output like this: 246 | 247 | ![Custom terminal logging](./docs/terminal_output_example.png) 248 | 249 | #### Detailed Report 250 | 251 | The detailed report is disabled by default, but can be enabled by including the `detailedReport` property in the `checkAlly` options. 252 | 253 | ```ts 254 | import { chromium, Browser, Page } from 'playwright' 255 | import { injectAxe, checkA11y } from 'axe-playwright' 256 | 257 | let browser: Browser 258 | let page: Page 259 | 260 | describe('Playwright web page accessibility test', () => { 261 | beforeAll(async () => { 262 | browser = await chromium.launch() 263 | page = await browser.newPage() 264 | await page.goto(`file://${process.cwd()}/test/site.html`) 265 | await injectAxe(page) 266 | }) 267 | 268 | // Prints outs a detailed report per node with an array of numbers of which violations from the summary affect that node 269 | it('print out a detailed report on violations', async () => { 270 | await checkA11y(page, null, { 271 | detailedReport: true, 272 | }) 273 | }) 274 | 275 | // Same as above, but includes the html of the offending node 276 | it('print out a detailed report on violations', async () => { 277 | await checkA11y(page, null, { 278 | detailedReport: true, 279 | detailedReportOptions: { html: true }, 280 | }) 281 | }) 282 | 283 | afterAll(async () => { 284 | await browser.close() 285 | }) 286 | }) 287 | ``` 288 | 289 | ![Detailed Report](./docs/detailed-report-example.png) 290 | 291 | ![Detailed Report with HTML](./docs/detailed-report-html-example.png) 292 | 293 | #### HTML Report 294 | 295 | Thanks to [axe-html-reporter](https://www.npmjs.com/package/axe-html-reporter) you can generate HTML report(s). 296 | From default HTML file(s) will be generated under `/artifacts/accessibilityReport.html`. 297 | Report's options can customized from `checkAlly` level: 298 | 299 | ``` 300 | await checkA11y( 301 | page, 302 | 'form', 303 | { 304 | axeOptions: { 305 | runOnly: { 306 | type: 'tag', 307 | values: ['wcag2a'], 308 | }, 309 | }, 310 | }, 311 | true, 312 | 'html', 313 | { 314 | outputDirPath: 'results', 315 | outputDir: 'accessibility', 316 | reportFileName: 'accessibility-audit.html' 317 | } 318 | ) 319 | ``` 320 | #### JUnit Report 321 | 322 | ``` 323 | await checkA11y( 324 | page, 325 | 'form', 326 | { 327 | axeOptions: { 328 | runOnly: { 329 | type: 'tag', 330 | values: ['wcag2a'], 331 | }, 332 | }, 333 | }, 334 | true, 335 | 'junit', 336 | { 337 | outputDirPath: 'results', 338 | outputDir: 'accessibility', 339 | reportFileName: 'accessibility-audit.xml', 340 | }, 341 | ) 342 | ``` 343 | ## Before you Go 344 | 345 | If it works for you , leave a [Star](https://github.com/abhinaba-ghosh/axe-playwright)! :star: 346 | -------------------------------------------------------------------------------- /docs/detailed-report-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abhinaba-ghosh/axe-playwright/cdec5d930129944d673f42d9124835c396d18e6f/docs/detailed-report-example.png -------------------------------------------------------------------------------- /docs/detailed-report-html-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abhinaba-ghosh/axe-playwright/cdec5d930129944d673f42d9124835c396d18e6f/docs/detailed-report-html-example.png -------------------------------------------------------------------------------- /docs/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abhinaba-ghosh/axe-playwright/cdec5d930129944d673f42d9124835c396d18e6f/docs/logo.png -------------------------------------------------------------------------------- /docs/terminal_output_example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abhinaba-ghosh/axe-playwright/cdec5d930129944d673f42d9124835c396d18e6f/docs/terminal_output_example.png -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'jest-playwright-preset', 3 | testEnvironment: 'node', 4 | testMatch: ['**/tests/**/*.[jt]s?(x)', '**/?(*.)+(spec|test).[tj]s?(x)'], 5 | testPathIgnorePatterns: ['/node_modules/'], 6 | testTimeout: 30000, 7 | transform: { 8 | '^.+\\.tsx?$': 'ts-jest', 9 | }, 10 | } 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "axe-playwright", 3 | "version": "2.1.0", 4 | "description": "Custom Playwright commands to inject axe-core and test for a11y", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "files": [ 8 | "dist/**/*", 9 | "README.md" 10 | ], 11 | "keywords": [ 12 | "a11y", 13 | "accessibility", 14 | "axe", 15 | "axe-core", 16 | "playwright" 17 | ], 18 | "scripts": { 19 | "prebuild": "rm -rf dist", 20 | "build": "tsc", 21 | "test": "jest", 22 | "format": "npx prettier --write .", 23 | "prerelease": "npm run build", 24 | "release": "npm cache clean --force && npm version patch && npm publish --force" 25 | }, 26 | "peerDependencies": { 27 | "playwright": ">1.0.0" 28 | }, 29 | "author": "Abhinaba Ghosh", 30 | "license": "MIT", 31 | "devDependencies": { 32 | "@types/jest": "^29.5.13", 33 | "@types/node": "^22.7.8", 34 | "jest": "^29.7.0", 35 | "jest-each": "^29.7.0", 36 | "jest-playwright-preset": "^4.0.0", 37 | "playwright": "^1.48.1", 38 | "prettier": "^3.3.3", 39 | "ts-jest": "^29.2.5", 40 | "typescript": "^5.6.3" 41 | }, 42 | "dependencies": { 43 | "@types/junit-report-builder": "^3.0.2", 44 | "axe-core": "^4.10.1", 45 | "axe-html-reporter": "2.2.11", 46 | "junit-report-builder": "^5.1.1", 47 | "picocolors": "^1.1.1" 48 | }, 49 | "repository": { 50 | "type": "git", 51 | "url": "git+https://github.com/abhinaba-ghosh/axe-playwright.git" 52 | }, 53 | "bugs": { 54 | "url": "https://github.com/abhinaba-ghosh/axe-playwright/issues" 55 | }, 56 | "homepage": "https://github.com/abhinaba-ghosh/axe-playwright#readme", 57 | "prettier": { 58 | "singleQuote": true, 59 | "trailingComma": "all", 60 | "semi": false 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Page } from 'playwright' 2 | import * as fs from 'fs' 3 | import axe, { 4 | AxeResults, 5 | ElementContext, 6 | Result, 7 | RunOptions, 8 | Spec, 9 | } from 'axe-core' 10 | import { getImpactedViolations, testResultDependsOnViolations } from './utils' 11 | import DefaultTerminalReporter from './reporter/defaultTerminalReporter' 12 | import TerminalReporterV2 from './reporter/terminalReporterV2' 13 | import Reporter, { ConfigOptions, AxeOptions } from './types' 14 | import { CreateReport, createHtmlReport, Options } from 'axe-html-reporter' 15 | import JUnitReporter from './reporter/junitReporter' 16 | import * as path from 'path' 17 | 18 | declare global { 19 | interface Window { 20 | axe: any 21 | } 22 | } 23 | 24 | declare module 'axe-core' { 25 | interface Node {} 26 | } 27 | 28 | /** 29 | * Injects axe executable commands in the active window 30 | * @param page 31 | */ 32 | export const injectAxe = async (page: Page): Promise => { 33 | const axe: string = fs.readFileSync(require.resolve('axe-core/axe.min.js'), { 34 | encoding: 'utf8', 35 | }) 36 | await page.evaluate((axe: string) => window.eval(axe), axe) 37 | } 38 | 39 | /** 40 | * Configures axe runtime options 41 | * @param page 42 | * @param configurationOptions 43 | */ 44 | export const configureAxe = async ( 45 | page: Page, 46 | configurationOptions: ConfigOptions = {}, 47 | ): Promise => { 48 | await page.evaluate( 49 | (configOptions: Spec) => window.axe.configure(configOptions), 50 | configurationOptions as Spec, 51 | ) 52 | } 53 | 54 | /** 55 | * Runs axe-core tools on the relevant page and returns all results 56 | * @param page 57 | * @param context 58 | * @param options 59 | */ 60 | export const getAxeResults = async ( 61 | page: Page, 62 | context?: ElementContext, 63 | options?: RunOptions, 64 | ): Promise => { 65 | const result = await page.evaluate( 66 | ([context, options]) => { 67 | return window.axe.run(context || window.document, options) 68 | }, 69 | [context, options], 70 | ) 71 | 72 | return result as Promise 73 | } 74 | 75 | /** 76 | * Runs axe-core tools on the relevant page and returns all accessibility violations detected on the page 77 | * @param page 78 | * @param context 79 | * @param options 80 | */ 81 | export const getViolations = async ( 82 | page: Page, 83 | context?: ElementContext, 84 | options?: RunOptions, 85 | ): Promise => { 86 | const result = await getAxeResults(page, context, options) 87 | return result.violations 88 | } 89 | 90 | /** 91 | * Report violations given the reporter. 92 | * @param violations 93 | * @param reporter 94 | */ 95 | export const reportViolations = async ( 96 | violations: Result[], 97 | reporter: Reporter, 98 | ): Promise => { 99 | await reporter.report(violations) 100 | } 101 | 102 | /** 103 | * Performs Axe validations 104 | * @param page 105 | * @param context 106 | * @param axeOptions 107 | * @param skipFailures 108 | * @param reporter 109 | * @param options 110 | */ 111 | export const checkA11y = async ( 112 | page: Page, 113 | context: ElementContext | undefined = undefined, 114 | axeOptions: AxeOptions | undefined = undefined, 115 | skipFailures: boolean = false, 116 | reporter: Reporter | 'default' | 'html' | 'junit' | 'v2' = 'default', 117 | options: Options | undefined = undefined, 118 | ): Promise => { 119 | const violations = await getViolations(page, context, axeOptions?.axeOptions) 120 | 121 | const impactedViolations = getImpactedViolations( 122 | violations, 123 | axeOptions?.includedImpacts, 124 | ) 125 | 126 | let reporterWithOptions: Promise | Reporter | void | any 127 | 128 | if (reporter === 'default') { 129 | reporterWithOptions = new DefaultTerminalReporter( 130 | axeOptions?.detailedReport, 131 | axeOptions?.detailedReportOptions?.html, 132 | axeOptions?.verbose ?? true, 133 | ) 134 | } else if (reporter === 'v2') { 135 | reporterWithOptions = new TerminalReporterV2(axeOptions?.verbose ?? false) 136 | } else if (reporter === 'html') { 137 | if (violations.length > 0) { 138 | await createHtmlReport({ 139 | results: { violations }, 140 | options, 141 | } as CreateReport) 142 | testResultDependsOnViolations(violations, skipFailures) 143 | } else console.log('There were no violations to save in report') 144 | } else if (reporter === 'junit') { 145 | // Get the system root directory 146 | // Construct the file path 147 | const outputFilePath = path.join( 148 | process.cwd(), 149 | options?.outputDirPath as any, 150 | options?.outputDir as any, 151 | options?.reportFileName as any, 152 | ) 153 | 154 | reporterWithOptions = new JUnitReporter( 155 | axeOptions?.detailedReport, 156 | page, 157 | outputFilePath, 158 | ) 159 | } else { 160 | reporterWithOptions = reporter 161 | } 162 | 163 | if (reporter !== 'html') 164 | await reportViolations(impactedViolations, reporterWithOptions) 165 | 166 | if (reporter === 'v2' || (reporter !== 'html' && reporter !== 'junit')) 167 | testResultDependsOnViolations(impactedViolations, skipFailures) 168 | } 169 | 170 | export { DefaultTerminalReporter } 171 | -------------------------------------------------------------------------------- /src/reporter/defaultTerminalReporter.ts: -------------------------------------------------------------------------------- 1 | import Reporter from '../types' 2 | import { Result } from 'axe-core' 3 | import { describeViolations } from '../utils' 4 | 5 | export default class DefaultTerminalReporter implements Reporter { 6 | constructor( 7 | protected detailedReport: boolean | undefined, 8 | protected includeHtml: boolean | undefined, 9 | protected verbose: boolean | undefined, 10 | ) {} 11 | 12 | async report(violations: Result[]): Promise { 13 | const violationData = violations.map(({ id, impact, description, nodes }) => { 14 | return { 15 | id, 16 | impact, 17 | description, 18 | nodes: nodes.length, 19 | } 20 | }) 21 | 22 | if (violationData.length > 0) { 23 | // summary 24 | console.table(violationData) 25 | if (this.detailedReport) { 26 | const nodeViolations = describeViolations(violations).map( 27 | ({ target, html, violations }) => { 28 | if (!this.includeHtml) { 29 | return { 30 | target, 31 | violations, 32 | } 33 | } 34 | return { target, html, violations } 35 | }, 36 | ) 37 | // per node 38 | console.table(nodeViolations) 39 | } 40 | } else { 41 | this.verbose && console.log(`No accessibility violations detected!`) 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/reporter/junitReporter.ts: -------------------------------------------------------------------------------- 1 | import Reporter from '../types' 2 | import { Result } from 'axe-core' 3 | import { Page } from 'playwright' 4 | import builder from 'junit-report-builder' 5 | import pc from 'picocolors' 6 | import assert from 'assert' 7 | import * as fs from 'fs' 8 | import * as path from 'path' 9 | 10 | export default class JUnitReporter implements Reporter { 11 | constructor( 12 | protected verbose: boolean | undefined, 13 | protected page: Page | undefined, 14 | protected outputFilename: string | undefined, 15 | ) {} 16 | 17 | async report(violations: Result[]): Promise { 18 | let lineBreak = '\n' 19 | let pageUrl = this.page?.url() || 'Page' 20 | let suite = builder.testSuite().name(pageUrl) 21 | 22 | const message = 23 | violations.length === 0 24 | ? 'No accessibility violations detected!' 25 | : `Found ${violations.length} accessibility violations` 26 | 27 | violations.map((violation) => { 28 | const errorBody = violation.nodes 29 | .map((node) => { 30 | const selector = node.target.join(', ') 31 | const expectedText = 32 | `Expected the HTML found at $('${selector}') to have no violations:` + 33 | '\n' 34 | return ( 35 | expectedText + 36 | node.html + 37 | lineBreak + 38 | `Received:\n` + 39 | `${violation.help} (${violation.id})` + 40 | lineBreak + 41 | node.failureSummary + 42 | lineBreak + 43 | (violation.helpUrl 44 | ? `You can find more information on this issue here: \n${violation.helpUrl}` 45 | : '') + 46 | '\n' 47 | ) 48 | }) 49 | .join(lineBreak) 50 | 51 | suite 52 | .testCase() 53 | .className(violation.id) 54 | .name(violation.description) 55 | .failure(errorBody) 56 | }) 57 | 58 | const pass = violations.length === 0 59 | 60 | if (pass) { 61 | builder.testCase().name('Accesibility testing - A11Y') 62 | this.verbose && console.log(`No accessibility violations detected!`) 63 | } 64 | let location = this.outputFilename || 'a11y-tests.xml' 65 | 66 | const dir = path.dirname(location) 67 | if (!fs.existsSync(dir)) { 68 | fs.mkdirSync(dir, { recursive: true }) 69 | } 70 | 71 | // Check if the file exists, if not create it 72 | if (!fs.existsSync(location)) { 73 | fs.writeFileSync(location, '') // Create an empty file 74 | } 75 | 76 | builder.writeTo(location) 77 | 78 | if (!pass) { 79 | assert.fail(message) 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/reporter/terminalReporterV2.ts: -------------------------------------------------------------------------------- 1 | import Reporter from '../types' 2 | import { Result } from 'axe-core' 3 | import assert from 'assert' 4 | import pc from 'picocolors' 5 | 6 | export default class TerminalReporterV2 implements Reporter { 7 | constructor( 8 | protected verbose: boolean | undefined, 9 | ) {} 10 | 11 | async report(violations: Result[]) { 12 | const lineBreak = '\n\n' 13 | 14 | const message = 15 | violations.length === 0 16 | ? 'No accessibility violations detected!' 17 | : `Found ${violations.length} accessibility violations: \n` 18 | 19 | const horizontalLine = pc.bold('-'.repeat(message.length)) 20 | 21 | const reporter = (violations: Result[]) => { 22 | if (violations.length === 0) { 23 | return [] 24 | } 25 | 26 | return violations 27 | .map((violation) => { 28 | const errorBody = violation.nodes 29 | .map((node) => { 30 | const selector = node.target.join(', ') 31 | const expectedText = 32 | `Expected the HTML found at $('${selector}') to have no violations:` + 33 | '\n' 34 | return ( 35 | pc.bold(expectedText) + 36 | pc.gray(node.html) + 37 | lineBreak + 38 | `Received:\n` + 39 | pc.red(`${violation.help} (${violation.id})`) + 40 | lineBreak + 41 | pc.bold(pc.yellow(node.failureSummary)) + 42 | lineBreak + 43 | (violation.helpUrl 44 | ? `You can find more information on this issue here: \n${pc.bold( 45 | pc.blue(violation.helpUrl), 46 | )}` 47 | : '') + 48 | '\n' + 49 | horizontalLine 50 | ) 51 | }) 52 | .join(lineBreak) 53 | 54 | return errorBody 55 | }) 56 | .join(lineBreak) 57 | } 58 | const formatedViolations = reporter(violations) 59 | const pass = formatedViolations.length === 0 60 | 61 | if (pass) { 62 | this.verbose && console.log(message) 63 | } else { 64 | assert.fail(message + horizontalLine + '\n' + formatedViolations) 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { Result, Check, ImpactValue, Locale, Rule, RunOptions } from 'axe-core' 2 | 3 | export interface NodeViolation { 4 | target: string 5 | html: string 6 | violations: string 7 | } 8 | 9 | export interface Aggregate { 10 | [key: string]: { 11 | target: string 12 | html: string 13 | violations: number[] 14 | } 15 | } 16 | 17 | export default interface Reporter { 18 | report(violations: Result[]): Promise 19 | } 20 | 21 | export interface axeOptionsConfig { 22 | axeOptions?: RunOptions 23 | } 24 | 25 | export interface ConfigOptions { 26 | branding?: { 27 | brand?: string 28 | application?: string 29 | } 30 | reporter?: 'v1' | 'v2' | 'no-passes' 31 | checks?: Check[] 32 | rules?: Rule[] 33 | locale?: Locale 34 | axeVersion?: string 35 | } 36 | 37 | /** 38 | * Implement this interface to be able to specific custom reporting behaviour for checkA11y method. 39 | * @see checkA11y 40 | */ 41 | export default interface Reporter { 42 | report(violations: Result[]): Promise 43 | } 44 | 45 | export type AxeOptions = { 46 | includedImpacts?: ImpactValue[] 47 | detailedReport?: boolean 48 | detailedReportOptions?: { html?: boolean } 49 | verbose?: boolean 50 | } & axeOptionsConfig 51 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { Aggregate, NodeViolation } from './types' 2 | import { ImpactValue, Result } from 'axe-core' 3 | import assert from 'assert' 4 | 5 | export const getImpactedViolations = ( 6 | violations: Result[], 7 | includedImpacts: ImpactValue[] = [], 8 | ): Result[] => { 9 | return Array.isArray(includedImpacts) && includedImpacts.length 10 | ? violations.filter( 11 | (v: Result) => v.impact && includedImpacts.includes(v.impact), 12 | ) 13 | : violations 14 | } 15 | 16 | export const testResultDependsOnViolations = ( 17 | violations: Result[], 18 | skipFailures: boolean, 19 | ) => { 20 | if (!skipFailures) { 21 | assert.strictEqual( 22 | violations.length, 23 | 0, 24 | `${violations.length} accessibility violation${ 25 | violations.length === 1 ? '' : 's' 26 | } ${violations.length === 1 ? 'was' : 'were'} detected`, 27 | ) 28 | } else { 29 | if (violations.length) { 30 | console.warn({ 31 | name: 'a11y violation summary', 32 | message: `${violations.length} accessibility violation${ 33 | violations.length === 1 ? '' : 's' 34 | } ${violations.length === 1 ? 'was' : 'were'} detected`, 35 | }) 36 | } 37 | } 38 | } 39 | 40 | export const describeViolations = (violations: Result[]): NodeViolation[] => { 41 | const aggregate: Aggregate = {} 42 | 43 | violations.map(({ nodes }, index) => { 44 | nodes.forEach(({ target, html }) => { 45 | const key = JSON.stringify(target) + html 46 | 47 | if (aggregate[key]) { 48 | aggregate[key].violations.push(index) 49 | } else { 50 | aggregate[key] = { 51 | target: JSON.stringify(target), 52 | html, 53 | violations: [index], 54 | } 55 | } 56 | }) 57 | }) 58 | return Object.values(aggregate).map(({ target, html, violations }) => { 59 | return { target, html, violations: JSON.stringify(violations) } 60 | }) 61 | } 62 | -------------------------------------------------------------------------------- /test/a11y.spec.ts: -------------------------------------------------------------------------------- 1 | import { Browser, chromium, Page } from 'playwright' 2 | import { checkA11y, injectAxe } from '../src' 3 | import each from 'jest-each' 4 | import fs from 'fs' 5 | import * as path from 'path' 6 | 7 | let browser: Browser 8 | let page: Page 9 | 10 | describe('Playwright web page accessibility test', () => { 11 | each([ 12 | [ 13 | 'on page with detectable accessibility issues', 14 | `file://${process.cwd()}/test/site.html`, 15 | ], 16 | [ 17 | 'on page with no detectable accessibility issues', 18 | `file://${process.cwd()}/test/site-no-accessibility-issues.html`, 19 | ], 20 | ]).it('check a11y %s', async (description, site) => { 21 | const log = jest.spyOn(global.console, 'log') 22 | 23 | browser = await chromium.launch({ args: ['--no-sandbox'] }) 24 | page = await browser.newPage() 25 | await page.goto(site) 26 | await injectAxe(page) 27 | await checkA11y( 28 | page, 29 | 'form', 30 | { 31 | axeOptions: { 32 | runOnly: { 33 | type: 'tag', 34 | values: ['wcag2a'], 35 | }, 36 | }, 37 | }, 38 | true, 39 | ) 40 | 41 | // condition to check console logs for both the cases 42 | expect(log).toHaveBeenCalledWith( 43 | expect.stringMatching(/(accessibility|impact)/i), 44 | ) 45 | }) 46 | 47 | afterEach(async () => { 48 | await browser.close() 49 | }) 50 | }) 51 | 52 | describe('Playwright web page accessibility test using reporter v2', () => { 53 | each([ 54 | [ 55 | 'on page with detectable accessibility issues', 56 | `file://${process.cwd()}/test/site.html`, 57 | ], 58 | [ 59 | 'on page with no detectable accessibility issues', 60 | `file://${process.cwd()}/test/site-no-accessibility-issues.html`, 61 | ], 62 | ]).it('check a11y %s', async (description, site) => { 63 | try { 64 | browser = await chromium.launch({ args: ['--no-sandbox'] }) 65 | page = await browser.newPage() 66 | await page.goto(site) 67 | await injectAxe(page) 68 | await checkA11y( 69 | page, 70 | 'form', 71 | { 72 | axeOptions: { 73 | runOnly: { 74 | type: 'tag', 75 | values: ['wcag2a'], 76 | }, 77 | }, 78 | }, 79 | true, 80 | 'v2', 81 | ) 82 | description === 'on page with detectable accessibility issues' 83 | ? expect.assertions(1) 84 | : expect.assertions(0) 85 | } catch (e) { 86 | console.log(e) 87 | } 88 | }) 89 | 90 | afterEach(async () => { 91 | await browser.close() 92 | }) 93 | }) 94 | 95 | describe('Playwright web page accessibility test using verbose false on default reporter', () => { 96 | each([ 97 | [ 98 | 'on page with no detectable accessibility issues', 99 | `file://${process.cwd()}/test/site-no-accessibility-issues.html`, 100 | ], 101 | ]).it('check a11y %s', async (description, site) => { 102 | const log = jest.spyOn(global.console, 'log') 103 | 104 | browser = await chromium.launch({ args: ['--no-sandbox'] }) 105 | page = await browser.newPage() 106 | await page.goto(site) 107 | await injectAxe(page) 108 | await checkA11y( 109 | page, 110 | 'form', 111 | { 112 | axeOptions: { 113 | runOnly: { 114 | type: 'tag', 115 | values: ['wcag2a'], 116 | }, 117 | }, 118 | verbose: false, 119 | }, 120 | true, 121 | ) 122 | expect(log).toHaveBeenCalledWith( 123 | expect.not.stringMatching(/accessibility/i), 124 | ) 125 | }) 126 | 127 | afterEach(async () => { 128 | await browser.close() 129 | }) 130 | }) 131 | 132 | describe('Playwright web page accessibility test using verbose true on reporter v2', () => { 133 | each([ 134 | [ 135 | 'on page with no detectable accessibility issues', 136 | `file://${process.cwd()}/test/site-no-accessibility-issues.html`, 137 | ], 138 | ]).it('check a11y %s', async (description, site) => { 139 | const log = jest.spyOn(global.console, 'log') 140 | 141 | browser = await chromium.launch({ args: ['--no-sandbox'] }) 142 | page = await browser.newPage() 143 | await page.goto(site) 144 | await injectAxe(page) 145 | await checkA11y( 146 | page, 147 | 'form', 148 | { 149 | axeOptions: { 150 | runOnly: { 151 | type: 'tag', 152 | values: ['wcag2a'], 153 | }, 154 | }, 155 | verbose: true, 156 | }, 157 | true, 158 | 'v2', 159 | ) 160 | 161 | expect(log).toHaveBeenCalledWith(expect.stringMatching(/accessibility/i)) 162 | }) 163 | 164 | afterEach(async () => { 165 | await browser.close() 166 | }) 167 | }) 168 | 169 | describe('Playwright web page accessibility test using generated html report with custom path', () => { 170 | each([ 171 | [ 172 | 'on page with detectable accessibility issues', 173 | `file://${process.cwd()}/test/site.html`, 174 | ], 175 | ]).it('check a11y %s', async (description, site) => { 176 | const log = jest.spyOn(global.console, 'log') 177 | 178 | browser = await chromium.launch({ args: ['--no-sandbox'] }) 179 | page = await browser.newPage() 180 | await page.goto(site) 181 | await injectAxe(page) 182 | await checkA11y( 183 | page, 184 | 'form', 185 | { 186 | axeOptions: { 187 | runOnly: { 188 | type: 'tag', 189 | values: ['wcag2a'], 190 | }, 191 | }, 192 | }, 193 | false, 194 | 'html', 195 | { 196 | outputDirPath: 'results', 197 | outputDir: 'accessibility', 198 | reportFileName: 'accessibility-audit.html', 199 | }, 200 | ) 201 | 202 | expect(log).toHaveBeenCalledWith( 203 | expect.stringMatching(/(accessibility|impact)/i), 204 | ) 205 | 206 | expect( 207 | fs.existsSync( 208 | path.join( 209 | process.cwd(), 210 | 'results', 211 | 'accessibility', 212 | 'accessibility-audit.html', 213 | ), 214 | ), 215 | ).toBe(true); 216 | }) 217 | 218 | afterEach(async () => { 219 | await browser.close() 220 | }) 221 | }) 222 | 223 | describe('Playwright web page accessibility test using junit reporter', () => { 224 | each([ 225 | [ 226 | 'on page with no detectable accessibility issues', 227 | `file://${process.cwd()}/test/site-no-accessibility-issues.html`, 228 | ], 229 | ]).it('check a11y %s', async (description, site) => { 230 | const log = jest.spyOn(global.console, 'log') 231 | 232 | browser = await chromium.launch({ args: ['--no-sandbox'] }) 233 | page = await browser.newPage() 234 | await page.goto(site) 235 | await injectAxe(page) 236 | await checkA11y( 237 | page, 238 | 'form', 239 | { 240 | axeOptions: { 241 | runOnly: { 242 | type: 'tag', 243 | values: ['wcag2a'], 244 | }, 245 | }, 246 | }, 247 | false, 248 | 'junit', 249 | { 250 | outputDirPath: 'results', 251 | outputDir: 'accessibility', 252 | reportFileName: 'accessibility-audit.xml', 253 | }, 254 | ) 255 | 256 | 257 | expect( 258 | fs.existsSync( 259 | path.join( 260 | process.cwd(), 261 | 'results', 262 | 'accessibility', 263 | 'accessibility-audit.xml', 264 | ), 265 | ), 266 | ).toBe(true); 267 | }) 268 | 269 | afterEach(async () => { 270 | await browser.close() 271 | //fs.unlinkSync('a11y-tests.xml') 272 | }) 273 | }) 274 | -------------------------------------------------------------------------------- /test/site-no-accessibility-issues.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Login page 5 | 6 | 7 |

Simple Login Page

8 |
9 | 10 | 11 | 12 | 13 |
14 | 15 | 16 | -------------------------------------------------------------------------------- /test/site.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Login page 5 | 6 | 7 |

Simple Login Page

8 |
9 | Username 10 | Password 11 | 12 | 13 |
14 | 15 | 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "commonjs", 5 | "strict": true, 6 | "declaration": true, 7 | "rootDir": "src", 8 | "moduleResolution": "node", 9 | "typeRoots": ["node_modules/@types"], 10 | "esModuleInterop": true, 11 | "outDir": "dist", 12 | "noImplicitAny": false, 13 | "types": ["node", "jest"] 14 | }, 15 | "exclude": ["node_modules"], 16 | "include": ["src/"] 17 | } 18 | --------------------------------------------------------------------------------