Source code for bok_choy.a11y.axe_core_ruleset

"""
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 format_errors(errors): """ Args: errors: results of `AxeCoreAudit.get_errors()`. Returns: The errors as a formatted string. """ def _get_message(node): """ Get the message to display in the error output. """ messages = set() try: messages.update([node['message']]) except KeyError: pass for check_group in ['any', 'all', 'none']: try: for check in node[check_group]: messages.update([check.get('message')]) except KeyError: pass messages = messages.difference(['']) return '; '.join(messages) lines = [] for error_type in errors: lines.append(f"Severity: {error_type.get('impact')}") lines.append(f"Rule ID: {error_type.get('id')}") lines.append(f"Help URL: {error_type.get('helpUrl')}\n") for node in error_type['nodes']: msg = f"Message: {_get_message(node)}" html = f"Html: {node.get('html').encode('utf-8')}" target = f"Target: {node.get('target')}" fill_opts = { 'width': 100, 'initial_indent': '\t', 'subsequent_indent': '\t\t', } lines.append(fill(msg, **fill_opts)) lines.append(fill(html, **fill_opts)) lines.append(fill(target, **fill_opts)) lines.append('\n') return '\n'.join(lines)
[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)