├── .github └── workflows │ └── python.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── .vscode └── settings.json ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── axe_playwright_python ├── __init__.py ├── async_playwright.py ├── axe.min.js ├── base.py ├── sync_playwright.py └── violations.txt ├── docs ├── api.md ├── index.md └── usage.md ├── examples └── usage.py ├── mkdocs.yml ├── pyproject.toml └── tests ├── __init__.py ├── test_axe_async_playwright.py ├── test_axe_sync_playwright.py ├── test_page.html ├── test_report.txt ├── test_report_color.txt ├── test_report_save.py └── test_result.json /.github/workflows/python.yaml: -------------------------------------------------------------------------------- 1 | name: Python checks 2 | 3 | on: 4 | push: 5 | branches: [ main, master ] 6 | pull_request: 7 | branches: [ main, master ] 8 | 9 | jobs: 10 | build: 11 | name: Test with Python ${{ matrix.python_version }} 12 | runs-on: ubuntu-latest 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | python_version: ["3.8", "3.9", "3.10", "3.11"] 17 | steps: 18 | - uses: actions/checkout@v3 19 | - name: Set up Python 3 20 | uses: actions/setup-python@v3 21 | with: 22 | python-version: ${{ matrix.python_version }} 23 | - name: Install dependencies 24 | run: | 25 | python -m pip install --upgrade pip 26 | python3 -m pip install -e '.[dev]' 27 | - name: Lint with ruff 28 | run: ruff . 29 | - name: Check formatting with black 30 | run: black . --check --verbose 31 | - name: Run unit tests 32 | run: | 33 | playwright install --with-deps 34 | python3 -m pytest 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.4.0 4 | hooks: 5 | - id: check-yaml 6 | - id: end-of-file-fixer 7 | - id: trailing-whitespace 8 | - repo: https://github.com/astral-sh/ruff-pre-commit 9 | rev: v0.0.280 10 | hooks: 11 | - id: ruff 12 | - repo: https://github.com/psf/black 13 | rev: 23.1.0 14 | hooks: 15 | - id: black 16 | args: ['--config=./pyproject.toml'] 17 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.linting.flake8Enabled": true, 3 | "python.linting.enabled": true 4 | } -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to contribute 2 | 3 | ## Development 4 | 5 | Install the project dependencies: 6 | 7 | ```sh 8 | python3 -m pip install -e '.[dev]' 9 | playwright install --with-deps 10 | pre-commit install 11 | ``` 12 | 13 | Run the tests: 14 | 15 | ```sh 16 | python3 -m pytest 17 | ``` 18 | 19 | ## Publishing 20 | 21 | Publish to PyPi: 22 | 23 | ```sh 24 | flit publish 25 | ``` 26 | 27 | ## Documentation 28 | 29 | Build locally: 30 | 31 | ```sh 32 | mkdocs serve 33 | ``` 34 | 35 | Deploy to GitHub Pages: 36 | 37 | ```sh 38 | mkdocs gh-deploy 39 | ``` 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Ruslan Iskov, Pamela Fox 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 | # axe-playwright-python 2 | 3 | Automated web accessibility testing using [axe-core](https://github.com/dequelabs/axe-core) engine 4 | and [Playwright](https://playwright.dev/python/docs/intro). 5 | 6 | ## Documentation 7 | 8 | - [Full documentation](https://pamelafox.github.io/axe-playwright-python/). 9 | 10 | ### Further Reading & More 11 | - ["Automated accessibility audits" - Pamela Fox (North Bay Python 2023)](https://www.youtube.com/watch?v=J-4Qa6PSomM&pp=ygUUcGFtZWxhIGZveCBub3J0aCBiYXk%3D) 12 | - [Using Python to Fix my Accessibility Night of a Website](https://kjaymiller.com/blog/using-python-to-fix-my-accessibility-nightmare-of-a-website.html) & [Website Accessibility Audit Reports via GH Actions](https://kjaymiller.com/blog/website-accessibility-audit-reports-via-gh-actions.html) by [Jay Miller](https://github.com/kjaymiller) 13 | 14 | 15 | ## Dependencies 16 | 17 | - Python >= 3.10 18 | - [playwright](https://github.com/microsoft/playwright-python) >= 1.25.0 19 | 20 | ## Installation 21 | 22 | ```console 23 | python3 -m pip install -U axe-playwright-python 24 | python3 -m playwright install --with-deps 25 | ``` 26 | 27 | ## Usage 28 | 29 | ```python 30 | from playwright.sync_api import sync_playwright 31 | from axe_playwright_python.sync_playwright import Axe 32 | 33 | axe = Axe() 34 | 35 | with sync_playwright() as playwright: 36 | browser = playwright.chromium.launch() 37 | page = browser.new_page() 38 | page.goto("https://www.google.com") 39 | results = axe.run(page) 40 | browser.close() 41 | 42 | print(f"Found {results.violations_count} violations.") 43 | print(f"Full axe-core response: {results.response}") 44 | ``` 45 | 46 | For more examples see [documentation](https://pamelafox.github.io/axe-playwright-python/). 47 | 48 | ## Contributing 49 | 50 | See [guide on contributing](CONTRIBUTING.md). 51 | 52 | ## Acknowledgments 53 | 54 | This project is based on [axe-core-python](https://github.com/ruslan-rv-ua/axe-core-python) by @ruslan-rv-ua and also takes inspiration from [axe-selenium-python](https://pypi.org/project/axe-selenium-python/) for output formats. 55 | -------------------------------------------------------------------------------- /axe_playwright_python/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pamelafox/axe-playwright-python/6a5c922ac6b4ed221186d0b4d36ad6b37097b8d0/axe_playwright_python/__init__.py -------------------------------------------------------------------------------- /axe_playwright_python/async_playwright.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from .base import AxeBase, AxeResults 4 | 5 | DEFAULT_OPTIONS = {"resultTypes": ["violations"]} 6 | 7 | 8 | class Axe(AxeBase): 9 | async def run( 10 | self, 11 | page, 12 | context: str | list | dict | None = None, 13 | options: dict | None = DEFAULT_OPTIONS, 14 | ) -> AxeResults: 15 | """Asynchronously run axe accessibility checks against webpage. 16 | 17 | Args: 18 | page (playwright.async_api.Page): Page object 19 | context (str | list | dict | None, optional): context. 20 | Defaults to None. 21 | options (dict | None, optional): options. 22 | Defaults to {"resultTypes": ["violations"]} 23 | 24 | For more information on `context` and `options`, 25 | view the [axe-core documentation](). 26 | 27 | Returns: 28 | dict: test result 29 | """ 30 | 31 | # inject `Axe` into document 32 | await page.evaluate(self.axe_script) 33 | 34 | # Run `Axe` against the current page 35 | args_str = self._format_script_args(context=context, options=options) 36 | command_template = "axe.run(%s).then(results => {return results;})" 37 | command = command_template % args_str 38 | response = await page.evaluate(command) 39 | results = AxeResults(response) 40 | return results 41 | -------------------------------------------------------------------------------- /axe_playwright_python/base.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | import os 5 | from abc import ABC, abstractmethod 6 | from pathlib import Path 7 | from string import Template 8 | 9 | AXE_FILE_NAME = "axe.min.js" 10 | AXE_FILE_PATH = Path(__file__).parent / AXE_FILE_NAME 11 | 12 | AXE_SCRIPT = AXE_FILE_PATH.read_text() 13 | 14 | 15 | class AxeBase(ABC): 16 | """Abstract base class.""" 17 | 18 | def __init__(self, axe_script: str = AXE_SCRIPT) -> None: 19 | """ 20 | Args: 21 | axe_script (str, optional): `axe.js` or `axe.min.js` javascript. 22 | Defaults to AXE_SCRIPT. 23 | """ 24 | self.axe_script = axe_script 25 | 26 | @staticmethod 27 | def _format_script_args( 28 | context: str | list | dict | None = None, options: dict | None = None 29 | ) -> str: 30 | args_list = [] 31 | # If context is passed, add to args 32 | if context: 33 | args_list.append(repr(context)) 34 | # If options is passed, add to args 35 | if options: 36 | args_list.append(str(options)) 37 | # Add comma delimiter only if both parameters are passed 38 | args = ",".join(args_list) 39 | 40 | return args 41 | 42 | @abstractmethod 43 | def run(self): 44 | pass 45 | 46 | @classmethod 47 | def from_file(cls, axe_min_js_path: str | Path) -> AxeBase: 48 | """Load axe script from file and create Axe instance. 49 | 50 | Args: 51 | axe_min_js_path (str | Path): path to `axe.js` or `axe.min.js` 52 | 53 | Returns: 54 | AxeBase: Axe instance 55 | """ 56 | axe_script = Path(axe_min_js_path).read_text(encoding="UTF-8") 57 | return cls(axe_script=axe_script) 58 | 59 | 60 | class AxeResults: 61 | def __init__(self, response: dict): 62 | self.response = response 63 | 64 | @property 65 | def violations_count(self) -> int: 66 | """ 67 | Number of violations found. 68 | """ 69 | return len(self.response["violations"]) 70 | 71 | def generate_snapshot(self): 72 | """ 73 | Return snapshot of violations, for use in snapshot testing. 74 | Format: 75 | (impact) : 76 | """ 77 | snapshot_lines = [] 78 | for v in self.response["violations"]: 79 | snapshot_lines.append(f"{v['id']} ({v['impact']}) : {len(v['nodes'])}") 80 | return "\n".join(snapshot_lines) 81 | 82 | def __violation_report(self, violation: dict, template: Template) -> str: 83 | nodes_str = "" 84 | for num, node in enumerate(violation["nodes"], start=1): 85 | targets = ", ".join(node["target"]) 86 | nodes_str += f"\n\n\t{num})\tTarget: {targets}" 87 | snippet = node.get("html").replace("\n", "") 88 | nodes_str += f"\n\t\tSnippet: {snippet}" 89 | nodes_str += "\n\t\tMessages:" 90 | for item in node.get("all", []) + node.get("any", []) + node.get("none", []): 91 | nodes_str += "\n\t\t* " + item["message"] 92 | return template.substitute(violation, elements=nodes_str) 93 | 94 | def generate_report(self, violation_id: str | None = None) -> str: 95 | """ 96 | Return readable report of accessibility violations found. 97 | Defaults to all violations, but can be filtered by violation ID. 98 | @param violation_id: Violation ID to report on 99 | """ 100 | violations = self.response["violations"] 101 | report_str = "" 102 | if violation_id is None: 103 | report_str += f"Found {len(violations)} accessibility violations:\n" 104 | tmpl_f = open(os.path.join(os.path.dirname(__file__), "violations.txt")) 105 | template = Template(tmpl_f.read()) 106 | tmpl_f.close() 107 | for violation in violations: 108 | if violation_id is not None and violation_id != violation["id"]: 109 | continue 110 | report_str += self.__violation_report(violation, template) 111 | return report_str 112 | 113 | def save_to_file( 114 | self, file_path: str | Path | None = None, violations_only: bool = False 115 | ) -> None: 116 | """Save results to file. 117 | @param results: Results from Axe analysis 118 | @param file_path: File path for saving results file 119 | """ 120 | # create a copy of the dict 121 | response = self.response.copy() 122 | if violations_only: 123 | del response["inapplicable"] 124 | del response["incomplete"] 125 | del response["passes"] 126 | if file_path is None: 127 | cwd = Path.cwd() 128 | file_path = cwd / "results.json" 129 | Path(file_path).write_text(json.dumps(response, indent=4)) 130 | -------------------------------------------------------------------------------- /axe_playwright_python/sync_playwright.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from .base import AxeBase, AxeResults 4 | 5 | DEFAULT_OPTIONS = {"resultTypes": ["violations"]} 6 | 7 | 8 | class Axe(AxeBase): 9 | def run( 10 | self, 11 | page, 12 | context: str | list | dict | None = None, 13 | options: dict | None = DEFAULT_OPTIONS, 14 | ) -> AxeResults: 15 | """Run axe accessibility checks against webpage. 16 | 17 | Args: 18 | page (playwright.sync_api._generated.Page): Page object 19 | context (str | list | dict | None, optional): context. 20 | Defaults to None. 21 | options (dict | None, optional): options. 22 | Defaults to {"resultTypes": ["violations"]}. 23 | 24 | For more information on `context` and `options`, 25 | view the [axe-core documentation](). 26 | 27 | Returns: 28 | dict: test result 29 | """ 30 | 31 | # inject `Axe` into document 32 | page.evaluate(self.axe_script) 33 | 34 | # Run `Axe` against the current page 35 | args_str = self._format_script_args(context=context, options=options) 36 | command_template = "axe.run(%s).then(results => {return results;})" 37 | command = command_template % args_str 38 | response = page.evaluate(command) 39 | results = AxeResults(response) 40 | return results 41 | -------------------------------------------------------------------------------- /axe_playwright_python/violations.txt: -------------------------------------------------------------------------------- 1 | Rule Violated: 2 | $id - $description 3 | URL: $helpUrl 4 | Impact Level: $impact 5 | Tags: $tags 6 | Elements Affected: 7 | $elements 8 | -------------------------------------------------------------------------------- /docs/api.md: -------------------------------------------------------------------------------- 1 | ## ::: base.AxeBase 2 | 3 | ## ::: base.AxeResults 4 | 5 | ## ::: sync_playwright.Axe 6 | 7 | ## ::: async_playwright.Axe 8 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # axe-playwright-python 2 | 3 | Automated web accessibility testing using [axe-core](https://github.com/dequelabs/axe-core) engine 4 | and [Playwright](https://playwright.dev/python/docs/intro). 5 | 6 | ## Requirements 7 | 8 | You will need the following prerequisites in order to use this library: 9 | 10 | - Python >= 3.10 11 | - [playwright](https://github.com/microsoft/playwright-python) >= 1.25.0 12 | 13 | ## Installation 14 | 15 | ```console 16 | python3 -m pip install -U axe-playwright-python 17 | ``` 18 | 19 | ### Playwright 20 | 21 | To install Playwright ([docs](https://playwright.bootcss.com/python/docs/installation)): 22 | 23 | ```console 24 | python3 -m pip install playwright 25 | ``` 26 | 27 | Then install browser binaries: 28 | 29 | ```console 30 | python3 -m playwright install --with-deps 31 | ``` 32 | -------------------------------------------------------------------------------- /docs/usage.md: -------------------------------------------------------------------------------- 1 | 2 | The first step is to run `axe.run()` on a Playwright `Page` object 3 | 4 | ```python 5 | from playwright.sync_api import sync_playwright 6 | from axe_playwright_python.sync_playwright import Axe 7 | 8 | axe = Axe() 9 | 10 | with sync_playwright() as playwright: 11 | browser = playwright.chromium.launch() 12 | page = browser.new_page() 13 | page.goto("https://www.google.com") 14 | results = axe.run(page) 15 | browser.close() 16 | ``` 17 | 18 | The `results` object is an instance of `AxeResults`, 19 | and we can work with it in several ways. 20 | 21 | ### Count the number of violations 22 | 23 | ```python 24 | print(f"Found {results.violations_count} violations.") 25 | ``` 26 | 27 | ```console 28 | Found 6 violations. 29 | ``` 30 | 31 | ### Generate a human-readable report of violations 32 | 33 | ```python 34 | print(results.generate_report()) 35 | ``` 36 | 37 | ```console 38 | Found 6 accessibility violations: 39 | Rule Violated: 40 | aria-allowed-role - Ensures role attribute has an appropriate value for the element 41 | URL: https://dequeuniversity.com/rules/axe/4.4/aria-allowed-role?application=axeAPI 42 | Impact Level: minor 43 | Tags: ['cat.aria', 'best-practice'] 44 | Elements Affected: 45 | 46 | 47 | 1) Target: #APjFqb 48 | Snippet: