weekly release 2.7dev
[moodle.git] / lib / tests / behat / behat_hooks.php
CommitLineData
f5ceb6c2 1<?php
f5ceb6c2
DM
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
17/**
18 * Behat hooks steps definitions.
19 *
20 * This methods are used by Behat CLI command.
21 *
22 * @package core
23 * @category test
24 * @copyright 2012 David Monllaó
25 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
26 */
27
28// NOTE: no MOODLE_INTERNAL test here, this file may be required by behat before including /config.php.
29
30require_once(__DIR__ . '/../../behat/behat_base.php');
31
1303eb29
DM
32use Behat\Behat\Event\SuiteEvent as SuiteEvent,
33 Behat\Behat\Event\ScenarioEvent as ScenarioEvent,
34 Behat\Behat\Event\StepEvent as StepEvent,
89cf999a 35 Behat\Mink\Exception\DriverException as DriverException,
870349ee
DM
36 WebDriver\Exception\NoSuchWindow as NoSuchWindow,
37 WebDriver\Exception\UnexpectedAlertOpen as UnexpectedAlertOpen,
3b7d3fb8
DM
38 WebDriver\Exception\UnknownError as UnknownError,
39 WebDriver\Exception\CurlExec as CurlExec,
870349ee 40 WebDriver\Exception\NoAlertOpenError as NoAlertOpenError;
f5ceb6c2
DM
41
42/**
43 * Hooks to the behat process.
44 *
45 * Behat accepts hooks after and before each
46 * suite, feature, scenario and step.
47 *
48 * They can not call other steps as part of their process
49 * like regular steps definitions does.
50 *
51 * Throws generic Exception because they are captured by Behat.
52 *
53 * @package core
54 * @category test
55 * @copyright 2012 David Monllaó
56 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
57 */
58class behat_hooks extends behat_base {
59
41eb672b
DM
60 /**
61 * @var Last browser session start time.
62 */
63 protected static $lastbrowsersessionstart = 0;
64
95b43d6b
DM
65 /**
66 * @var For actions that should only run once.
67 */
68 protected static $initprocessesfinished = false;
69
f49cede7
DM
70 /**
71 * Some exceptions can only be caught in a before or after step hook,
72 * they can not be thrown there as they will provoke a framework level
73 * failure, but we can store them here to fail the step in i_look_for_exceptions()
74 * which result will be parsed by the framework as the last step result.
75 *
76 * @var Null or the exception last step throw in the before or after hook.
77 */
78 protected static $currentstepexception = null;
79
5c0dfe32
DM
80 /**
81 * If we are saving screenshots on failures we should use the same parent dir during a run.
82 *
83 * @var The parent dir name
84 */
85 protected static $screenshotsdirname = false;
86
f5ceb6c2
DM
87 /**
88 * Gives access to moodle codebase, ensures all is ready and sets up the test lock.
89 *
90 * Includes config.php to use moodle codebase with $CFG->behat_*
91 * instead of $CFG->prefix and $CFG->dataroot, called once per suite.
92 *
93 * @static
94 * @throws Exception
95 * @BeforeSuite
96 */
97 public static function before_suite($event) {
98 global $CFG;
99
cfcbc34a
DM
100 // Defined only when the behat CLI command is running, the moodle init setup process will
101 // read this value and switch to $CFG->behat_dataroot and $CFG->behat_prefix instead of
102 // the normal site.
103 define('BEHAT_TEST', 1);
104
f5ceb6c2
DM
105 define('CLI_SCRIPT', 1);
106
cfcbc34a 107 // With BEHAT_TEST we will be using $CFG->behat_* instead of $CFG->dataroot, $CFG->prefix and $CFG->wwwroot.
f5ceb6c2
DM
108 require_once(__DIR__ . '/../../../config.php');
109
110 // Now that we are MOODLE_INTERNAL.
111 require_once(__DIR__ . '/../../behat/classes/behat_command.php');
17344d4c 112 require_once(__DIR__ . '/../../behat/classes/behat_selectors.php');
f5ceb6c2
DM
113 require_once(__DIR__ . '/../../behat/classes/util.php');
114 require_once(__DIR__ . '/../../testing/classes/test_lock.php');
b08b0a28 115 require_once(__DIR__ . '/../../testing/classes/nasty_strings.php');
f5ceb6c2
DM
116
117 // Avoids vendor/bin/behat to be executed directly without test environment enabled
118 // to prevent undesired db & dataroot modifications, this is also checked
119 // before each scenario (accidental user deletes) in the BeforeScenario hook.
120
121 if (!behat_util::is_test_mode_enabled()) {
122 throw new Exception('Behat only can run if test mode is enabled. More info in ' . behat_command::DOCS_URL . '#Running_tests');
123 }
124
125 if (!behat_util::is_server_running()) {
3b7d3fb8 126 throw new Exception($CFG->behat_wwwroot .
60129d5d 127 ' is not available, ensure you specified correct url and that the server is set up and started.' .
3b7d3fb8 128 ' More info in ' . behat_command::DOCS_URL . '#Running_tests');
f5ceb6c2
DM
129 }
130
b831d479
DM
131 // Prevents using outdated data, upgrade script would start and tests would fail.
132 if (!behat_util::is_test_data_updated()) {
b32ca4ca 133 $commandpath = 'php admin/tool/behat/cli/init.php';
b831d479
DM
134 throw new Exception('Your behat test site is outdated, please run ' . $commandpath . ' from your moodle dirroot to drop and install the behat test site again.');
135 }
f5ceb6c2
DM
136 // Avoid parallel tests execution, it continues when the previous lock is released.
137 test_lock::acquire('behat');
41eb672b
DM
138
139 // Store the browser reset time if reset after N seconds is specified in config.php.
140 if (!empty($CFG->behat_restart_browser_after)) {
141 // Store the initial browser session opening.
142 self::$lastbrowsersessionstart = time();
143 }
5c0dfe32
DM
144
145 if (!empty($CFG->behat_screenshots_path) && !is_writable($CFG->behat_screenshots_path)) {
146 throw new Exception('You set $CFG->behat_screenshots_path to a non-writable directory');
147 }
f5ceb6c2
DM
148 }
149
150 /**
151 * Resets the test environment.
152 *
153 * @throws coding_exception If here we are not using the test database it should be because of a coding error
154 * @BeforeScenario
155 */
156 public function before_scenario($event) {
157 global $DB, $SESSION, $CFG;
158
159 // As many checks as we can.
cfcbc34a
DM
160 if (!defined('BEHAT_TEST') ||
161 !defined('BEHAT_SITE_RUNNING') ||
f5ceb6c2
DM
162 php_sapi_name() != 'cli' ||
163 !behat_util::is_test_mode_enabled() ||
cfcbc34a 164 !behat_util::is_test_site()) {
46ac40cd 165 throw new coding_exception('Behat only can modify the test database and the test dataroot!');
f5ceb6c2
DM
166 }
167
89cf999a
DM
168 $moreinfo = 'More info in ' . behat_command::DOCS_URL . '#Running_tests';
169 $driverexceptionmsg = 'Selenium server is not running, you need to start it to run tests that involve Javascript. ' . $moreinfo;
f59f6b6b
DM
170 try {
171 $session = $this->getSession();
172 } catch (CurlExec $e) {
173 // Exception thrown by WebDriver, so only @javascript tests will be caugth; in
174 // behat_util::is_server_running() we already checked that the server is running.
89cf999a
DM
175 throw new Exception($driverexceptionmsg);
176 } catch (DriverException $e) {
177 throw new Exception($driverexceptionmsg);
f59f6b6b
DM
178 } catch (UnknownError $e) {
179 // Generic 'I have no idea' Selenium error. Custom exception to provide more feedback about possible solutions.
180 $this->throw_unknown_exception($e);
181 }
182
183
17344d4c
DM
184 // We need the Mink session to do it and we do it only before the first scenario.
185 if (self::is_first_scenario()) {
f59f6b6b 186 behat_selectors::register_moodle_selectors($session);
17344d4c
DM
187 }
188
de230fd3
PS
189 // Reset $SESSION.
190 $_SESSION = array();
c8619f33 191 $SESSION = new stdClass();
de230fd3 192 $_SESSION['SESSION'] =& $SESSION;
c8619f33 193
f5ceb6c2
DM
194 behat_util::reset_database();
195 behat_util::reset_dataroot();
196
10dd80c2
DM
197 purge_all_caches();
198 accesslib_clear_all_caches(true);
199
b08b0a28
DM
200 // Reset the nasty strings list used during the last test.
201 nasty_strings::reset_used_strings();
202
de230fd3 203 // Assign valid data to admin user (some generator-related code needs a valid user).
f5ceb6c2 204 $user = $DB->get_record('user', array('username' => 'admin'));
d79d5ac2 205 \core\session\manager::set_user($user);
5f4b4e91 206
41eb672b
DM
207 // Reset the browser if specified in config.php.
208 if (!empty($CFG->behat_restart_browser_after) && $this->running_javascript()) {
209 $now = time();
210 if (self::$lastbrowsersessionstart + $CFG->behat_restart_browser_after < $now) {
f59f6b6b 211 $session->restart();
41eb672b
DM
212 self::$lastbrowsersessionstart = $now;
213 }
214 }
215
5f4b4e91 216 // Start always in the the homepage.
3b7d3fb8 217 try {
f59f6b6b
DM
218 // Let's be conservative as we never know when new upstream issues will affect us.
219 $session->visit($this->locate_path('/'));
3b7d3fb8 220 } catch (UnknownError $e) {
3b7d3fb8
DM
221 $this->throw_unknown_exception($e);
222 }
870349ee 223
f59f6b6b 224
95b43d6b
DM
225 // Checking that the root path is a Moodle test site.
226 if (self::is_first_scenario()) {
227 $notestsiteexception = new Exception('The base URL (' . $CFG->wwwroot . ') is not a behat test site, ' .
3b7d3fb8 228 'ensure you started the built-in web server in the correct directory or your web server is correctly started and set up');
19f6703d 229 $this->find("xpath", "//head/child::title[normalize-space(.)='" . behat_util::BEHATSITENAME . "']", $notestsiteexception);
95b43d6b
DM
230
231 self::$initprocessesfinished = true;
232 }
233
f5ceb6c2
DM
234 }
235
f5ceb6c2 236 /**
d1e55a47 237 * Wait for JS to complete before beginning interacting with the DOM.
f5ceb6c2 238 *
f49cede7
DM
239 * Executed only when running against a real browser. We wrap it
240 * all in a try & catch to forward the exception to i_look_for_exceptions
241 * so the exception will be at scenario level, which causes a failure, by
242 * default would be at framework level, which will stop the execution of
243 * the run.
f5ceb6c2 244 *
6e2c417c 245 * @BeforeStep @javascript
f5ceb6c2 246 */
6e2c417c 247 public function before_step_javascript($event) {
f49cede7
DM
248
249 try {
250 $this->wait_for_pending_js();
251 self::$currentstepexception = null;
252 } catch (Exception $e) {
253 self::$currentstepexception = $e;
254 }
d1e55a47
DM
255 }
256
257 /**
258 * Wait for JS to complete after finishing the step.
259 *
260 * With this we ensure that there are not AJAX calls
261 * still in progress.
262 *
f49cede7
DM
263 * Executed only when running against a real browser. We wrap it
264 * all in a try & catch to forward the exception to i_look_for_exceptions
265 * so the exception will be at scenario level, which causes a failure, by
266 * default would be at framework level, which will stop the execution of
267 * the run.
d1e55a47
DM
268 *
269 * @AfterStep @javascript
270 */
271 public function after_step_javascript($event) {
5c0dfe32
DM
272 global $CFG;
273
274 // Save a screenshot if the step failed.
275 if (!empty($CFG->behat_screenshots_path) &&
276 $event->getResult() === StepEvent::FAILED) {
277 $this->take_screenshot($event);
278 }
f49cede7
DM
279
280 try {
281 $this->wait_for_pending_js();
282 self::$currentstepexception = null;
283 } catch (UnexpectedAlertOpen $e) {
284 self::$currentstepexception = $e;
285
286 // Accepting the alert so the framework can continue properly running
287 // the following scenarios. Some browsers already closes the alert, so
288 // wrapping in a try & catch.
289 try {
290 $this->getSession()->getDriver()->getWebDriverSession()->accept_alert();
291 } catch (Exception $e) {
292 // Catching the generic one as we never know how drivers reacts here.
293 }
294 } catch (Exception $e) {
295 self::$currentstepexception = $e;
296 }
d1e55a47
DM
297 }
298
3ec07614 299 /**
5c0dfe32
DM
300 * Getter for self::$screenshotsdirname
301 *
302 * @return string
303 */
304 protected function get_run_screenshots_dir() {
305 return self::$screenshotsdirname;
306 }
307
308 /**
309 * Take screenshot when a step fails.
3ec07614 310 *
5c0dfe32
DM
311 * @throws Exception
312 * @param StepEvent $event
3ec07614 313 */
5c0dfe32 314 protected function take_screenshot(StepEvent $event) {
3ec07614
DW
315 global $CFG;
316
5c0dfe32
DM
317 // Goutte can't save screenshots.
318 if (!$this->running_javascript()) {
319 return false;
3ec07614 320 }
5c0dfe32
DM
321
322 // All the run screenshots in the same parent dir.
323 if (!$screenshotsdirname = self::get_run_screenshots_dir()) {
cca6f373 324 $screenshotsdirname = self::$screenshotsdirname = date('Ymd_His');
5c0dfe32
DM
325
326 $dir = $CFG->behat_screenshots_path . DIRECTORY_SEPARATOR . $screenshotsdirname;
327
328 if (!mkdir($dir, $CFG->directorypermissions, true)) {
329 // It shouldn't, we already checked that the directory is writable.
330 throw new Exception('No directories can be created inside $CFG->behat_screenshots_path, check the directory permissions.');
331 }
332 } else {
333 // We will always need to know the full path.
334 $dir = $CFG->behat_screenshots_path . DIRECTORY_SEPARATOR . $screenshotsdirname;
335 }
336
337 // The scenario title + the failed step text.
338 // We want a i-am-the-scenario-title_i-am-the-failed-step.png format.
339 $filename = $event->getStep()->getParent()->getTitle() . '_' . $event->getStep()->getText();
340 $filename = preg_replace('/([^a-zA-Z0-9\_]+)/', '-', $filename) . '.png';
341
342 $this->saveScreenshot($filename, $dir);
3ec07614
DW
343 }
344
d1e55a47
DM
345 /**
346 * Waits for all the JS to be loaded.
347 *
afe9f42a 348 * @throws \Exception
d1e55a47
DM
349 * @throws NoSuchWindow
350 * @throws UnknownError
351 * @return bool True or false depending whether all the JS is loaded or not.
352 */
353 protected function wait_for_pending_js() {
354
c1faf86b
DM
355 // We don't use behat_base::spin() here as we don't want to end up with an exception
356 // if the page & JSs don't finish loading properly.
357 for ($i = 0; $i < self::EXTENDED_TIMEOUT * 10; $i++) {
6e2c417c
DW
358 $pending = '';
359 try {
d1e55a47
DM
360 $jscode = 'return ' . self::PAGE_READY_JS . ' ? "" : M.util.pending_js.join(":");';
361 $pending = $this->getSession()->evaluateScript($jscode);
6e2c417c 362 } catch (NoSuchWindow $nsw) {
d1e55a47 363 // We catch an exception here, in case we just closed the window we were interacting with.
6e2c417c
DW
364 // No javascript is running if there is no window right?
365 $pending = '';
d1e55a47 366 } catch (UnknownError $e) {
f49cede7
DM
367 // M is not defined when the window or the frame don't exist anymore.
368 if (strstr($e->getMessage(), 'M is not defined') != false) {
369 $pending = '';
370 }
28abad1a 371 }
d1e55a47
DM
372
373 // If there are no pending JS we stop waiting.
6e2c417c 374 if ($pending === '') {
d1e55a47 375 return true;
6e2c417c 376 }
d1e55a47 377
6e2c417c
DW
378 // 0.1 seconds.
379 usleep(100000);
1303eb29 380 }
d1e55a47 381
afe9f42a
DM
382 // Timeout waiting for JS to complete. It will be catched and forwarded to behat_hooks::i_look_for_exceptions().
383 // It is unlikely that Javascript code of a page or an AJAX request needs more than self::EXTENDED_TIMEOUT seconds
384 // to be loaded, although when pages contains Javascript errors M.util.js_complete() can not be executed, so the
385 // number of JS pending code and JS completed code will not match and we will reach this point.
386 throw new \Exception('Javascript code and/or AJAX requests are not ready after ' . self::EXTENDED_TIMEOUT .
387 ' seconds. There is a Javascript error or the code is extremely slow.');
f5ceb6c2
DM
388 }
389
5f4b4e91
DM
390 /**
391 * Internal step definition to find exceptions, debugging() messages and PHP debug messages.
392 *
393 * Part of behat_hooks class as is part of the testing framework, is auto-executed
394 * after each step so no features will splicitly use it.
395 *
396 * @Given /^I look for exceptions$/
f49cede7 397 * @throw Exception Unknown type, depending on what we caught in the hook or basic \Exception.
5f4b4e91
DM
398 * @see Moodle\BehatExtension\Tester\MoodleStepTester
399 */
400 public function i_look_for_exceptions() {
401
f49cede7
DM
402 // If the step already failed in a hook throw the exception.
403 if (!is_null(self::$currentstepexception)) {
404 throw self::$currentstepexception;
405 }
406
217e8e59
DM
407 // Wrap in try in case we were interacting with a closed window.
408 try {
5f4b4e91 409
217e8e59 410 // Exceptions.
3e76c7fa 411 $exceptionsxpath = "//div[@data-rel='fatalerror']";
8aea365f 412 // Debugging messages.
3e76c7fa 413 $debuggingxpath = "//div[@data-rel='debugging']";
8aea365f 414 // PHP debug messages.
3e76c7fa 415 $phperrorxpath = "//div[@data-rel='phpdebugmessage']";
8aea365f 416 // Any other backtrace.
6cd21356 417 $othersxpath = "(//*[contains(., ': call to ')])[1]";
8aea365f
DM
418
419 $xpaths = array($exceptionsxpath, $debuggingxpath, $phperrorxpath, $othersxpath);
420 $joinedxpath = implode(' | ', $xpaths);
421
422 // Joined xpath expression. Most of the time there will be no exceptions, so this pre-check
423 // is faster than to send the 4 xpath queries for each step.
424 if (!$this->getSession()->getDriver()->find($joinedxpath)) {
425 return;
426 }
427
428 // Exceptions.
429 if ($errormsg = $this->getSession()->getPage()->find('xpath', $exceptionsxpath)) {
5f4b4e91 430
217e8e59
DM
431 // Getting the debugging info and the backtrace.
432 $errorinfoboxes = $this->getSession()->getPage()->findAll('css', 'div.notifytiny');
433 $errorinfo = $this->get_debug_text($errorinfoboxes[0]->getHtml()) . "\n" .
434 $this->get_debug_text($errorinfoboxes[1]->getHtml());
5f4b4e91 435
217e8e59
DM
436 $msg = "Moodle exception: " . $errormsg->getText() . "\n" . $errorinfo;
437 throw new \Exception(html_entity_decode($msg));
438 }
439
440 // Debugging messages.
8aea365f 441 if ($debuggingmessages = $this->getSession()->getPage()->findAll('xpath', $debuggingxpath)) {
217e8e59
DM
442 $msgs = array();
443 foreach ($debuggingmessages as $debuggingmessage) {
444 $msgs[] = $this->get_debug_text($debuggingmessage->getHtml());
445 }
446 $msg = "debugging() message/s found:\n" . implode("\n", $msgs);
447 throw new \Exception(html_entity_decode($msg));
5f4b4e91 448 }
90ed22ab 449
217e8e59 450 // PHP debug messages.
8aea365f 451 if ($phpmessages = $this->getSession()->getPage()->findAll('xpath', $phperrorxpath)) {
90ed22ab 452
217e8e59
DM
453 $msgs = array();
454 foreach ($phpmessages as $phpmessage) {
455 $msgs[] = $this->get_debug_text($phpmessage->getHtml());
456 }
457 $msg = "PHP debug message/s found:\n" . implode("\n", $msgs);
458 throw new \Exception(html_entity_decode($msg));
90ed22ab 459 }
bbd802f7 460
217e8e59 461 // Any other backtrace.
9a1f4922
DM
462 // First looking through xpath as it is faster than get and parse the whole page contents,
463 // we get the contents and look for matches once we found something to suspect that there is a backtrace.
8aea365f 464 if ($this->getSession()->getDriver()->find($othersxpath)) {
9a1f4922
DM
465 $backtracespattern = '/(line [0-9]* of [^:]*: call to [\->&;:a-zA-Z_\x7f-\xff][\->&;:a-zA-Z0-9_\x7f-\xff]*)/';
466 if (preg_match_all($backtracespattern, $this->getSession()->getPage()->getContent(), $backtraces)) {
467 $msgs = array();
468 foreach ($backtraces[0] as $backtrace) {
469 $msgs[] = $backtrace . '()';
470 }
471 $msg = "Other backtraces found:\n" . implode("\n", $msgs);
472 throw new \Exception(htmlentities($msg));
217e8e59 473 }
bbd802f7 474 }
217e8e59
DM
475
476 } catch (NoSuchWindow $e) {
477 // If we were interacting with a popup window it will not exists after closing it.
bbd802f7 478 }
5f4b4e91
DM
479 }
480
481 /**
482 * Converts HTML tags to line breaks to display the info in CLI
483 *
484 * @param string $html
485 * @return string
486 */
487 protected function get_debug_text($html) {
488
489 // Replacing HTML tags for new lines and keeping only the text.
490 $notags = preg_replace('/<+\s*\/*\s*([A-Z][A-Z0-9]*)\b[^>]*\/*\s*>*/i', "\n", $html);
491 return preg_replace("/(\n)+/s", "\n", $notags);
492 }
493
95b43d6b
DM
494 /**
495 * Returns whether the first scenario of the suite is running
496 *
497 * @return bool
498 */
499 protected static function is_first_scenario() {
500 return !(self::$initprocessesfinished);
501 }
3b7d3fb8
DM
502
503 /**
504 * Throws an exception after appending an extra info text.
505 *
506 * @throws Exception
507 * @param UnknownError $exception
508 * @return void
509 */
510 protected function throw_unknown_exception(UnknownError $exception) {
511 $text = get_string('unknownexceptioninfo', 'tool_behat');
512 throw new Exception($text . PHP_EOL . $exception->getMessage());
513 }
514
f5ceb6c2 515}