MDL-48302 behat: Reset all data, before starting behat suite
[moodle.git] / lib / tests / behat / behat_hooks.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  * 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  */
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 use Behat\Behat\Event\SuiteEvent as SuiteEvent,
33     Behat\Behat\Event\ScenarioEvent as ScenarioEvent,
34     Behat\Behat\Event\StepEvent as StepEvent,
35     Behat\Mink\Exception\DriverException as DriverException,
36     WebDriver\Exception\NoSuchWindow as NoSuchWindow,
37     WebDriver\Exception\UnexpectedAlertOpen as UnexpectedAlertOpen,
38     WebDriver\Exception\UnknownError as UnknownError,
39     WebDriver\Exception\CurlExec as CurlExec,
40     WebDriver\Exception\NoAlertOpenError as NoAlertOpenError;
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  */
58 class behat_hooks extends behat_base {
60     /**
61      * @var Last browser session start time.
62      */
63     protected static $lastbrowsersessionstart = 0;
65     /**
66      * @var For actions that should only run once.
67      */
68     protected static $initprocessesfinished = false;
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;
80     /**
81      * If we are saving any kind of dump on failure we should use the same parent dir during a run.
82      *
83      * @var The parent dir name
84      */
85     protected static $faildumpdirname = false;
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;
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);
105         define('CLI_SCRIPT', 1);
107         // With BEHAT_TEST we will be using $CFG->behat_* instead of $CFG->dataroot, $CFG->prefix and $CFG->wwwroot.
108         require_once(__DIR__ . '/../../../config.php');
110         // Now that we are MOODLE_INTERNAL.
111         require_once(__DIR__ . '/../../behat/classes/behat_command.php');
112         require_once(__DIR__ . '/../../behat/classes/behat_selectors.php');
113         require_once(__DIR__ . '/../../behat/classes/behat_context_helper.php');
114         require_once(__DIR__ . '/../../behat/classes/util.php');
115         require_once(__DIR__ . '/../../testing/classes/test_lock.php');
116         require_once(__DIR__ . '/../../testing/classes/nasty_strings.php');
118         // Avoids vendor/bin/behat to be executed directly without test environment enabled
119         // to prevent undesired db & dataroot modifications, this is also checked
120         // before each scenario (accidental user deletes) in the BeforeScenario hook.
122         if (!behat_util::is_test_mode_enabled()) {
123             throw new Exception('Behat only can run if test mode is enabled. More info in ' . behat_command::DOCS_URL . '#Running_tests');
124         }
126         // Reset all data, before checking for is_server_running.
127         // If not done, then it can return apache error, while running tests.
128         behat_util::reset_all_data();
130         if (!behat_util::is_server_running()) {
131             throw new Exception($CFG->behat_wwwroot .
132                 ' is not available, ensure you specified correct url and that the server is set up and started.' .
133                 ' More info in ' . behat_command::DOCS_URL . '#Running_tests');
134         }
136         // Prevents using outdated data, upgrade script would start and tests would fail.
137         if (!behat_util::is_test_data_updated()) {
138             $commandpath = 'php admin/tool/behat/cli/init.php';
139             throw new Exception("Your behat test site is outdated, please run\n\n    " .
140                     $commandpath . "\n\nfrom your moodle dirroot to drop and install the behat test site again.");
141         }
142         // Avoid parallel tests execution, it continues when the previous lock is released.
143         test_lock::acquire('behat');
145         // Store the browser reset time if reset after N seconds is specified in config.php.
146         if (!empty($CFG->behat_restart_browser_after)) {
147             // Store the initial browser session opening.
148             self::$lastbrowsersessionstart = time();
149         }
151         if (!empty($CFG->behat_faildump_path) && !is_writable($CFG->behat_faildump_path)) {
152             throw new Exception('You set $CFG->behat_faildump_path to a non-writable directory');
153         }
154     }
156     /**
157      * Resets the test environment.
158      *
159      * @throws coding_exception If here we are not using the test database it should be because of a coding error
160      * @BeforeScenario
161      */
162     public function before_scenario($event) {
163         global $DB, $SESSION, $CFG;
165         // As many checks as we can.
166         if (!defined('BEHAT_TEST') ||
167                !defined('BEHAT_SITE_RUNNING') ||
168                php_sapi_name() != 'cli' ||
169                !behat_util::is_test_mode_enabled() ||
170                !behat_util::is_test_site()) {
171             throw new coding_exception('Behat only can modify the test database and the test dataroot!');
172         }
174         $moreinfo = 'More info in ' . behat_command::DOCS_URL . '#Running_tests';
175         $driverexceptionmsg = 'Selenium server is not running, you need to start it to run tests that involve Javascript. ' . $moreinfo;
176         try {
177             $session = $this->getSession();
178         } catch (CurlExec $e) {
179             // Exception thrown by WebDriver, so only @javascript tests will be caugth; in
180             // behat_util::is_server_running() we already checked that the server is running.
181             throw new Exception($driverexceptionmsg);
182         } catch (DriverException $e) {
183             throw new Exception($driverexceptionmsg);
184         } catch (UnknownError $e) {
185             // Generic 'I have no idea' Selenium error. Custom exception to provide more feedback about possible solutions.
186             $this->throw_unknown_exception($e);
187         }
190         // We need the Mink session to do it and we do it only before the first scenario.
191         if (self::is_first_scenario()) {
192             behat_selectors::register_moodle_selectors($session);
193             behat_context_helper::set_session($session);
194         }
196         // Reset $SESSION.
197         \core\session\manager::init_empty_session();
199         behat_util::reset_all_data();
201         // Assign valid data to admin user (some generator-related code needs a valid user).
202         $user = $DB->get_record('user', array('username' => 'admin'));
203         \core\session\manager::set_user($user);
205         // Reset the browser if specified in config.php.
206         if (!empty($CFG->behat_restart_browser_after) && $this->running_javascript()) {
207             $now = time();
208             if (self::$lastbrowsersessionstart + $CFG->behat_restart_browser_after < $now) {
209                 $session->restart();
210                 self::$lastbrowsersessionstart = $now;
211             }
212         }
214         // Start always in the the homepage.
215         try {
216             // Let's be conservative as we never know when new upstream issues will affect us.
217             $session->visit($this->locate_path('/'));
218         } catch (UnknownError $e) {
219             $this->throw_unknown_exception($e);
220         }
223         // Checking that the root path is a Moodle test site.
224         if (self::is_first_scenario()) {
225             $notestsiteexception = new Exception('The base URL (' . $CFG->wwwroot . ') is not a behat test site, ' .
226                 'ensure you started the built-in web server in the correct directory or your web server is correctly started and set up');
227             $this->find("xpath", "//head/child::title[normalize-space(.)='" . behat_util::BEHATSITENAME . "']", $notestsiteexception);
229             self::$initprocessesfinished = true;
230         }
231         // Run all test with medium (1024x768) screen size, to avoid responsive problems.
232         $this->resize_window('medium');
233     }
235     /**
236      * Wait for JS to complete before beginning interacting with the DOM.
237      *
238      * Executed only when running against a real browser. We wrap it
239      * all in a try & catch to forward the exception to i_look_for_exceptions
240      * so the exception will be at scenario level, which causes a failure, by
241      * default would be at framework level, which will stop the execution of
242      * the run.
243      *
244      * @BeforeStep @javascript
245      */
246     public function before_step_javascript($event) {
248         try {
249             $this->wait_for_pending_js();
250             self::$currentstepexception = null;
251         } catch (Exception $e) {
252             self::$currentstepexception = $e;
253         }
254     }
256     /**
257      * Wait for JS to complete after finishing the step.
258      *
259      * With this we ensure that there are not AJAX calls
260      * still in progress.
261      *
262      * Executed only when running against a real browser. We wrap it
263      * all in a try & catch to forward the exception to i_look_for_exceptions
264      * so the exception will be at scenario level, which causes a failure, by
265      * default would be at framework level, which will stop the execution of
266      * the run.
267      *
268      * @AfterStep @javascript
269      */
270     public function after_step_javascript($event) {
271         global $CFG;
273         // Save a screenshot if the step failed.
274         if (!empty($CFG->behat_faildump_path) &&
275                 $event->getResult() === StepEvent::FAILED) {
276             $this->take_screenshot($event);
277         }
279         try {
280             $this->wait_for_pending_js();
281             self::$currentstepexception = null;
282         } catch (UnexpectedAlertOpen $e) {
283             self::$currentstepexception = $e;
285             // Accepting the alert so the framework can continue properly running
286             // the following scenarios. Some browsers already closes the alert, so
287             // wrapping in a try & catch.
288             try {
289                 $this->getSession()->getDriver()->getWebDriverSession()->accept_alert();
290             } catch (Exception $e) {
291                 // Catching the generic one as we never know how drivers reacts here.
292             }
293         } catch (Exception $e) {
294             self::$currentstepexception = $e;
295         }
296     }
298     /**
299      * Execute any steps required after the step has finished.
300      *
301      * This includes creating an HTML dump of the content if there was a failure.
302      *
303      * @AfterStep
304      */
305     public function after_step($event) {
306         global $CFG;
308         // Save the page content if the step failed.
309         if (!empty($CFG->behat_faildump_path) &&
310                 $event->getResult() === StepEvent::FAILED) {
311             $this->take_contentdump($event);
312         }
313     }
315     /**
316      * Getter for self::$faildumpdirname
317      *
318      * @return string
319      */
320     protected function get_run_faildump_dir() {
321         return self::$faildumpdirname;
322     }
324     /**
325      * Take screenshot when a step fails.
326      *
327      * @throws Exception
328      * @param StepEvent $event
329      */
330     protected function take_screenshot(StepEvent $event) {
331         // Goutte can't save screenshots.
332         if (!$this->running_javascript()) {
333             return false;
334         }
336         list ($dir, $filename) = $this->get_faildump_filename($event, 'png');
337         $this->saveScreenshot($filename, $dir);
338     }
340     /**
341      * Take a dump of the page content when a step fails.
342      *
343      * @throws Exception
344      * @param StepEvent $event
345      */
346     protected function take_contentdump(StepEvent $event) {
347         list ($dir, $filename) = $this->get_faildump_filename($event, 'html');
349         $fh = fopen($dir . DIRECTORY_SEPARATOR . $filename, 'w');
350         fwrite($fh, $this->getSession()->getPage()->getContent());
351         fclose($fh);
352     }
354     /**
355      * Determine the full pathname to store a failure-related dump.
356      *
357      * This is used for content such as the DOM, and screenshots.
358      *
359      * @param StepEvent $event
360      * @param String $filetype The file suffix to use. Limited to 4 chars.
361      */
362     protected function get_faildump_filename(StepEvent $event, $filetype) {
363         global $CFG;
365         // All the contentdumps should be in the same parent dir.
366         if (!$faildumpdir = self::get_run_faildump_dir()) {
367             $faildumpdir = self::$faildumpdirname = date('Ymd_His');
369             $dir = $CFG->behat_faildump_path . DIRECTORY_SEPARATOR . $faildumpdir;
371             if (!is_dir($dir) && !mkdir($dir, $CFG->directorypermissions, true)) {
372                 // It shouldn't, we already checked that the directory is writable.
373                 throw new Exception('No directories can be created inside $CFG->behat_faildump_path, check the directory permissions.');
374             }
375         } else {
376             // We will always need to know the full path.
377             $dir = $CFG->behat_faildump_path . DIRECTORY_SEPARATOR . $faildumpdir;
378         }
380         // The scenario title + the failed step text.
381         // We want a i-am-the-scenario-title_i-am-the-failed-step.$filetype format.
382         $filename = $event->getStep()->getParent()->getTitle() . '_' . $event->getStep()->getText();
383         $filename = preg_replace('/([^a-zA-Z0-9\_]+)/', '-', $filename);
385         // File name limited to 255 characters. Leaving 4 chars for the file
386         // extension as we allow .png for images and .html for DOM contents.
387         $filename = substr($filename, 0, 250) . '.' . $filetype;
389         return array($dir, $filename);
390     }
392     /**
393      * Waits for all the JS to be loaded.
394      *
395      * @throws \Exception
396      * @throws NoSuchWindow
397      * @throws UnknownError
398      * @return bool True or false depending whether all the JS is loaded or not.
399      */
400     protected function wait_for_pending_js() {
402         // We don't use behat_base::spin() here as we don't want to end up with an exception
403         // if the page & JSs don't finish loading properly.
404         for ($i = 0; $i < self::EXTENDED_TIMEOUT * 10; $i++) {
405             $pending = '';
406             try {
407                 $jscode = 'return ' . self::PAGE_READY_JS . ' ? "" : M.util.pending_js.join(":");';
408                 $pending = $this->getSession()->evaluateScript($jscode);
409             } catch (NoSuchWindow $nsw) {
410                 // We catch an exception here, in case we just closed the window we were interacting with.
411                 // No javascript is running if there is no window right?
412                 $pending = '';
413             } catch (UnknownError $e) {
414                 // M is not defined when the window or the frame don't exist anymore.
415                 if (strstr($e->getMessage(), 'M is not defined') != false) {
416                     $pending = '';
417                 }
418             }
420             // If there are no pending JS we stop waiting.
421             if ($pending === '') {
422                 return true;
423             }
425             // 0.1 seconds.
426             usleep(100000);
427         }
429         // Timeout waiting for JS to complete. It will be catched and forwarded to behat_hooks::i_look_for_exceptions().
430         // It is unlikely that Javascript code of a page or an AJAX request needs more than self::EXTENDED_TIMEOUT seconds
431         // to be loaded, although when pages contains Javascript errors M.util.js_complete() can not be executed, so the
432         // number of JS pending code and JS completed code will not match and we will reach this point.
433         throw new \Exception('Javascript code and/or AJAX requests are not ready after ' . self::EXTENDED_TIMEOUT .
434             ' seconds. There is a Javascript error or the code is extremely slow.');
435     }
437     /**
438      * Internal step definition to find exceptions, debugging() messages and PHP debug messages.
439      *
440      * Part of behat_hooks class as is part of the testing framework, is auto-executed
441      * after each step so no features will splicitly use it.
442      *
443      * @Given /^I look for exceptions$/
444      * @throw Exception Unknown type, depending on what we caught in the hook or basic \Exception.
445      * @see Moodle\BehatExtension\Tester\MoodleStepTester
446      */
447     public function i_look_for_exceptions() {
449         // If the step already failed in a hook throw the exception.
450         if (!is_null(self::$currentstepexception)) {
451             throw self::$currentstepexception;
452         }
454         // Wrap in try in case we were interacting with a closed window.
455         try {
457             // Exceptions.
458             $exceptionsxpath = "//div[@data-rel='fatalerror']";
459             // Debugging messages.
460             $debuggingxpath = "//div[@data-rel='debugging']";
461             // PHP debug messages.
462             $phperrorxpath = "//div[@data-rel='phpdebugmessage']";
463             // Any other backtrace.
464             $othersxpath = "(//*[contains(., ': call to ')])[1]";
466             $xpaths = array($exceptionsxpath, $debuggingxpath, $phperrorxpath, $othersxpath);
467             $joinedxpath = implode(' | ', $xpaths);
469             // Joined xpath expression. Most of the time there will be no exceptions, so this pre-check
470             // is faster than to send the 4 xpath queries for each step.
471             if (!$this->getSession()->getDriver()->find($joinedxpath)) {
472                 return;
473             }
475             // Exceptions.
476             if ($errormsg = $this->getSession()->getPage()->find('xpath', $exceptionsxpath)) {
478                 // Getting the debugging info and the backtrace.
479                 $errorinfoboxes = $this->getSession()->getPage()->findAll('css', 'div.alert-error');
480                 // If errorinfoboxes is empty, try find notifytiny (original) class.
481                 if (empty($errorinfoboxes)) {
482                     $errorinfoboxes = $this->getSession()->getPage()->findAll('css', 'div.notifytiny');
483                 }
484                 $errorinfo = $this->get_debug_text($errorinfoboxes[0]->getHtml()) . "\n" .
485                     $this->get_debug_text($errorinfoboxes[1]->getHtml());
487                 $msg = "Moodle exception: " . $errormsg->getText() . "\n" . $errorinfo;
488                 throw new \Exception(html_entity_decode($msg));
489             }
491             // Debugging messages.
492             if ($debuggingmessages = $this->getSession()->getPage()->findAll('xpath', $debuggingxpath)) {
493                 $msgs = array();
494                 foreach ($debuggingmessages as $debuggingmessage) {
495                     $msgs[] = $this->get_debug_text($debuggingmessage->getHtml());
496                 }
497                 $msg = "debugging() message/s found:\n" . implode("\n", $msgs);
498                 throw new \Exception(html_entity_decode($msg));
499             }
501             // PHP debug messages.
502             if ($phpmessages = $this->getSession()->getPage()->findAll('xpath', $phperrorxpath)) {
504                 $msgs = array();
505                 foreach ($phpmessages as $phpmessage) {
506                     $msgs[] = $this->get_debug_text($phpmessage->getHtml());
507                 }
508                 $msg = "PHP debug message/s found:\n" . implode("\n", $msgs);
509                 throw new \Exception(html_entity_decode($msg));
510             }
512             // Any other backtrace.
513             // First looking through xpath as it is faster than get and parse the whole page contents,
514             // we get the contents and look for matches once we found something to suspect that there is a backtrace.
515             if ($this->getSession()->getDriver()->find($othersxpath)) {
516                 $backtracespattern = '/(line [0-9]* of [^:]*: call to [\->&;:a-zA-Z_\x7f-\xff][\->&;:a-zA-Z0-9_\x7f-\xff]*)/';
517                 if (preg_match_all($backtracespattern, $this->getSession()->getPage()->getContent(), $backtraces)) {
518                     $msgs = array();
519                     foreach ($backtraces[0] as $backtrace) {
520                         $msgs[] = $backtrace . '()';
521                     }
522                     $msg = "Other backtraces found:\n" . implode("\n", $msgs);
523                     throw new \Exception(htmlentities($msg));
524                 }
525             }
527         } catch (NoSuchWindow $e) {
528             // If we were interacting with a popup window it will not exists after closing it.
529         }
530     }
532     /**
533      * Converts HTML tags to line breaks to display the info in CLI
534      *
535      * @param string $html
536      * @return string
537      */
538     protected function get_debug_text($html) {
540         // Replacing HTML tags for new lines and keeping only the text.
541         $notags = preg_replace('/<+\s*\/*\s*([A-Z][A-Z0-9]*)\b[^>]*\/*\s*>*/i', "\n", $html);
542         return preg_replace("/(\n)+/s", "\n", $notags);
543     }
545     /**
546      * Returns whether the first scenario of the suite is running
547      *
548      * @return bool
549      */
550     protected static function is_first_scenario() {
551         return !(self::$initprocessesfinished);
552     }
554     /**
555      * Throws an exception after appending an extra info text.
556      *
557      * @throws Exception
558      * @param UnknownError $exception
559      * @return void
560      */
561     protected function throw_unknown_exception(UnknownError $exception) {
562         $text = get_string('unknownexceptioninfo', 'tool_behat');
563         throw new Exception($text . PHP_EOL . $exception->getMessage());
564     }