| 1 | efrain | 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/>.
 | 
        
           |  |  | 16 |   | 
        
           | 1441 | ariadna | 17 | use Behat\Mink\Exception\DriverException;
 | 
        
           |  |  | 18 | use Behat\Mink\Exception\ExpectationException;
 | 
        
           |  |  | 19 | use Behat\Mink\Element\NodeElement;
 | 
        
           | 1 | efrain | 20 |   | 
        
           |  |  | 21 | // NOTE: no MOODLE_INTERNAL test here, this file may be required by behat before including /config.php.
 | 
        
           |  |  | 22 |   | 
        
           |  |  | 23 | require_once(__DIR__ . '/../../behat/behat_base.php');
 | 
        
           |  |  | 24 |   | 
        
           |  |  | 25 | /**
 | 
        
           |  |  | 26 |  * Steps definitions to assist with accessibility testing.
 | 
        
           |  |  | 27 |  *
 | 
        
           |  |  | 28 |  * @package    core
 | 
        
           |  |  | 29 |  * @category   test
 | 
        
           |  |  | 30 |  * @copyright  2020 Andrew Nicols <andrew@nicols.co.uk>
 | 
        
           |  |  | 31 |  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 | 
        
           |  |  | 32 |  */
 | 
        
           |  |  | 33 | class behat_accessibility extends behat_base {
 | 
        
           |  |  | 34 |     /**
 | 
        
           |  |  | 35 |      * Run the axe-core accessibility tests.
 | 
        
           |  |  | 36 |      *
 | 
        
           |  |  | 37 |      * There are standard tags to ensure WCAG 2.1 A, WCAG 2.1 AA, and Section 508 compliance.
 | 
        
           |  |  | 38 |      * It is also possible to specify any desired optional tags.
 | 
        
           |  |  | 39 |      *
 | 
        
           | 1441 | ariadna | 40 |      * See {@link https://github.com/dequelabs/axe-core/blob/v4.10.2/doc/rule-descriptions.md} for the list of available tags
 | 
        
           | 1 | efrain | 41 |      *
 | 
        
           |  |  | 42 |      * @Then the page should meet accessibility standards
 | 
        
           |  |  | 43 |      * @Then the page should meet accessibility standards with :extratags extra tests
 | 
        
           |  |  | 44 |      * @Then the page should meet :standardtags accessibility standards
 | 
        
           |  |  | 45 |      * @param   string $standardtags Comma-separated list of standard tags to run
 | 
        
           |  |  | 46 |      * @param   string $extratags Comma-separated list of tags to run in addition to the standard tags
 | 
        
           |  |  | 47 |      */
 | 
        
           |  |  | 48 |     public function run_axe_validation_for_tags(string $standardtags = '', string $extratags = ''): void {
 | 
        
           |  |  | 49 |         $this->run_axe_for_tags(
 | 
        
           |  |  | 50 |             // Turn the comma-separated string into an array of trimmed values, filtering out empty values.
 | 
        
           |  |  | 51 |             array_filter(array_map('trim', explode(',', $standardtags))),
 | 
        
           |  |  | 52 |             array_filter(array_map('trim', explode(',', $extratags)))
 | 
        
           |  |  | 53 |         );
 | 
        
           |  |  | 54 |     }
 | 
        
           |  |  | 55 |   | 
        
           |  |  | 56 |     /**
 | 
        
           | 1441 | ariadna | 57 |      * Run the axe-core accessibility tests for a page region.
 | 
        
           |  |  | 58 |      *
 | 
        
           |  |  | 59 |      * There are standard tags to ensure WCAG 2.1 A, WCAG 2.1 AA, and Section 508 compliance.
 | 
        
           |  |  | 60 |      * It is also possible to specify any desired optional tags.
 | 
        
           |  |  | 61 |      *
 | 
        
           |  |  | 62 |      * See {@link https://github.com/dequelabs/axe-core/blob/v4.10.0/doc/rule-descriptions.md} for the list of available tags
 | 
        
           |  |  | 63 |      *
 | 
        
           |  |  | 64 |      * @Then the :element :selector should meet accessibility standards
 | 
        
           |  |  | 65 |      * @Then the :element :selector should meet accessibility standards with :extratags extra tests
 | 
        
           |  |  | 66 |      * @Then the :element :selector should meet :standardtags accessibility standards
 | 
        
           |  |  | 67 |      * @param  string $element The element to run the tests on
 | 
        
           |  |  | 68 |      * @param  string $selector The selector to use to find the element
 | 
        
           |  |  | 69 |      * @param  string $standardtags Comma-separated list of standard tags to run
 | 
        
           |  |  | 70 |      * @param  string $extratags Comma-separated list of tags to run in addition to the standard tags
 | 
        
           |  |  | 71 |      */
 | 
        
           |  |  | 72 |     public function run_axe_validation_for_tags_within_element(
 | 
        
           |  |  | 73 |         string $element,
 | 
        
           |  |  | 74 |         string $selector,
 | 
        
           |  |  | 75 |         string $standardtags = '',
 | 
        
           |  |  | 76 |         string $extratags = '',
 | 
        
           |  |  | 77 |     ): void {
 | 
        
           |  |  | 78 |         $node = $this->get_selected_node($selector, $element);
 | 
        
           |  |  | 79 |         $this->run_axe_for_tags(
 | 
        
           |  |  | 80 |             // Turn the comma-separated string into an array of trimmed values, filtering out empty values.
 | 
        
           |  |  | 81 |             array_filter(array_map('trim', explode(',', $standardtags))),
 | 
        
           |  |  | 82 |             array_filter(array_map('trim', explode(',', $extratags))),
 | 
        
           |  |  | 83 |             $node,
 | 
        
           |  |  | 84 |         );
 | 
        
           |  |  | 85 |     }
 | 
        
           |  |  | 86 |   | 
        
           |  |  | 87 |     /**
 | 
        
           | 1 | efrain | 88 |      * Run the Axe tests.
 | 
        
           |  |  | 89 |      *
 | 
        
           | 1441 | ariadna | 90 |      * See {@see behat_accessibility::run_axe_validation_for_tags} for details of the supported tags.
 | 
        
           | 1 | efrain | 91 |      *
 | 
        
           |  |  | 92 |      * @param   array $standardtags The list of standard tags to run
 | 
        
           |  |  | 93 |      * @param   array $extratags The list of tags, in addition to the standard tags, to run
 | 
        
           | 1441 | ariadna | 94 |      * @param null|NodeElement $containerelement The element to run the tests on
 | 
        
           | 1 | efrain | 95 |      */
 | 
        
           | 1441 | ariadna | 96 |     protected function run_axe_for_tags(
 | 
        
           |  |  | 97 |         array $standardtags = [],
 | 
        
           |  |  | 98 |         array $extratags = [],
 | 
        
           |  |  | 99 |         ?NodeElement $containerelement = null,
 | 
        
           |  |  | 100 |     ): void {
 | 
        
           | 1 | efrain | 101 |         if (!behat_config_manager::get_behat_run_config_value('axe')) {
 | 
        
           |  |  | 102 |             return;
 | 
        
           |  |  | 103 |         }
 | 
        
           |  |  | 104 |   | 
        
           |  |  | 105 |         if (!$this->has_tag('accessibility')) {
 | 
        
           |  |  | 106 |             throw new DriverException(
 | 
        
           |  |  | 107 |                 'Accessibility tests using Axe must have the @accessibility tag on either the scenario or feature.'
 | 
        
           |  |  | 108 |             );
 | 
        
           |  |  | 109 |         }
 | 
        
           |  |  | 110 |   | 
        
           |  |  | 111 |         $this->require_javascript();
 | 
        
           |  |  | 112 |   | 
        
           |  |  | 113 |         $axeurl = (new \moodle_url('/lib/behat/axe/axe.min.js'))->out(false);
 | 
        
           |  |  | 114 |         $axeconfig = $this->get_axe_config_for_tags($standardtags, $extratags);
 | 
        
           | 1441 | ariadna | 115 |         $xpath = '';
 | 
        
           |  |  | 116 |         if ($containerelement) {
 | 
        
           |  |  | 117 |             $xpath = $this->prepare_xpath_for_javascript($containerelement->getXpath());
 | 
        
           |  |  | 118 |         }
 | 
        
           | 1 | efrain | 119 |         $runaxe = <<<EOF
 | 
        
           |  |  | 120 | (axeurl => {
 | 
        
           |  |  | 121 |     const runTests = () => {
 | 
        
           |  |  | 122 |         const axeTag = document.querySelector('script[data-purpose="axe"]');
 | 
        
           |  |  | 123 |         axeTag.dataset.results = null;
 | 
        
           |  |  | 124 |   | 
        
           | 1441 | ariadna | 125 |         const getRun = () => {
 | 
        
           |  |  | 126 |             const xpath = "{$xpath}";
 | 
        
           |  |  | 127 |             if (xpath.length) {
 | 
        
           |  |  | 128 |                 const targetElements = [];
 | 
        
           |  |  | 129 |                 const results = document.evaluate(xpath, document, null, XPathResult.ANY_TYPE, null);
 | 
        
           |  |  | 130 |                 let targetElement = results.iterateNext();
 | 
        
           |  |  | 131 |                 while (targetElement) {
 | 
        
           |  |  | 132 |                     targetElements.push(targetElement);
 | 
        
           |  |  | 133 |                     targetElement = results.iterateNext();
 | 
        
           |  |  | 134 |                 }
 | 
        
           |  |  | 135 |                 return axe.run(targetElements, {$axeconfig});
 | 
        
           |  |  | 136 |             }
 | 
        
           |  |  | 137 |   | 
        
           |  |  | 138 |             return axe.run({$axeconfig});
 | 
        
           |  |  | 139 |         };
 | 
        
           |  |  | 140 |   | 
        
           |  |  | 141 |         getRun()
 | 
        
           | 1 | efrain | 142 |         .then(results => {
 | 
        
           |  |  | 143 |             axeTag.dataset.results = JSON.stringify({
 | 
        
           |  |  | 144 |                 violations: results.violations,
 | 
        
           |  |  | 145 |                 exception: null,
 | 
        
           |  |  | 146 |             });
 | 
        
           |  |  | 147 |         })
 | 
        
           |  |  | 148 |         .catch(exception => {
 | 
        
           |  |  | 149 |             axeTag.dataset.results = JSON.stringify({
 | 
        
           |  |  | 150 |                 violations: [],
 | 
        
           |  |  | 151 |                 exception: exception,
 | 
        
           |  |  | 152 |             });
 | 
        
           |  |  | 153 |         });
 | 
        
           |  |  | 154 |     };
 | 
        
           |  |  | 155 |   | 
        
           |  |  | 156 |     if (document.querySelector('script[data-purpose="axe"]')) {
 | 
        
           |  |  | 157 |         runTests();
 | 
        
           |  |  | 158 |     } else {
 | 
        
           |  |  | 159 |         // Inject the axe content.
 | 
        
           |  |  | 160 |         const axeTag = document.createElement('script');
 | 
        
           |  |  | 161 |         axeTag.src = axeurl,
 | 
        
           |  |  | 162 |         axeTag.dataset.purpose = 'axe';
 | 
        
           |  |  | 163 |   | 
        
           |  |  | 164 |         axeTag.onload = () => runTests();
 | 
        
           |  |  | 165 |         document.head.append(axeTag);
 | 
        
           |  |  | 166 |     }
 | 
        
           |  |  | 167 | })('{$axeurl}');
 | 
        
           |  |  | 168 | EOF;
 | 
        
           |  |  | 169 |   | 
        
           |  |  | 170 |         $this->execute_script($runaxe);
 | 
        
           |  |  | 171 |   | 
        
           |  |  | 172 |         $getresults = <<<EOF
 | 
        
           |  |  | 173 | return (() => {
 | 
        
           |  |  | 174 |     const axeTag = document.querySelector('script[data-purpose="axe"]');
 | 
        
           |  |  | 175 |     return axeTag.dataset.results;
 | 
        
           |  |  | 176 | })()
 | 
        
           |  |  | 177 | EOF;
 | 
        
           |  |  | 178 |   | 
        
           |  |  | 179 |         for ($i = 0; $i < self::get_extended_timeout() * 10; $i++) {
 | 
        
           |  |  | 180 |             $results = json_decode($this->evaluate_script($getresults) ?? '');
 | 
        
           |  |  | 181 |             if ($results) {
 | 
        
           |  |  | 182 |                 break;
 | 
        
           |  |  | 183 |             }
 | 
        
           |  |  | 184 |         }
 | 
        
           |  |  | 185 |   | 
        
           |  |  | 186 |         if (empty($results)) {
 | 
        
           |  |  | 187 |             throw new \Exception('No data');
 | 
        
           |  |  | 188 |         }
 | 
        
           |  |  | 189 |   | 
        
           |  |  | 190 |         if ($results->exception !== null) {
 | 
        
           |  |  | 191 |             throw new ExpectationException($results->exception, $this->getSession());
 | 
        
           |  |  | 192 |         }
 | 
        
           |  |  | 193 |   | 
        
           |  |  | 194 |         $violations = $results->violations;
 | 
        
           |  |  | 195 |         if (!count($violations)) {
 | 
        
           |  |  | 196 |             return;
 | 
        
           |  |  | 197 |         }
 | 
        
           |  |  | 198 |   | 
        
           |  |  | 199 |         $violationdata = "Accessibility violations found:\n";
 | 
        
           |  |  | 200 |         foreach ($violations as $violation) {
 | 
        
           |  |  | 201 |             $nodedata = '';
 | 
        
           |  |  | 202 |             foreach ($violation->nodes as $node) {
 | 
        
           |  |  | 203 |                 $failedchecks = [];
 | 
        
           |  |  | 204 |                 foreach (array_merge($node->any, $node->all, $node->none) as $check) {
 | 
        
           |  |  | 205 |                     $failedchecks[$check->id] = $check->message;
 | 
        
           |  |  | 206 |                 }
 | 
        
           |  |  | 207 |   | 
        
           |  |  | 208 |                 $nodedata .= sprintf(
 | 
        
           |  |  | 209 |                     "    - %s:\n      %s\n\n",
 | 
        
           |  |  | 210 |                     implode(', ', $failedchecks),
 | 
        
           |  |  | 211 |                     implode("\n      ", $node->target)
 | 
        
           |  |  | 212 |                 );
 | 
        
           |  |  | 213 |             }
 | 
        
           |  |  | 214 |   | 
        
           |  |  | 215 |             $violationdata .= sprintf(
 | 
        
           | 1441 | ariadna | 216 |                 "  %.03d violations of rule '%s' found (severity: %s)\n",
 | 
        
           | 1 | efrain | 217 |                 count($violation->nodes),
 | 
        
           | 1441 | ariadna | 218 |                 $violation->id,
 | 
        
           | 1 | efrain | 219 |                 $violation->impact,
 | 
        
           |  |  | 220 |             );
 | 
        
           | 1441 | ariadna | 221 |             $violationdata .= "  {$violation->help}\n";
 | 
        
           |  |  | 222 |             $violationdata .= "  {$violation->description}\n";
 | 
        
           |  |  | 223 |             $violationdata .= "  {$violation->helpUrl}\n";
 | 
        
           |  |  | 224 |             $violationdata .= $nodedata;
 | 
        
           | 1 | efrain | 225 |         }
 | 
        
           |  |  | 226 |   | 
        
           |  |  | 227 |         throw new ExpectationException($violationdata, $this->getSession());
 | 
        
           |  |  | 228 |     }
 | 
        
           |  |  | 229 |   | 
        
           |  |  | 230 |     /**
 | 
        
           |  |  | 231 |      * Get the configuration to use with Axe.
 | 
        
           |  |  | 232 |      *
 | 
        
           | 1441 | ariadna | 233 |      * See {@see behat_accessibility::run_axe_validation_for_tags} for details of the rules.
 | 
        
           | 1 | efrain | 234 |      *
 | 
        
           |  |  | 235 |      * @param   array|null $standardtags The list of standard tags to run
 | 
        
           |  |  | 236 |      * @param   array|null $extratags The list of tags, in addition to the standard tags, to run
 | 
        
           |  |  | 237 |      * @return  string The JSON-encoded configuration.
 | 
        
           |  |  | 238 |      */
 | 
        
           |  |  | 239 |     protected function get_axe_config_for_tags(?array $standardtags = null, ?array $extratags = null): string {
 | 
        
           |  |  | 240 |         if (empty($standardtags)) {
 | 
        
           |  |  | 241 |             $standardtags = [
 | 
        
           | 11 | efrain | 242 |                 // Meet WCAG 2.2 Level A success criteria.
 | 
        
           | 1441 | ariadna | 243 |                 'wcag2a',
 | 
        
           |  |  | 244 |                 'wcag21a',
 | 
        
           | 11 | efrain | 245 |                 'wcag22a',
 | 
        
           | 1 | efrain | 246 |   | 
        
           | 11 | efrain | 247 |                 // Meet WCAG 2.2 Level AA success criteria.
 | 
        
           | 1441 | ariadna | 248 |                 'wcag2aa',
 | 
        
           |  |  | 249 |                 'wcag21aa',
 | 
        
           | 11 | efrain | 250 |                 'wcag22aa',
 | 
        
           | 1 | efrain | 251 |   | 
        
           |  |  | 252 |                 // Meet Section 508 requirements.
 | 
        
           |  |  | 253 |                 // See https://www.epa.gov/accessibility/what-section-508 for detail.
 | 
        
           |  |  | 254 |                 'section508',
 | 
        
           |  |  | 255 |   | 
        
           |  |  | 256 |                 // Ensure that ARIA attributes are correctly defined.
 | 
        
           |  |  | 257 |                 'cat.aria',
 | 
        
           |  |  | 258 |   | 
        
           |  |  | 259 |                 // Requirements for sensory and visual cues.
 | 
        
           |  |  | 260 |                 // These largely related to viewport scale and zoom functionality.
 | 
        
           |  |  | 261 |                 'cat.sensory-and-visual-cues',
 | 
        
           |  |  | 262 |   | 
        
           |  |  | 263 |                 // Meet WCAG 1.3.4 requirements for orientation.
 | 
        
           |  |  | 264 |                 // See https://www.w3.org/WAI/WCAG21/Understanding/orientation.html for detail.
 | 
        
           |  |  | 265 |                 'wcag134',
 | 
        
           |  |  | 266 |             ];
 | 
        
           |  |  | 267 |         }
 | 
        
           |  |  | 268 |   | 
        
           |  |  | 269 |         return json_encode([
 | 
        
           |  |  | 270 |             'runOnly' => [
 | 
        
           |  |  | 271 |                 'type' > 'tag',
 | 
        
           |  |  | 272 |                 'values' => array_merge($standardtags, $extratags),
 | 
        
           |  |  | 273 |             ],
 | 
        
           |  |  | 274 |         ]);
 | 
        
           |  |  | 275 |     }
 | 
        
           |  |  | 276 | }
 |