MDL-50163 behat: Restart session after scenario with switch_window
[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\FeatureEvent as FeatureEvent,
35     Behat\Behat\Event\OutlineExampleEvent as OutlineExampleEvent,
36     Behat\Behat\Event\StepEvent as StepEvent,
37     Behat\Mink\Exception\DriverException as DriverException,
38     WebDriver\Exception\NoSuchWindow as NoSuchWindow,
39     WebDriver\Exception\UnexpectedAlertOpen as UnexpectedAlertOpen,
40     WebDriver\Exception\UnknownError as UnknownError,
41     WebDriver\Exception\CurlExec as CurlExec,
42     WebDriver\Exception\NoAlertOpenError as NoAlertOpenError;
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  */
60 class behat_hooks extends behat_base {
62     /**
63      * @var Last browser session start time.
64      */
65     protected static $lastbrowsersessionstart = 0;
67     /**
68      * @var For actions that should only run once.
69      */
70     protected static $initprocessesfinished = false;
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;
82     /**
83      * If we are saving any kind of dump on failure we should use the same parent dir during a run.
84      *
85      * @var The parent dir name
86      */
87     protected static $faildumpdirname = false;
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();
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      *
102      * @param SuiteEvent $event event before suite.
103      * @static
104      * @throws Exception
105      * @BeforeSuite
106      */
107     public static function before_suite(SuiteEvent $event) {
108         global $CFG;
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);
115         define('CLI_SCRIPT', 1);
117         // With BEHAT_TEST we will be using $CFG->behat_* instead of $CFG->dataroot, $CFG->prefix and $CFG->wwwroot.
118         require_once(__DIR__ . '/../../../config.php');
120         // Now that we are MOODLE_INTERNAL.
121         require_once(__DIR__ . '/../../behat/classes/behat_command.php');
122         require_once(__DIR__ . '/../../behat/classes/behat_selectors.php');
123         require_once(__DIR__ . '/../../behat/classes/behat_context_helper.php');
124         require_once(__DIR__ . '/../../behat/classes/util.php');
125         require_once(__DIR__ . '/../../testing/classes/test_lock.php');
126         require_once(__DIR__ . '/../../testing/classes/nasty_strings.php');
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.
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         }
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();
140         if (!behat_util::is_server_running()) {
141             throw new Exception($CFG->behat_wwwroot .
142                 ' is not available, ensure you specified correct url and that the server is set up and started.' .
143                 ' More info in ' . behat_command::DOCS_URL . '#Running_tests');
144         }
146         // Prevents using outdated data, upgrade script would start and tests would fail.
147         if (!behat_util::is_test_data_updated()) {
148             $commandpath = 'php admin/tool/behat/cli/init.php';
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.");
151         }
152         // Avoid parallel tests execution, it continues when the previous lock is released.
153         test_lock::acquire('behat');
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         }
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');
163         }
164     }
166     /**
167      * Gives access to moodle codebase, to keep track of feature start time.
168      *
169      * @param FeatureEvent $event event fired before feature.
170      * @BeforeFeature
171      */
172     public static function before_feature(FeatureEvent $event) {
173         if (!defined('BEHAT_FEATURE_TIMING_FILE')) {
174             return;
175         }
176         $file = $event->getFeature()->getFile();
177         self::$timings[$file] = microtime(true);
178     }
180     /**
181      * Gives access to moodle codebase, to keep track of feature end time.
182      *
183      * @param FeatureEvent $event event fired after feature.
184      * @AfterFeature
185      */
186     public static function after_feature(FeatureEvent $event) {
187         if (!defined('BEHAT_FEATURE_TIMING_FILE')) {
188             return;
189         }
190         $file = $event->getFeature()->getFile();
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     }
198     /**
199      * Gives access to moodle codebase, to keep track of suite timings.
200      *
201      * @param SuiteEvent $event event fired after suite.
202      * @AfterSuite
203      */
204     public static function after_suite(SuiteEvent $event) {
205         if (!defined('BEHAT_FEATURE_TIMING_FILE')) {
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         }
214         if ($existing = @json_decode(file_get_contents(BEHAT_FEATURE_TIMING_FILE), true)) {
215             self::$timings = array_merge($existing, self::$timings);
216         }
217         arsort(self::$timings);
218         @file_put_contents(BEHAT_FEATURE_TIMING_FILE, json_encode(self::$timings, JSON_PRETTY_PRINT));
219     }
221     /**
222      * Resets the test environment.
223      *
224      * @param OutlineExampleEvent|ScenarioEvent $event event fired before scenario.
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;
231         // As many checks as we can.
232         if (!defined('BEHAT_TEST') ||
233                !defined('BEHAT_SITE_RUNNING') ||
234                php_sapi_name() != 'cli' ||
235                !behat_util::is_test_mode_enabled() ||
236                !behat_util::is_test_site()) {
237             throw new coding_exception('Behat only can modify the test database and the test dataroot!');
238         }
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;
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.
247             throw new Exception($driverexceptionmsg);
248         } catch (DriverException $e) {
249             throw new Exception($driverexceptionmsg);
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         }
256         // We need the Mink session to do it and we do it only before the first scenario.
257         if (self::is_first_scenario()) {
258             behat_selectors::register_moodle_selectors($session);
259             behat_context_helper::set_session($session);
260         }
262         // Reset mink session between the scenarios.
263         $session->reset();
265         // Reset $SESSION.
266         \core\session\manager::init_empty_session();
268         behat_util::reset_all_data();
270         // Assign valid data to admin user (some generator-related code needs a valid user).
271         $user = $DB->get_record('user', array('username' => 'admin'));
272         \core\session\manager::set_user($user);
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) {
278                 $session->restart();
279                 self::$lastbrowsersessionstart = $now;
280             }
281         }
283         // Start always in the the homepage.
284         try {
285             // Let's be conservative as we never know when new upstream issues will affect us.
286             $session->visit($this->locate_path('/'));
287         } catch (UnknownError $e) {
288             $this->throw_unknown_exception($e);
289         }
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, ' .
295                 'ensure you started the built-in web server in the correct directory or your web server is correctly started and set up');
296             $this->find("xpath", "//head/child::title[normalize-space(.)='" . behat_util::BEHATSITENAME . "']", $notestsiteexception);
298             self::$initprocessesfinished = true;
299         }
300         // Run all test with medium (1024x768) screen size, to avoid responsive problems.
301         $this->resize_window('medium');
302     }
304     /**
305      * Wait for JS to complete before beginning interacting with the DOM.
306      *
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.
312      *
313      * @param StepEvent $event event fired before step.
314      * @BeforeStep @javascript
315      */
316     public function before_step_javascript(StepEvent $event) {
318         try {
319             $this->wait_for_pending_js();
320             self::$currentstepexception = null;
321         } catch (Exception $e) {
322             self::$currentstepexception = $e;
323         }
324     }
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      *
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.
337      *
338      * @param StepEvent $event event fired after step.
339      * @AfterStep @javascript
340      */
341     public function after_step_javascript(StepEvent $event) {
342         global $CFG;
344         // Save a screenshot if the step failed.
345         if (!empty($CFG->behat_faildump_path) &&
346                 $event->getResult() === StepEvent::FAILED) {
347             $this->take_screenshot($event);
348         }
350         try {
351             $this->wait_for_pending_js();
352             self::$currentstepexception = null;
353         } catch (UnexpectedAlertOpen $e) {
354             self::$currentstepexception = $e;
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         }
367     }
369     /**
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      *
374      * @param StepEvent $event event fired after step.
375      * @AfterStep
376      */
377     public function after_step(StepEvent $event) {
378         global $CFG;
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         }
385     }
387     /**
388      * Executed after scenario having switch window to restart session.
389      * This is needed to close all extra browser windows and starting
390      * one browser window.
391      *
392      * @param ScenarioEvent $event event fired after scenario.
393      * @AfterScenario @_switch_window
394      */
395     public function after_scenario_switchwindow(ScenarioEvent $event) {
396         $this->getSession()->restart();
397     }
399     /**
400      * Getter for self::$faildumpdirname
401      *
402      * @return string
403      */
404     protected function get_run_faildump_dir() {
405         return self::$faildumpdirname;
406     }
408     /**
409      * Take screenshot when a step fails.
410      *
411      * @throws Exception
412      * @param StepEvent $event
413      */
414     protected function take_screenshot(StepEvent $event) {
415         // Goutte can't save screenshots.
416         if (!$this->running_javascript()) {
417             return false;
418         }
420         list ($dir, $filename) = $this->get_faildump_filename($event, 'png');
421         $this->saveScreenshot($filename, $dir);
422     }
424     /**
425      * Take a dump of the page content when a step fails.
426      *
427      * @throws Exception
428      * @param StepEvent $event
429      */
430     protected function take_contentdump(StepEvent $event) {
431         list ($dir, $filename) = $this->get_faildump_filename($event, 'html');
433         $fh = fopen($dir . DIRECTORY_SEPARATOR . $filename, 'w');
434         fwrite($fh, $this->getSession()->getPage()->getContent());
435         fclose($fh);
436     }
438     /**
439      * Determine the full pathname to store a failure-related dump.
440      *
441      * This is used for content such as the DOM, and screenshots.
442      *
443      * @param StepEvent $event
444      * @param String $filetype The file suffix to use. Limited to 4 chars.
445      */
446     protected function get_faildump_filename(StepEvent $event, $filetype) {
447         global $CFG;
449         // All the contentdumps should be in the same parent dir.
450         if (!$faildumpdir = self::get_run_faildump_dir()) {
451             $faildumpdir = self::$faildumpdirname = date('Ymd_His');
453             $dir = $CFG->behat_faildump_path . DIRECTORY_SEPARATOR . $faildumpdir;
455             if (!is_dir($dir) && !mkdir($dir, $CFG->directorypermissions, true)) {
456                 // It shouldn't, we already checked that the directory is writable.
457                 throw new Exception('No directories can be created inside $CFG->behat_faildump_path, check the directory permissions.');
458             }
459         } else {
460             // We will always need to know the full path.
461             $dir = $CFG->behat_faildump_path . DIRECTORY_SEPARATOR . $faildumpdir;
462         }
464         // The scenario title + the failed step text.
465         // We want a i-am-the-scenario-title_i-am-the-failed-step.$filetype format.
466         $filename = $event->getStep()->getParent()->getTitle() . '_' . $event->getStep()->getText();
467         $filename = preg_replace('/([^a-zA-Z0-9\_]+)/', '-', $filename);
469         // File name limited to 255 characters. Leaving 4 chars for the file
470         // extension as we allow .png for images and .html for DOM contents.
471         $filename = substr($filename, 0, 250) . '.' . $filetype;
473         return array($dir, $filename);
474     }
476     /**
477      * Waits for all the JS to be loaded.
478      *
479      * @throws \Exception
480      * @throws NoSuchWindow
481      * @throws UnknownError
482      * @return bool True or false depending whether all the JS is loaded or not.
483      */
484     protected function wait_for_pending_js() {
486         // We don't use behat_base::spin() here as we don't want to end up with an exception
487         // if the page & JSs don't finish loading properly.
488         for ($i = 0; $i < self::EXTENDED_TIMEOUT * 10; $i++) {
489             $pending = '';
490             try {
491                 $jscode =
492                     'if (typeof M === "undefined") {
493                         if (document.readyState === "complete") {
494                             return "";
495                         } else {
496                             return "incomplete";
497                         }
498                     } else if (' . self::PAGE_READY_JS . ') {
499                         return "";
500                     } else {
501                         return M.util.pending_js.join(":");
502                     }';
503                 $pending = $this->getSession()->evaluateScript($jscode);
504             } catch (NoSuchWindow $nsw) {
505                 // We catch an exception here, in case we just closed the window we were interacting with.
506                 // No javascript is running if there is no window right?
507                 $pending = '';
508             } catch (UnknownError $e) {
509                 // M is not defined when the window or the frame don't exist anymore.
510                 if (strstr($e->getMessage(), 'M is not defined') != false) {
511                     $pending = '';
512                 }
513             }
515             // If there are no pending JS we stop waiting.
516             if ($pending === '') {
517                 return true;
518             }
520             // 0.1 seconds.
521             usleep(100000);
522         }
524         // Timeout waiting for JS to complete. It will be catched and forwarded to behat_hooks::i_look_for_exceptions().
525         // It is unlikely that Javascript code of a page or an AJAX request needs more than self::EXTENDED_TIMEOUT seconds
526         // to be loaded, although when pages contains Javascript errors M.util.js_complete() can not be executed, so the
527         // number of JS pending code and JS completed code will not match and we will reach this point.
528         throw new \Exception('Javascript code and/or AJAX requests are not ready after ' . self::EXTENDED_TIMEOUT .
529             ' seconds. There is a Javascript error or the code is extremely slow.');
530     }
532     /**
533      * Internal step definition to find exceptions, debugging() messages and PHP debug messages.
534      *
535      * Part of behat_hooks class as is part of the testing framework, is auto-executed
536      * after each step so no features will splicitly use it.
537      *
538      * @Given /^I look for exceptions$/
539      * @throw Exception Unknown type, depending on what we caught in the hook or basic \Exception.
540      * @see Moodle\BehatExtension\Tester\MoodleStepTester
541      */
542     public function i_look_for_exceptions() {
544         // If the step already failed in a hook throw the exception.
545         if (!is_null(self::$currentstepexception)) {
546             throw self::$currentstepexception;
547         }
549         // Wrap in try in case we were interacting with a closed window.
550         try {
552             // Exceptions.
553             $exceptionsxpath = "//div[@data-rel='fatalerror']";
554             // Debugging messages.
555             $debuggingxpath = "//div[@data-rel='debugging']";
556             // PHP debug messages.
557             $phperrorxpath = "//div[@data-rel='phpdebugmessage']";
558             // Any other backtrace.
559             $othersxpath = "(//*[contains(., ': call to ')])[1]";
561             $xpaths = array($exceptionsxpath, $debuggingxpath, $phperrorxpath, $othersxpath);
562             $joinedxpath = implode(' | ', $xpaths);
564             // Joined xpath expression. Most of the time there will be no exceptions, so this pre-check
565             // is faster than to send the 4 xpath queries for each step.
566             if (!$this->getSession()->getDriver()->find($joinedxpath)) {
567                 return;
568             }
570             // Exceptions.
571             if ($errormsg = $this->getSession()->getPage()->find('xpath', $exceptionsxpath)) {
573                 // Getting the debugging info and the backtrace.
574                 $errorinfoboxes = $this->getSession()->getPage()->findAll('css', 'div.alert-error');
575                 // If errorinfoboxes is empty, try find notifytiny (original) class.
576                 if (empty($errorinfoboxes)) {
577                     $errorinfoboxes = $this->getSession()->getPage()->findAll('css', 'div.notifytiny');
578                 }
579                 $errorinfo = $this->get_debug_text($errorinfoboxes[0]->getHtml()) . "\n" .
580                     $this->get_debug_text($errorinfoboxes[1]->getHtml());
582                 $msg = "Moodle exception: " . $errormsg->getText() . "\n" . $errorinfo;
583                 throw new \Exception(html_entity_decode($msg));
584             }
586             // Debugging messages.
587             if ($debuggingmessages = $this->getSession()->getPage()->findAll('xpath', $debuggingxpath)) {
588                 $msgs = array();
589                 foreach ($debuggingmessages as $debuggingmessage) {
590                     $msgs[] = $this->get_debug_text($debuggingmessage->getHtml());
591                 }
592                 $msg = "debugging() message/s found:\n" . implode("\n", $msgs);
593                 throw new \Exception(html_entity_decode($msg));
594             }
596             // PHP debug messages.
597             if ($phpmessages = $this->getSession()->getPage()->findAll('xpath', $phperrorxpath)) {
599                 $msgs = array();
600                 foreach ($phpmessages as $phpmessage) {
601                     $msgs[] = $this->get_debug_text($phpmessage->getHtml());
602                 }
603                 $msg = "PHP debug message/s found:\n" . implode("\n", $msgs);
604                 throw new \Exception(html_entity_decode($msg));
605             }
607             // Any other backtrace.
608             // First looking through xpath as it is faster than get and parse the whole page contents,
609             // we get the contents and look for matches once we found something to suspect that there is a backtrace.
610             if ($this->getSession()->getDriver()->find($othersxpath)) {
611                 $backtracespattern = '/(line [0-9]* of [^:]*: call to [\->&;:a-zA-Z_\x7f-\xff][\->&;:a-zA-Z0-9_\x7f-\xff]*)/';
612                 if (preg_match_all($backtracespattern, $this->getSession()->getPage()->getContent(), $backtraces)) {
613                     $msgs = array();
614                     foreach ($backtraces[0] as $backtrace) {
615                         $msgs[] = $backtrace . '()';
616                     }
617                     $msg = "Other backtraces found:\n" . implode("\n", $msgs);
618                     throw new \Exception(htmlentities($msg));
619                 }
620             }
622         } catch (NoSuchWindow $e) {
623             // If we were interacting with a popup window it will not exists after closing it.
624         }
625     }
627     /**
628      * Converts HTML tags to line breaks to display the info in CLI
629      *
630      * @param string $html
631      * @return string
632      */
633     protected function get_debug_text($html) {
635         // Replacing HTML tags for new lines and keeping only the text.
636         $notags = preg_replace('/<+\s*\/*\s*([A-Z][A-Z0-9]*)\b[^>]*\/*\s*>*/i', "\n", $html);
637         return preg_replace("/(\n)+/s", "\n", $notags);
638     }
640     /**
641      * Returns whether the first scenario of the suite is running
642      *
643      * @return bool
644      */
645     protected static function is_first_scenario() {
646         return !(self::$initprocessesfinished);
647     }
649     /**
650      * Throws an exception after appending an extra info text.
651      *
652      * @throws Exception
653      * @param UnknownError $exception
654      * @return void
655      */
656     protected function throw_unknown_exception(UnknownError $exception) {
657         $text = get_string('unknownexceptioninfo', 'tool_behat');
658         throw new Exception($text . PHP_EOL . $exception->getMessage());
659     }