"""
Interface for using the axe-core ruleset.
See: https://github.com/dequelabs/axe-core
"""
import json
import os
from textwrap import dedent, fill
from .a11y_audit import A11yAudit, A11yAuditConfig, AccessibilityError, A11yAuditConfigError
from ..promise import Promise
CUR_DIR = os.path.dirname(os.path.abspath(__file__))
[docs]class AxeCoreAuditConfig(A11yAuditConfig):
"""
The `AxeCoreAuditConfig` object defines the options available when
running an `AxeCoreAudit`.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.rules, self.context = None, None
self.custom_rules = "customRules={}"
self.rules_file = os.path.join(
os.path.split(CUR_DIR)[0],
'vendor/axe-core/axe.min.js'
)
self.set_rules({})
self.set_scope()
self.customize_ruleset()
[docs] def set_rules(self, rules):
"""
Set rules to ignore XOR limit to when checking for accessibility
errors on the page.
Args:
rules: a dictionary one of the following formats.
If you want to run all of the rules except for some::
{"ignore": []}
If you want to run only a specific set of rules::
{"apply": []}
If you want to run only rules of a specific standard::
{"tags": []}
Examples:
To run only "bad-link" and "color-contrast" rules::
page.a11y_audit.config.set_rules({
"apply": ["bad-link", "color-contrast"],
})
To run all rules except for "bad-link" and "color-contrast"::
page.a11y_audit.config.set_rules({
"ignore": ["bad-link", "color-contrast"],
})
To run only WCAG 2.0 Level A rules::
page.a11y_audit.config.set_rules({
"tags": ["wcag2a"],
})
To run all rules:
page.a11y_audit.config.set_rules({})
Related documentation:
* https://github.com/dequelabs/axe-core/blob/master/doc/API.md#options-parameter-examples
* https://github.com/dequelabs/axe-core/doc/rule-descriptions.md
"""
options = {}
if rules:
if rules.get("ignore"):
options["rules"] = {}
for rule in rules.get("ignore"):
options["rules"][rule] = {"enabled": False}
elif rules.get("apply"):
options["runOnly"] = {
"type": "rule",
"values": rules.get("apply"),
}
elif rules.get("tags"):
options["runOnly"] = {
"type": "tag",
"values": rules.get("tags"),
}
self.rules = json.dumps(options)
[docs] def set_scope(self, include=None, exclude=None):
"""
Sets `scope` (refered to as `context` in ruleset documentation), which
defines the elements on a page to include or exclude in the audit. If
neither `include` nor `exclude` are passed, the entire document will
be included.
Args:
include (optional): a list of css selectors for elements that
should be included in the audit. By, default, the entire document
is included.
exclude (optional): a list of css selectors for elements that should not
be included in the audit.
Examples:
To include all items in `#main-content` except `#some-special-elm`::
page.a11y_audit.config.set_scope(
exclude=["#some-special-elm"],
include=["#main-content"]
)
To include all items in the document except `#some-special-elm`::
page.a11y_audit.config.set_scope(
exclude=["#some-special-elm"],
)
To include only children of `#some-special-elm`::
page.a11y_audit.config.set_scope(
include=["#some-special-elm"],
)
Context documentation:
https://github.com/dequelabs/axe-core/blob/master/doc/API.md#a-context-parameter
Note that this implementation only supports css selectors. It does
not accept nodes as described in the above documentation resource.
"""
context = {}
if exclude:
context["exclude"] = [[selector] for selector in exclude]
if include:
context["include"] = [[selector] for selector in include]
self.context = json.dumps(context) if context else 'document'
[docs] def customize_ruleset(self, custom_ruleset_file=None):
"""
Updates the ruleset to include a set of custom rules. These rules will
be _added_ to the existing ruleset or replace the existing rule with
the same ID.
Args:
custom_ruleset_file (optional): The filepath to the custom rules.
Defaults to `None`. If `custom_ruleset_file` isn't passed, the
environment variable `BOKCHOY_A11Y_CUSTOM_RULES_FILE` will be
checked. If a filepath isn't specified by either of these
methods, the ruleset will not be updated.
Raises:
`IOError` if the specified file does not exist.
Examples:
To include the rules defined in `axe-core-custom-rules.js`::
page.a11y_audit.config.customize_ruleset(
"axe-core-custom-rules.js"
)
Alternatively, use the environment variable `BOKCHOY_A11Y_CUSTOM_RULES_FILE`
to specify the path to the file containing the custom rules.
Documentation for how to write rules:
https://github.com/dequelabs/axe-core/blob/master/doc/developer-guide.md
An example of a custom rules file can be found at
https://github.com/openedx/bok-choy/tree/master/tests/a11y_custom_rules.js
"""
custom_file = custom_ruleset_file or os.environ.get(
"BOKCHOY_A11Y_CUSTOM_RULES_FILE"
)
if not custom_file:
return
with open(custom_file, encoding="utf8") as additional_rules:
custom_rules = additional_rules.read()
if "var customRules" not in custom_rules:
raise A11yAuditConfigError(
"Custom rules file must include \"var customRules\""
)
self.custom_rules = custom_rules
[docs]class AxeCoreAudit(A11yAudit):
"""
Use Deque Labs' axe-core engine to audit a page for accessibility issues.
Related documentation:
https://github.com/dequelabs/axe-core/blob/master/doc/API.md
"""
@property
def default_config(self):
"""
Returns an instance of AxeCoreAuditConfig.
"""
return AxeCoreAuditConfig()
@staticmethod
def _check_rules(browser, rules_js, config):
"""
Run an accessibility audit on the page using the axe-core ruleset.
Args:
browser: a browser instance.
rules_js: the ruleset JavaScript as a string.
config: an AxsAuditConfig instance.
Returns:
A list of violations.
Related documentation:
https://github.com/dequelabs/axe-core/blob/master/doc/API.md#results-object
__Caution__: You probably don't really want to call this method
directly! It will be used by `AxeCoreAudit.do_audit`.
"""
audit_run_script = dedent("""
{rules_js}
{custom_rules}
axe.configure(customRules);
var callback = function(err, results) {{
if (err) throw err;
window.a11yAuditResults = JSON.stringify(results);
window.console.log(window.a11yAuditResults);
}}
axe.run({context}, {options}, callback);
""").format(
rules_js=rules_js,
custom_rules=config.custom_rules,
context=config.context,
options=config.rules
)
audit_results_script = dedent("""
window.console.log(window.a11yAuditResults);
return window.a11yAuditResults;
""")
browser.execute_script(audit_run_script)
def audit_results_check_func():
"""
A method to check that the audit has completed.
Returns:
(True, results) if the results are available.
(False, None) if the results aren't available.
"""
unicode_results = browser.execute_script(audit_results_script)
try:
results = json.loads(unicode_results)
except (TypeError, ValueError):
results = None
if results:
return True, results
return False, None
result = Promise(
audit_results_check_func,
"Timed out waiting for a11y audit results.",
timeout=5,
).fulfill()
# audit_results is report of accessibility violations for that session
# Note that this ruleset doesn't have distinct error/warning levels.
audit_results = result.get('violations')
return audit_results
[docs] @staticmethod
def get_errors(audit_results):
"""
Args:
audit_results: results of `AxeCoreAudit.do_audit()`.
Returns:
A dictionary with keys "errors" and "total".
"""
errors = {"errors": [], "total": 0}
if audit_results:
errors["errors"].extend(audit_results)
for i in audit_results:
for _node in i["nodes"]:
errors["total"] += 1
return errors
[docs] @staticmethod
def report_errors(audit, url):
"""
Args:
audit: results of `AxeCoreAudit.do_audit()`.
url: the url of the page being audited.
Raises: `AccessibilityError`
"""
errors = AxeCoreAudit.get_errors(audit)
if errors["total"] > 0:
msg = f"URL '{url}' has {errors['total']} errors:\n\n{AxeCoreAudit.format_errors(errors['errors'])}"
raise AccessibilityError(msg)