MDL-67687 behat: Add axe web accessibility tool integration
[moodle.git] / lib / tests / behat / behat_accessibility.php
1 <?php
2 // This file is part of Moodle - http://moodle.org/
3 //
4 // Moodle is free software: you can redistribute it and/or modify
5 // it under the terms of the GNU General Public License as published by
6 // the Free Software Foundation, either version 3 of the License, or
7 // (at your option) any later version.
8 //
9 // Moodle is distributed in the hope that it will be useful,
10 // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 // GNU General Public License for more details.
13 //
14 // You should have received a copy of the GNU General Public License
15 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
17 /**
18  * Steps definitions to open and close action menus.
19  *
20  * @package    core
21  * @category   test
22  * @copyright  2020 Andrew Nicols <andrew@nicols.co.uk>
23  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
24  */
26 use Behat\Mink\Exception\{DriverException, ExpectationException};
28 // NOTE: no MOODLE_INTERNAL test here, this file may be required by behat before including /config.php.
30 require_once(__DIR__ . '/../../behat/behat_base.php');
32 /**
33  * Steps definitions to assist with accessibility testing.
34  *
35  * @package    core
36  * @category   test
37  * @copyright  2020 Andrew Nicols <andrew@nicols.co.uk>
38  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
39  */
40 class behat_accessibility extends behat_base {
42     /**
43      * Run the axe-core accessibility tests.
44      *
45      * There are standard tags to ensure WCAG 2.1 A, WCAG 2.1 AA, and Section 508 compliance.
46      * It is also possible to specify any desired optional tags.
47      *
48      * The list of available tags can be found at
49      * https://github.com/dequelabs/axe-core/blob/v3.5.5/doc/rule-descriptions.md.
50      *
51      * @Then the page should meet accessibility standards
52      * @Then the page should meet accessibility standards with :extratags extra tests
53      * @Then the page should meet :standardtags accessibility standards
54      * @param   string $standardtags Comma-separated list of standard tags to run
55      * @param   string $extratags Comma-separated list of tags to run in addition to the standard tags
56      */
57     public function run_axe_validation_for_tags(string $standardtags = '', string $extratags = ''): void {
58         $this->run_axe_for_tags(
59             // Turn the comma-separated string into an array of trimmed values, filtering out empty values.
60             array_filter(array_map('trim', explode(',', $standardtags))),
61             array_filter(array_map('trim', explode(',', $extratags)))
62         );
63     }
65     /**
66      * Run the Axe tests.
67      *
68      * See https://github.com/dequelabs/axe-core/blob/develop/doc/rule-descriptions.md for details of the supported
69      * tags.
70      *
71      * @param   array $standardtags The list of standard tags to run
72      * @param   array $extratags The list of tags, in addition to the standard tags, to run
73      */
74     protected function run_axe_for_tags(array $standardtags = [], array $extratags = []): void {
75         if (!$this->has_tag('accessibility')) {
76             throw new DriverException(
77                 'Accessibility tests using Axe must have the @accessibility tag on either the scenario or feature.'
78             );
79         }
81         $this->require_javascript();
83         $axeurl = (new \moodle_url('/lib/behat/axe/axe.min.js'))->out(false);
84         $axeconfig = $this->get_axe_config_for_tags($standardtags, $extratags);
85         $runaxe = <<<EOF
86 (axeurl => {
87     const runTests = () => {
88         const axeTag = document.querySelector('script[data-purpose="axe"]');
89         axeTag.dataset.results = null;
91         axe.run({$axeconfig})
92         .then(results => {
93             axeTag.dataset.results = JSON.stringify({
94                 violations: results.violations,
95                 exception: null,
96             });
97         })
98         .catch(exception => {
99             axeTag.dataset.results = JSON.stringify({
100                 violations: [],
101                 exception: exception,
102             });
103         });
104     };
106     if (document.querySelector('script[data-purpose="axe"]')) {
107         runTests();
108     } else {
109         // Inject the axe content.
110         const axeTag = document.createElement('script');
111         axeTag.src = axeurl,
112         axeTag.dataset.purpose = 'axe';
114         axeTag.onload = () => runTests();
115         document.head.append(axeTag);
116     }
117 })('{$axeurl}');
118 EOF;
120         $this->execute_script($runaxe);
122         $getresults = <<<EOF
123 return (() => {
124     const axeTag = document.querySelector('script[data-purpose="axe"]');
125     return axeTag.dataset.results;
126 })()
127 EOF;
129         for ($i = 0; $i < self::get_extended_timeout() * 10; $i++) {
130             $results = json_decode($this->evaluate_script($getresults));
131             if ($results) {
132                 break;
133             }
134         }
136         if (empty($results)) {
137             throw new \Exception('No data');
138         }
140         if ($results->exception !== null) {
141             throw new ExpectationException($results->exception, $this->session);
142         }
144         $violations = $results->violations;
145         if (!count($violations)) {
146             return;
147         }
149         $violationdata = "Accessibility violations found:\n";
150         foreach ($violations as $violation) {
151             $nodedata = '';
152             foreach ($violation->nodes as $node) {
153                 $failedchecks = [];
154                 foreach (array_merge($node->any, $node->all, $node->none) as $check) {
155                     $failedchecks[$check->id] = $check->message;
156                 }
158                 $nodedata .= sprintf(
159                     "    - %s:\n      %s\n\n",
160                     implode(', ', $failedchecks),
161                     implode("\n      ", $node->target)
162                 );
163             }
165             $violationdata .= sprintf(
166                 "  %.03d violations of '%s' (severity: %s)\n%s\n",
167                 count($violation->nodes),
168                 $violation->description,
169                 $violation->impact,
170                 $nodedata
171             );
172         }
174         throw new ExpectationException($violationdata, $this->getSession());
175     }
177     /**
178      * Get the configuration to use with Axe.
179      *
180      * See https://github.com/dequelabs/axe-core/blob/develop/doc/rule-descriptions.md for details of the rules.
181      *
182      * @param   array|null $standardtags The list of standard tags to run
183      * @param   array|null $extratags The list of tags, in addition to the standard tags, to run
184      * @return  string The JSON-encoded configuration.
185      */
186     protected function get_axe_config_for_tags(?array $standardtags = null, ?array $extratags = null): string {
187         if (empty($standardtags)) {
188             $standardtags = [
189                 // Meet WCAG 2.1 A requirements.
190                 'wcag2a',
192                 // Meet WCAG 2.1 AA requirements.
193                 'wcag2aa',
195                 // Meet Section 508 requirements.
196                 // See https://www.epa.gov/accessibility/what-section-508 for detail.
197                 'section508',
199                 // Ensure that ARIA attributes are correctly defined.
200                 'cat.aria',
202                 // Requiremetns for sensory and visual cues.
203                 // These largely related to viewport scale and zoom functionality.
204                 'cat.sensory-and-visual-cues',
206                 // Meet WCAG 1.3.4 requirements for orientation.
207                 // See https://www.w3.org/WAI/WCAG21/Understanding/orientation.html for detail.
208                 'wcag134',
209             ];
210         }
212         return json_encode([
213             'runOnly' => [
214                 'type' > 'tag',
215                 'values' => array_merge($standardtags, $extratags),
216             ],
217         ]);
218     }