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