weekly release 3.1dev
[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,
3c71c15c
RT
34 Behat\Behat\Event\FeatureEvent as FeatureEvent,
35 Behat\Behat\Event\OutlineExampleEvent as OutlineExampleEvent,
1303eb29 36 Behat\Behat\Event\StepEvent as StepEvent,
89cf999a 37 Behat\Mink\Exception\DriverException as DriverException,
870349ee
DM
38 WebDriver\Exception\NoSuchWindow as NoSuchWindow,
39 WebDriver\Exception\UnexpectedAlertOpen as UnexpectedAlertOpen,
3b7d3fb8
DM
40 WebDriver\Exception\UnknownError as UnknownError,
41 WebDriver\Exception\CurlExec as CurlExec,
870349ee 42 WebDriver\Exception\NoAlertOpenError as NoAlertOpenError;
f5ceb6c2
DM
43
44/**
45 * Hooks to the behat process.
46 *
47 * Behat accepts hooks after and before each
48 * suite, feature, scenario and step.
49 *
50 * They can not call other steps as part of their process
51 * like regular steps definitions does.
52 *
53 * Throws generic Exception because they are captured by Behat.
54 *
55 * @package core
56 * @category test
57 * @copyright 2012 David Monllaó
58 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
59 */
60class behat_hooks extends behat_base {
61
41eb672b
DM
62 /**
63 * @var Last browser session start time.
64 */
65 protected static $lastbrowsersessionstart = 0;
66
95b43d6b
DM
67 /**
68 * @var For actions that should only run once.
69 */
70 protected static $initprocessesfinished = false;
71
f49cede7
DM
72 /**
73 * Some exceptions can only be caught in a before or after step hook,
74 * they can not be thrown there as they will provoke a framework level
75 * failure, but we can store them here to fail the step in i_look_for_exceptions()
76 * which result will be parsed by the framework as the last step result.
77 *
78 * @var Null or the exception last step throw in the before or after hook.
79 */
80 protected static $currentstepexception = null;
81
5c0dfe32 82 /**
a964ead0 83 * If we are saving any kind of dump on failure we should use the same parent dir during a run.
5c0dfe32
DM
84 *
85 * @var The parent dir name
86 */
a964ead0 87 protected static $faildumpdirname = false;
5c0dfe32 88
3c71c15c
RT
89 /**
90 * Keeps track of time taken by feature to execute.
91 *
92 * @var array list of feature timings
93 */
94 protected static $timings = array();
95
f5ceb6c2
DM
96 /**
97 * Gives access to moodle codebase, ensures all is ready and sets up the test lock.
98 *
99 * Includes config.php to use moodle codebase with $CFG->behat_*
100 * instead of $CFG->prefix and $CFG->dataroot, called once per suite.
101 *
3c71c15c 102 * @param SuiteEvent $event event before suite.
f5ceb6c2
DM
103 * @static
104 * @throws Exception
105 * @BeforeSuite
106 */
3c71c15c 107 public static function before_suite(SuiteEvent $event) {
f5ceb6c2
DM
108 global $CFG;
109
cfcbc34a
DM
110 // Defined only when the behat CLI command is running, the moodle init setup process will
111 // read this value and switch to $CFG->behat_dataroot and $CFG->behat_prefix instead of
112 // the normal site.
113 define('BEHAT_TEST', 1);
114
f5ceb6c2
DM
115 define('CLI_SCRIPT', 1);
116
cfcbc34a 117 // With BEHAT_TEST we will be using $CFG->behat_* instead of $CFG->dataroot, $CFG->prefix and $CFG->wwwroot.
f5ceb6c2
DM
118 require_once(__DIR__ . '/../../../config.php');
119
120 // Now that we are MOODLE_INTERNAL.
121 require_once(__DIR__ . '/../../behat/classes/behat_command.php');
17344d4c 122 require_once(__DIR__ . '/../../behat/classes/behat_selectors.php');
af4830a2 123 require_once(__DIR__ . '/../../behat/classes/behat_context_helper.php');
f5ceb6c2
DM
124 require_once(__DIR__ . '/../../behat/classes/util.php');
125 require_once(__DIR__ . '/../../testing/classes/test_lock.php');
b08b0a28 126 require_once(__DIR__ . '/../../testing/classes/nasty_strings.php');
f5ceb6c2
DM
127
128 // Avoids vendor/bin/behat to be executed directly without test environment enabled
129 // to prevent undesired db & dataroot modifications, this is also checked
130 // before each scenario (accidental user deletes) in the BeforeScenario hook.
131
132 if (!behat_util::is_test_mode_enabled()) {
133 throw new Exception('Behat only can run if test mode is enabled. More info in ' . behat_command::DOCS_URL . '#Running_tests');
134 }
135
ab25d8a7
RT
136 // Reset all data, before checking for is_server_running.
137 // If not done, then it can return apache error, while running tests.
138 behat_util::reset_all_data();
139
f5ceb6c2 140 if (!behat_util::is_server_running()) {
3b7d3fb8 141 throw new Exception($CFG->behat_wwwroot .
60129d5d 142 ' is not available, ensure you specified correct url and that the server is set up and started.' .
3b7d3fb8 143 ' More info in ' . behat_command::DOCS_URL . '#Running_tests');
f5ceb6c2
DM
144 }
145
b831d479
DM
146 // Prevents using outdated data, upgrade script would start and tests would fail.
147 if (!behat_util::is_test_data_updated()) {
b32ca4ca 148 $commandpath = 'php admin/tool/behat/cli/init.php';
d69a6ad9
TH
149 throw new Exception("Your behat test site is outdated, please run\n\n " .
150 $commandpath . "\n\nfrom your moodle dirroot to drop and install the behat test site again.");
b831d479 151 }
f5ceb6c2
DM
152 // Avoid parallel tests execution, it continues when the previous lock is released.
153 test_lock::acquire('behat');
41eb672b
DM
154
155 // Store the browser reset time if reset after N seconds is specified in config.php.
156 if (!empty($CFG->behat_restart_browser_after)) {
157 // Store the initial browser session opening.
158 self::$lastbrowsersessionstart = time();
159 }
5c0dfe32 160
a964ead0
AN
161 if (!empty($CFG->behat_faildump_path) && !is_writable($CFG->behat_faildump_path)) {
162 throw new Exception('You set $CFG->behat_faildump_path to a non-writable directory');
5c0dfe32 163 }
f5ceb6c2
DM
164 }
165
3c71c15c
RT
166 /**
167 * Gives access to moodle codebase, to keep track of feature start time.
168 *
169 * @param FeatureEvent $event event fired before feature.
3c71c15c
RT
170 * @BeforeFeature
171 */
172 public static function before_feature(FeatureEvent $event) {
027212b0 173 if (!defined('BEHAT_FEATURE_TIMING_FILE')) {
3c71c15c
RT
174 return;
175 }
176 $file = $event->getFeature()->getFile();
08e7f97e
TL
177 self::$timings[$file] = microtime(true);
178 }
179
3c71c15c
RT
180 /**
181 * Gives access to moodle codebase, to keep track of feature end time.
182 *
183 * @param FeatureEvent $event event fired after feature.
3c71c15c
RT
184 * @AfterFeature
185 */
186 public static function after_feature(FeatureEvent $event) {
027212b0 187 if (!defined('BEHAT_FEATURE_TIMING_FILE')) {
3c71c15c
RT
188 return;
189 }
190 $file = $event->getFeature()->getFile();
08e7f97e
TL
191 self::$timings[$file] = microtime(true) - self::$timings[$file];
192 // Probably didn't actually run this, don't output it.
193 if (self::$timings[$file] < 1) {
194 unset(self::$timings[$file]);
195 }
196 }
197
3c71c15c
RT
198 /**
199 * Gives access to moodle codebase, to keep track of suite timings.
200 *
201 * @param SuiteEvent $event event fired after suite.
3c71c15c
RT
202 * @AfterSuite
203 */
204 public static function after_suite(SuiteEvent $event) {
027212b0 205 if (!defined('BEHAT_FEATURE_TIMING_FILE')) {
08e7f97e
TL
206 return;
207 }
208 $realroot = realpath(__DIR__.'/../../../').'/';
209 foreach (self::$timings as $k => $v) {
210 $new = str_replace($realroot, '', $k);
211 self::$timings[$new] = round($v, 1);
212 unset(self::$timings[$k]);
213 }
027212b0 214 if ($existing = @json_decode(file_get_contents(BEHAT_FEATURE_TIMING_FILE), true)) {
08e7f97e
TL
215 self::$timings = array_merge($existing, self::$timings);
216 }
217 arsort(self::$timings);
027212b0 218 @file_put_contents(BEHAT_FEATURE_TIMING_FILE, json_encode(self::$timings, JSON_PRETTY_PRINT));
08e7f97e
TL
219 }
220
f5ceb6c2
DM
221 /**
222 * Resets the test environment.
223 *
3c71c15c 224 * @param OutlineExampleEvent|ScenarioEvent $event event fired before scenario.
f5ceb6c2
DM
225 * @throws coding_exception If here we are not using the test database it should be because of a coding error
226 * @BeforeScenario
227 */
228 public function before_scenario($event) {
229 global $DB, $SESSION, $CFG;
230
231 // As many checks as we can.
cfcbc34a
DM
232 if (!defined('BEHAT_TEST') ||
233 !defined('BEHAT_SITE_RUNNING') ||
f5ceb6c2
DM
234 php_sapi_name() != 'cli' ||
235 !behat_util::is_test_mode_enabled() ||
cfcbc34a 236 !behat_util::is_test_site()) {
46ac40cd 237 throw new coding_exception('Behat only can modify the test database and the test dataroot!');
f5ceb6c2
DM
238 }
239
89cf999a
DM
240 $moreinfo = 'More info in ' . behat_command::DOCS_URL . '#Running_tests';
241 $driverexceptionmsg = 'Selenium server is not running, you need to start it to run tests that involve Javascript. ' . $moreinfo;
f59f6b6b
DM
242 try {
243 $session = $this->getSession();
244 } catch (CurlExec $e) {
245 // Exception thrown by WebDriver, so only @javascript tests will be caugth; in
246 // behat_util::is_server_running() we already checked that the server is running.
89cf999a
DM
247 throw new Exception($driverexceptionmsg);
248 } catch (DriverException $e) {
249 throw new Exception($driverexceptionmsg);
f59f6b6b
DM
250 } catch (UnknownError $e) {
251 // Generic 'I have no idea' Selenium error. Custom exception to provide more feedback about possible solutions.
252 $this->throw_unknown_exception($e);
253 }
254
255
17344d4c
DM
256 // We need the Mink session to do it and we do it only before the first scenario.
257 if (self::is_first_scenario()) {
f59f6b6b 258 behat_selectors::register_moodle_selectors($session);
8f76de32 259 behat_context_helper::set_main_context($event->getContext()->getMainContext());
17344d4c
DM
260 }
261
55f47341
RT
262 // Reset mink session between the scenarios.
263 $session->reset();
264
de230fd3 265 // Reset $SESSION.
2e00d01d 266 \core\session\manager::init_empty_session();
c8619f33 267
ef1d45b3 268 behat_util::reset_all_data();
b08b0a28 269
de230fd3 270 // Assign valid data to admin user (some generator-related code needs a valid user).
f5ceb6c2 271 $user = $DB->get_record('user', array('username' => 'admin'));
d79d5ac2 272 \core\session\manager::set_user($user);
5f4b4e91 273
41eb672b
DM
274 // Reset the browser if specified in config.php.
275 if (!empty($CFG->behat_restart_browser_after) && $this->running_javascript()) {
276 $now = time();
277 if (self::$lastbrowsersessionstart + $CFG->behat_restart_browser_after < $now) {
f59f6b6b 278 $session->restart();
41eb672b
DM
279 self::$lastbrowsersessionstart = $now;
280 }
281 }
282
5f4b4e91 283 // Start always in the the homepage.
3b7d3fb8 284 try {
f59f6b6b
DM
285 // Let's be conservative as we never know when new upstream issues will affect us.
286 $session->visit($this->locate_path('/'));
3b7d3fb8 287 } catch (UnknownError $e) {
3b7d3fb8
DM
288 $this->throw_unknown_exception($e);
289 }
870349ee 290
f59f6b6b 291
95b43d6b
DM
292 // Checking that the root path is a Moodle test site.
293 if (self::is_first_scenario()) {
294 $notestsiteexception = new Exception('The base URL (' . $CFG->wwwroot . ') is not a behat test site, ' .
3b7d3fb8 295 'ensure you started the built-in web server in the correct directory or your web server is correctly started and set up');
19f6703d 296 $this->find("xpath", "//head/child::title[normalize-space(.)='" . behat_util::BEHATSITENAME . "']", $notestsiteexception);
95b43d6b
DM
297
298 self::$initprocessesfinished = true;
299 }
3b0b5e57
RT
300 // Run all test with medium (1024x768) screen size, to avoid responsive problems.
301 $this->resize_window('medium');
f5ceb6c2
DM
302 }
303
f5ceb6c2 304 /**
d1e55a47 305 * Wait for JS to complete before beginning interacting with the DOM.
f5ceb6c2 306 *
f49cede7
DM
307 * Executed only when running against a real browser. We wrap it
308 * all in a try & catch to forward the exception to i_look_for_exceptions
309 * so the exception will be at scenario level, which causes a failure, by
310 * default would be at framework level, which will stop the execution of
311 * the run.
f5ceb6c2 312 *
3c71c15c 313 * @param StepEvent $event event fired before step.
6e2c417c 314 * @BeforeStep @javascript
f5ceb6c2 315 */
3c71c15c 316 public function before_step_javascript(StepEvent $event) {
f49cede7
DM
317
318 try {
319 $this->wait_for_pending_js();
320 self::$currentstepexception = null;
321 } catch (Exception $e) {
322 self::$currentstepexception = $e;
323 }
d1e55a47
DM
324 }
325
326 /**
327 * Wait for JS to complete after finishing the step.
328 *
329 * With this we ensure that there are not AJAX calls
330 * still in progress.
331 *
f49cede7
DM
332 * Executed only when running against a real browser. We wrap it
333 * all in a try & catch to forward the exception to i_look_for_exceptions
334 * so the exception will be at scenario level, which causes a failure, by
335 * default would be at framework level, which will stop the execution of
336 * the run.
d1e55a47 337 *
3c71c15c 338 * @param StepEvent $event event fired after step.
d1e55a47
DM
339 * @AfterStep @javascript
340 */
3c71c15c 341 public function after_step_javascript(StepEvent $event) {
5c0dfe32
DM
342 global $CFG;
343
344 // Save a screenshot if the step failed.
a964ead0 345 if (!empty($CFG->behat_faildump_path) &&
5c0dfe32
DM
346 $event->getResult() === StepEvent::FAILED) {
347 $this->take_screenshot($event);
348 }
f49cede7
DM
349
350 try {
351 $this->wait_for_pending_js();
352 self::$currentstepexception = null;
353 } catch (UnexpectedAlertOpen $e) {
354 self::$currentstepexception = $e;
355
356 // Accepting the alert so the framework can continue properly running
357 // the following scenarios. Some browsers already closes the alert, so
358 // wrapping in a try & catch.
359 try {
360 $this->getSession()->getDriver()->getWebDriverSession()->accept_alert();
361 } catch (Exception $e) {
362 // Catching the generic one as we never know how drivers reacts here.
363 }
364 } catch (Exception $e) {
365 self::$currentstepexception = $e;
366 }
d1e55a47
DM
367 }
368
3ec07614 369 /**
a964ead0
AN
370 * Execute any steps required after the step has finished.
371 *
372 * This includes creating an HTML dump of the content if there was a failure.
373 *
3c71c15c 374 * @param StepEvent $event event fired after step.
a964ead0
AN
375 * @AfterStep
376 */
3c71c15c 377 public function after_step(StepEvent $event) {
a2497e50 378 global $CFG, $DB;
a964ead0
AN
379
380 // Save the page content if the step failed.
381 if (!empty($CFG->behat_faildump_path) &&
382 $event->getResult() === StepEvent::FAILED) {
383 $this->take_contentdump($event);
384 }
a2497e50
MJ
385
386 // Abort any open transactions to prevent subsequent tests hanging.
387 // This does the same as abort_all_db_transactions(), but doesn't call error_log() as we don't
388 // want to see a message in the behat output.
389 if ($event->hasException()) {
390 if ($DB && $DB->is_transaction_started()) {
391 $DB->force_transaction_rollback();
392 }
393 }
a964ead0
AN
394 }
395
131d4ac2
T
396 /**
397 * Executed after scenario having switch window to restart session.
398 * This is needed to close all extra browser windows and starting
399 * one browser window.
400 *
401 * @param ScenarioEvent $event event fired after scenario.
402 * @AfterScenario @_switch_window
403 */
404 public function after_scenario_switchwindow(ScenarioEvent $event) {
5e483833
RT
405 for ($count = 0; $count < self::EXTENDED_TIMEOUT; $count) {
406 try {
407 $this->getSession()->restart();
408 break;
409 } catch (DriverException $e) {
410 // Wait for timeout and try again.
411 sleep(self::TIMEOUT);
412 }
413 }
414 // If session is not restarted above then it will try to start session before next scenario
415 // and if that fails then exception will be thrown.
131d4ac2
T
416 }
417
a964ead0
AN
418 /**
419 * Getter for self::$faildumpdirname
5c0dfe32
DM
420 *
421 * @return string
422 */
a964ead0
AN
423 protected function get_run_faildump_dir() {
424 return self::$faildumpdirname;
5c0dfe32
DM
425 }
426
427 /**
428 * Take screenshot when a step fails.
3ec07614 429 *
5c0dfe32
DM
430 * @throws Exception
431 * @param StepEvent $event
3ec07614 432 */
5c0dfe32 433 protected function take_screenshot(StepEvent $event) {
5c0dfe32
DM
434 // Goutte can't save screenshots.
435 if (!$this->running_javascript()) {
436 return false;
3ec07614 437 }
5c0dfe32 438
a964ead0
AN
439 list ($dir, $filename) = $this->get_faildump_filename($event, 'png');
440 $this->saveScreenshot($filename, $dir);
441 }
442
443 /**
444 * Take a dump of the page content when a step fails.
445 *
446 * @throws Exception
447 * @param StepEvent $event
448 */
449 protected function take_contentdump(StepEvent $event) {
450 list ($dir, $filename) = $this->get_faildump_filename($event, 'html');
451
452 $fh = fopen($dir . DIRECTORY_SEPARATOR . $filename, 'w');
453 fwrite($fh, $this->getSession()->getPage()->getContent());
454 fclose($fh);
455 }
456
457 /**
458 * Determine the full pathname to store a failure-related dump.
459 *
460 * This is used for content such as the DOM, and screenshots.
461 *
462 * @param StepEvent $event
167486b3 463 * @param String $filetype The file suffix to use. Limited to 4 chars.
a964ead0
AN
464 */
465 protected function get_faildump_filename(StepEvent $event, $filetype) {
466 global $CFG;
467
468 // All the contentdumps should be in the same parent dir.
469 if (!$faildumpdir = self::get_run_faildump_dir()) {
470 $faildumpdir = self::$faildumpdirname = date('Ymd_His');
5c0dfe32 471
a964ead0 472 $dir = $CFG->behat_faildump_path . DIRECTORY_SEPARATOR . $faildumpdir;
5c0dfe32 473
a964ead0 474 if (!is_dir($dir) && !mkdir($dir, $CFG->directorypermissions, true)) {
5c0dfe32 475 // It shouldn't, we already checked that the directory is writable.
a964ead0 476 throw new Exception('No directories can be created inside $CFG->behat_faildump_path, check the directory permissions.');
5c0dfe32
DM
477 }
478 } else {
479 // We will always need to know the full path.
a964ead0 480 $dir = $CFG->behat_faildump_path . DIRECTORY_SEPARATOR . $faildumpdir;
5c0dfe32
DM
481 }
482
483 // The scenario title + the failed step text.
a964ead0 484 // We want a i-am-the-scenario-title_i-am-the-failed-step.$filetype format.
5c0dfe32 485 $filename = $event->getStep()->getParent()->getTitle() . '_' . $event->getStep()->getText();
167486b3
DM
486 $filename = preg_replace('/([^a-zA-Z0-9\_]+)/', '-', $filename);
487
a2c271dd 488 // File name limited to 255 characters. Leaving 4 chars for the file
167486b3 489 // extension as we allow .png for images and .html for DOM contents.
a2c271dd 490 $filename = substr($filename, 0, 250) . '.' . $filetype;
5c0dfe32 491
a964ead0 492 return array($dir, $filename);
3ec07614
DW
493 }
494
d1e55a47
DM
495 /**
496 * Waits for all the JS to be loaded.
497 *
afe9f42a 498 * @throws \Exception
d1e55a47
DM
499 * @throws NoSuchWindow
500 * @throws UnknownError
501 * @return bool True or false depending whether all the JS is loaded or not.
502 */
503 protected function wait_for_pending_js() {
504
c1faf86b
DM
505 // We don't use behat_base::spin() here as we don't want to end up with an exception
506 // if the page & JSs don't finish loading properly.
507 for ($i = 0; $i < self::EXTENDED_TIMEOUT * 10; $i++) {
6e2c417c
DW
508 $pending = '';
509 try {
2ca7f4e6
RT
510 $jscode =
511 'if (typeof M === "undefined") {
512 if (document.readyState === "complete") {
513 return "";
514 } else {
515 return "incomplete";
516 }
517 } else if (' . self::PAGE_READY_JS . ') {
518 return "";
519 } else {
520 return M.util.pending_js.join(":");
521 }';
d1e55a47 522 $pending = $this->getSession()->evaluateScript($jscode);
6e2c417c 523 } catch (NoSuchWindow $nsw) {
d1e55a47 524 // We catch an exception here, in case we just closed the window we were interacting with.
6e2c417c
DW
525 // No javascript is running if there is no window right?
526 $pending = '';
d1e55a47 527 } catch (UnknownError $e) {
f49cede7
DM
528 // M is not defined when the window or the frame don't exist anymore.
529 if (strstr($e->getMessage(), 'M is not defined') != false) {
530 $pending = '';
531 }
28abad1a 532 }
d1e55a47
DM
533
534 // If there are no pending JS we stop waiting.
6e2c417c 535 if ($pending === '') {
d1e55a47 536 return true;
6e2c417c 537 }
d1e55a47 538
6e2c417c
DW
539 // 0.1 seconds.
540 usleep(100000);
1303eb29 541 }
d1e55a47 542
afe9f42a
DM
543 // Timeout waiting for JS to complete. It will be catched and forwarded to behat_hooks::i_look_for_exceptions().
544 // It is unlikely that Javascript code of a page or an AJAX request needs more than self::EXTENDED_TIMEOUT seconds
545 // to be loaded, although when pages contains Javascript errors M.util.js_complete() can not be executed, so the
546 // number of JS pending code and JS completed code will not match and we will reach this point.
547 throw new \Exception('Javascript code and/or AJAX requests are not ready after ' . self::EXTENDED_TIMEOUT .
548 ' seconds. There is a Javascript error or the code is extremely slow.');
f5ceb6c2
DM
549 }
550
5f4b4e91
DM
551 /**
552 * Internal step definition to find exceptions, debugging() messages and PHP debug messages.
553 *
554 * Part of behat_hooks class as is part of the testing framework, is auto-executed
555 * after each step so no features will splicitly use it.
556 *
557 * @Given /^I look for exceptions$/
f49cede7 558 * @throw Exception Unknown type, depending on what we caught in the hook or basic \Exception.
5f4b4e91
DM
559 * @see Moodle\BehatExtension\Tester\MoodleStepTester
560 */
561 public function i_look_for_exceptions() {
562
f49cede7
DM
563 // If the step already failed in a hook throw the exception.
564 if (!is_null(self::$currentstepexception)) {
565 throw self::$currentstepexception;
566 }
567
217e8e59
DM
568 // Wrap in try in case we were interacting with a closed window.
569 try {
5f4b4e91 570
217e8e59 571 // Exceptions.
3e76c7fa 572 $exceptionsxpath = "//div[@data-rel='fatalerror']";
8aea365f 573 // Debugging messages.
3e76c7fa 574 $debuggingxpath = "//div[@data-rel='debugging']";
8aea365f 575 // PHP debug messages.
3e76c7fa 576 $phperrorxpath = "//div[@data-rel='phpdebugmessage']";
8aea365f 577 // Any other backtrace.
6cd21356 578 $othersxpath = "(//*[contains(., ': call to ')])[1]";
8aea365f
DM
579
580 $xpaths = array($exceptionsxpath, $debuggingxpath, $phperrorxpath, $othersxpath);
581 $joinedxpath = implode(' | ', $xpaths);
582
583 // Joined xpath expression. Most of the time there will be no exceptions, so this pre-check
584 // is faster than to send the 4 xpath queries for each step.
585 if (!$this->getSession()->getDriver()->find($joinedxpath)) {
586 return;
587 }
588
589 // Exceptions.
590 if ($errormsg = $this->getSession()->getPage()->find('xpath', $exceptionsxpath)) {
5f4b4e91 591
217e8e59 592 // Getting the debugging info and the backtrace.
f8b589c9
RT
593 $errorinfoboxes = $this->getSession()->getPage()->findAll('css', 'div.alert-error');
594 // If errorinfoboxes is empty, try find notifytiny (original) class.
595 if (empty($errorinfoboxes)) {
596 $errorinfoboxes = $this->getSession()->getPage()->findAll('css', 'div.notifytiny');
597 }
217e8e59
DM
598 $errorinfo = $this->get_debug_text($errorinfoboxes[0]->getHtml()) . "\n" .
599 $this->get_debug_text($errorinfoboxes[1]->getHtml());
5f4b4e91 600
217e8e59
DM
601 $msg = "Moodle exception: " . $errormsg->getText() . "\n" . $errorinfo;
602 throw new \Exception(html_entity_decode($msg));
603 }
604
605 // Debugging messages.
8aea365f 606 if ($debuggingmessages = $this->getSession()->getPage()->findAll('xpath', $debuggingxpath)) {
217e8e59
DM
607 $msgs = array();
608 foreach ($debuggingmessages as $debuggingmessage) {
609 $msgs[] = $this->get_debug_text($debuggingmessage->getHtml());
610 }
611 $msg = "debugging() message/s found:\n" . implode("\n", $msgs);
612 throw new \Exception(html_entity_decode($msg));
5f4b4e91 613 }
90ed22ab 614
217e8e59 615 // PHP debug messages.
8aea365f 616 if ($phpmessages = $this->getSession()->getPage()->findAll('xpath', $phperrorxpath)) {
90ed22ab 617
217e8e59
DM
618 $msgs = array();
619 foreach ($phpmessages as $phpmessage) {
620 $msgs[] = $this->get_debug_text($phpmessage->getHtml());
621 }
622 $msg = "PHP debug message/s found:\n" . implode("\n", $msgs);
623 throw new \Exception(html_entity_decode($msg));
90ed22ab 624 }
bbd802f7 625
217e8e59 626 // Any other backtrace.
9a1f4922
DM
627 // First looking through xpath as it is faster than get and parse the whole page contents,
628 // we get the contents and look for matches once we found something to suspect that there is a backtrace.
8aea365f 629 if ($this->getSession()->getDriver()->find($othersxpath)) {
9a1f4922
DM
630 $backtracespattern = '/(line [0-9]* of [^:]*: call to [\->&;:a-zA-Z_\x7f-\xff][\->&;:a-zA-Z0-9_\x7f-\xff]*)/';
631 if (preg_match_all($backtracespattern, $this->getSession()->getPage()->getContent(), $backtraces)) {
632 $msgs = array();
633 foreach ($backtraces[0] as $backtrace) {
634 $msgs[] = $backtrace . '()';
635 }
636 $msg = "Other backtraces found:\n" . implode("\n", $msgs);
637 throw new \Exception(htmlentities($msg));
217e8e59 638 }
bbd802f7 639 }
217e8e59
DM
640
641 } catch (NoSuchWindow $e) {
642 // If we were interacting with a popup window it will not exists after closing it.
bbd802f7 643 }
5f4b4e91
DM
644 }
645
646 /**
647 * Converts HTML tags to line breaks to display the info in CLI
648 *
649 * @param string $html
650 * @return string
651 */
652 protected function get_debug_text($html) {
653
654 // Replacing HTML tags for new lines and keeping only the text.
655 $notags = preg_replace('/<+\s*\/*\s*([A-Z][A-Z0-9]*)\b[^>]*\/*\s*>*/i', "\n", $html);
656 return preg_replace("/(\n)+/s", "\n", $notags);
657 }
658
95b43d6b
DM
659 /**
660 * Returns whether the first scenario of the suite is running
661 *
662 * @return bool
663 */
664 protected static function is_first_scenario() {
665 return !(self::$initprocessesfinished);
666 }
3b7d3fb8
DM
667
668 /**
669 * Throws an exception after appending an extra info text.
670 *
671 * @throws Exception
672 * @param UnknownError $exception
673 * @return void
674 */
675 protected function throw_unknown_exception(UnknownError $exception) {
676 $text = get_string('unknownexceptioninfo', 'tool_behat');
677 throw new Exception($text . PHP_EOL . $exception->getMessage());
678 }
679
f5ceb6c2 680}
a964ead0 681