Skip to content

Modernize Axe class implementation #202

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
202 changes: 123 additions & 79 deletions axe_selenium_python/axe.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,113 +2,157 @@
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.

from __future__ import annotations

import json
import os
import logging
from pathlib import Path

logger = logging.getLogger(__name__)

_DEFAULT_SCRIPT = os.path.join(
os.path.dirname(__file__), "node_modules", "axe-core", "axe.min.js"
)
_DEFAULT_SCRIPT = Path(__file__).parent / "node_modules" / "axe-core" / "axe.min.js"


class Axe:
def __init__(self, selenium, script_url=_DEFAULT_SCRIPT):
self.script_url = script_url
self.selenium = selenium
def __init__(
self, selenium_driver, script_url: str | Path = _DEFAULT_SCRIPT
) -> None:
"""
Initialize Axe instance.

def inject(self):
Args:
selenium_driver: Selenium WebDriver instance
script_url: Path to axe-core JavaScript file

Raises:
FileNotFoundError: If script file doesn't exist
"""
Recursively inject aXe into all iframes and the top level document.
self.script_url = Path(script_url)
self.selenium = selenium_driver
self._is_injected = False

if not self.script_url.exists():
msg = f"Axe script not found at: {self.script_url}"
raise FileNotFoundError(msg)

:param script_url: location of the axe-core script.
:type script_url: string
def inject(self) -> None:
"""
with open(self.script_url, encoding="utf8") as f:
self.selenium.execute_script(f.read())
Inject axe-core script into the current page and all iframes.

Raises:
RuntimeError: If injection fails
"""
try:
script_content = self.script_url.read_text(encoding="utf-8")
self.selenium.execute_script(script_content)
self._is_injected = True
logger.info("Axe-core successfully injected into page")
except Exception as e:
msg = f"Failed to inject axe-core script: {e}"
raise RuntimeError(msg) from e

def run(self, context=None, options=None):
"""
Run axe against the current page.

:param context: which page part(s) to analyze and/or what to exclude.
:param options: dictionary of aXe options.
Args:
context: which page part(s) to analyze and/or what to exclude.
options: dictionary of aXe options.

Raises:
RuntimeError: If axe is not injected or analysis fails
"""
template = (
"var callback = arguments[arguments.length - 1];"
+ "axe.run(%s).then(results => callback(results))"
)
args = ""

# If context parameter is passed, add to args
if not self._is_injected:
msg = "Axe not injected. Call inject() first."
raise RuntimeError(msg)

args = []
if context is not None:
args += "%r" % context
# Add comma delimiter only if both parameters are passed
if context is not None and options is not None:
args += ","
# If options parameter is passed, add to args
args.append(json.dumps(context))
if options is not None:
args += "%s" % options
if context is None:
args.append("document")
args.append(json.dumps(options))

command = template % args
response = self.selenium.execute_async_script(command)
return response
js_args = ", ".join(args) if args else ""

command = f"""
var callback = arguments[arguments.length - 1];
axe.run({js_args})
.then(results => callback(results))
.catch(error => callback({{error: error.message}}));
"""

try:
response = self.selenium.execute_async_script(command)

if "error" in response:
msg = f"Axe analysis failed: {response['error']}"
raise RuntimeError(msg)

return response
except Exception as e:
msg = f"Failed to execute axe analysis: {e}"
raise RuntimeError(msg) from e

def report(self, violations):
"""
Return readable report of accessibility violations found.

:param violations: Dictionary of violations.
:type violations: dict
:return report: Readable report of violations.
:rtype: string
Args:
violations: List of violations from axe results.

Returns:
Formatted string report.
"""
string = ""
string += "Found " + str(len(violations)) + " accessibility violations:"
for violation in violations:
string += (
"\n\n\nRule Violated:\n"
+ violation["id"]
+ " - "
+ violation["description"]
+ "\n\tURL: "
+ violation["helpUrl"]
+ "\n\tImpact Level: "
+ violation["impact"]
+ "\n\tTags:"
)
for tag in violation["tags"]:
string += " " + tag
string += "\n\tElements Affected:"
i = 1
for node in violation["nodes"]:
for target in node["target"]:
string += "\n\t" + str(i) + ") Target: " + target
i += 1
for item in node["all"]:
string += "\n\t\t" + item["message"]
for item in node["any"]:
string += "\n\t\t" + item["message"]
for item in node["none"]:
string += "\n\t\t" + item["message"]
string += "\n\n\n"
return string
if not violations:
return "No accessibility violations found!"

lines = [f"Found {len(violations)} accessibility violations:"]

for i, violation in enumerate(violations, 1):
lines.append(f"\n{i}. Rule: {violation['id']} - {violation['description']}")
lines.append(f" Impact Level: {violation['impact']}")
lines.append(f" Help URL: {violation['helpUrl']}")
lines.append(f" Tags: {', '.join(violation['tags'])}")
lines.append(" Elements Affected:")

for node_idx, node in enumerate(violation["nodes"], 1):
lines.extend(
f" {node_idx}) Target: {target}" for target in node["target"]
)

for message_list in [
node.get("all", []),
node.get("any", []),
node.get("none", []),
]:
lines.extend(
f" - {item['message']}"
for item in message_list
if item.get("message")
)

lines.append("")

return "\n".join(lines)

def write_results(self, data, name=None):
"""
Write JSON to file with the specified name.

:param name: Path to the file to be written to. If no path is passed
a new JSON file "results.json" will be created in the
current working directory.
:param output: JSON object.
param name: Path to the file to be written to. If no path is passed
a new JSON file "results.json" will be created in the
current working directory.
:param data: JSON object.
"""

if name:
filepath = os.path.abspath(name)
else:
filepath = os.path.join(os.path.getcwd(), "results.json")

with open(filepath, "w", encoding="utf8") as f:
try:
f.write(json.dumps(data, indent=4))
except NameError:
f.write(json.dumps(data, indent=4))
filepath = Path(name).resolve() if name else Path.cwd() / "results.json"

try:
with open(filepath, "w", encoding="utf-8") as f:
json.dump(data, f, indent=4)
logger.info("Results saved to: %s", filepath)
except Exception as e:
msg = f"Failed to save results to {filepath}: {e}"
raise OSError(msg) from e