Merge branch 'MDL-53252_master' of git://github.com/dmonllao/moodle
[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\Testwork\Hook\Scope\BeforeSuiteScope,
33     Behat\Testwork\Hook\Scope\AfterSuiteScope,
34     Behat\Behat\Hook\Scope\BeforeFeatureScope,
35     Behat\Behat\Hook\Scope\AfterFeatureScope,
36     Behat\Behat\Hook\Scope\BeforeScenarioScope,
37     Behat\Behat\Hook\Scope\AfterScenarioScope,
38     Behat\Behat\Hook\Scope\BeforeStepScope,
39     Behat\Behat\Hook\Scope\AfterStepScope,
40     Behat\Mink\Exception\DriverException as DriverException,
41     WebDriver\Exception\NoSuchWindow as NoSuchWindow,
42     WebDriver\Exception\UnexpectedAlertOpen as UnexpectedAlertOpen,
43     WebDriver\Exception\UnknownError as UnknownError,
44     WebDriver\Exception\CurlExec as CurlExec,
45     WebDriver\Exception\NoAlertOpenError as NoAlertOpenError;
47 /**
48  * Hooks to the behat process.
49  *
50  * Behat accepts hooks after and before each
51  * suite, feature, scenario and step.
52  *
53  * They can not call other steps as part of their process
54  * like regular steps definitions does.
55  *
56  * Throws generic Exception because they are captured by Behat.
57  *
58  * @package   core
59  * @category  test
60  * @copyright 2012 David MonllaĆ³
61  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
62  */
63 class behat_hooks extends behat_base {
65     /**
66      * @var Last browser session start time.
67      */
68     protected static $lastbrowsersessionstart = 0;
70     /**
71      * @var For actions that should only run once.
72      */
73     protected static $initprocessesfinished = false;
75     /**
76      * Some exceptions can only be caught in a before or after step hook,
77      * they can not be thrown there as they will provoke a framework level
78      * failure, but we can store them here to fail the step in i_look_for_exceptions()
79      * which result will be parsed by the framework as the last step result.
80      *
81      * @var Null or the exception last step throw in the before or after hook.
82      */
83     protected static $currentstepexception = null;
85     /**
86      * If we are saving any kind of dump on failure we should use the same parent dir during a run.
87      *
88      * @var The parent dir name
89      */
90     protected static $faildumpdirname = false;
92     /**
93      * Keeps track of time taken by feature to execute.
94      *
95      * @var array list of feature timings
96      */
97     protected static $timings = array();
99     /**
100      * Gives access to moodle codebase, ensures all is ready and sets up the test lock.
101      *
102      * Includes config.php to use moodle codebase with $CFG->behat_*
103      * instead of $CFG->prefix and $CFG->dataroot, called once per suite.
104      *
105      * @param SuiteEvent $event event before suite.
106      * @static
107      * @throws Exception
108      * @BeforeSuite
109      */
110     public static function before_suite(BeforeSuiteScope $scope) {
111         global $CFG;
113         // Defined only when the behat CLI command is running, the moodle init setup process will
114         // read this value and switch to $CFG->behat_dataroot and $CFG->behat_prefix instead of
115         // the normal site.
116         define('BEHAT_TEST', 1);
118         define('CLI_SCRIPT', 1);
119         // With BEHAT_TEST we will be using $CFG->behat_* instead of $CFG->dataroot, $CFG->prefix and $CFG->wwwroot.
120         require_once(__DIR__ . '/../../../config.php');
122         // Now that we are MOODLE_INTERNAL.
123         require_once(__DIR__ . '/../../behat/classes/behat_command.php');
124         require_once(__DIR__ . '/../../behat/classes/behat_selectors.php');
125         require_once(__DIR__ . '/../../behat/classes/behat_context_helper.php');
126         require_once(__DIR__ . '/../../behat/classes/util.php');
127         require_once(__DIR__ . '/../../testing/classes/test_lock.php');
128         require_once(__DIR__ . '/../../testing/classes/nasty_strings.php');
130         // Avoids vendor/bin/behat to be executed directly without test environment enabled
131         // to prevent undesired db & dataroot modifications, this is also checked
132         // before each scenario (accidental user deletes) in the BeforeScenario hook.
134         if (!behat_util::is_test_mode_enabled()) {
135             throw new Exception('Behat only can run if test mode is enabled. More info in ' . behat_command::DOCS_URL . '#Running_tests');
136         }
138         // Reset all data, before checking for is_server_running.
139         // If not done, then it can return apache error, while running tests.
140         behat_util::reset_all_data();
142         if (!behat_util::is_server_running()) {
143             throw new Exception($CFG->behat_wwwroot .
144                 ' is not available, ensure you specified correct url and that the server is set up and started.' .
145                 ' More info in ' . behat_command::DOCS_URL . '#Running_tests');
146         }
148         // Prevents using outdated data, upgrade script would start and tests would fail.
149         if (!behat_util::is_test_data_updated()) {
150             $commandpath = 'php admin/tool/behat/cli/init.php';
151             throw new Exception("Your behat test site is outdated, please run\n\n    " .
152                     $commandpath . "\n\nfrom your moodle dirroot to drop and install the behat test site again.");
153         }
154         // Avoid parallel tests execution, it continues when the previous lock is released.
155         test_lock::acquire('behat');
157         // Store the browser reset time if reset after N seconds is specified in config.php.
158         if (!empty($CFG->behat_restart_browser_after)) {
159             // Store the initial browser session opening.
160             self::$lastbrowsersessionstart = time();
161         }
163         if (!empty($CFG->behat_faildump_path) && !is_writable($CFG->behat_faildump_path)) {
164             throw new Exception('You set $CFG->behat_faildump_path to a non-writable directory');
165         }
166     }
168     /**
169      * Gives access to moodle codebase, to keep track of feature start time.
170      *
171      * @param FeatureEvent $event event fired before feature.
172      * @BeforeFeature
173      */
174     public static function before_feature(BeforeFeatureScope $event) {
175         if (!defined('BEHAT_FEATURE_TIMING_FILE')) {
176             return;
177         }
178         $file = $event->getFeature()->getFile();
179         self::$timings[$file] = microtime(true);
180     }
182     /**
183      * Gives access to moodle codebase, to keep track of feature end time.
184      *
185      * @param FeatureEvent $event event fired after feature.
186      * @AfterFeature
187      */
188     public static function after_feature(AfterFeatureScope $event) {
189         if (!defined('BEHAT_FEATURE_TIMING_FILE')) {
190             return;
191         }
192         $file = $event->getFeature()->getFile();
193         self::$timings[$file] = microtime(true) - self::$timings[$file];
194         // Probably didn't actually run this, don't output it.
195         if (self::$timings[$file] < 1) {
196             unset(self::$timings[$file]);
197         }
198     }
200     /**
201      * Gives access to moodle codebase, to keep track of suite timings.
202      *
203      * @param SuiteEvent $event event fired after suite.
204      * @AfterSuite
205      */
206     public static function after_suite(AfterSuiteScope $event) {
207         if (!defined('BEHAT_FEATURE_TIMING_FILE')) {
208             return;
209         }
210         $realroot = realpath(__DIR__.'/../../../').'/';
211         foreach (self::$timings as $k => $v) {
212             $new = str_replace($realroot, '', $k);
213             self::$timings[$new] = round($v, 1);
214             unset(self::$timings[$k]);
215         }
216         if ($existing = @json_decode(file_get_contents(BEHAT_FEATURE_TIMING_FILE), true)) {
217             self::$timings = array_merge($existing, self::$timings);
218         }
219         arsort(self::$timings);
220         @file_put_contents(BEHAT_FEATURE_TIMING_FILE, json_encode(self::$timings, JSON_PRETTY_PRINT));
221     }
223     /**
224      * Resets the test environment.
225      *
226      * @param OutlineExampleEvent|ScenarioEvent $event event fired before scenario.
227      * @throws coding_exception If here we are not using the test database it should be because of a coding error
228      * @BeforeScenario
229      */
230     public function before_scenario(BeforeScenarioScope $scope) {
231         global $DB, $SESSION, $CFG;
233         // As many checks as we can.
234         if (!defined('BEHAT_TEST') ||
235                !defined('BEHAT_SITE_RUNNING') ||
236                php_sapi_name() != 'cli' ||
237                !behat_util::is_test_mode_enabled() ||
238                !behat_util::is_test_site()) {
239             throw new coding_exception('Behat only can modify the test database and the test dataroot!');
240         }
242         $moreinfo = 'More info in ' . behat_command::DOCS_URL . '#Running_tests';
243         $driverexceptionmsg = 'Selenium server is not running, you need to start it to run tests that involve Javascript. ' . $moreinfo;
244         try {
245             $session = $this->getSession();
246         } catch (CurlExec $e) {
247             // Exception thrown by WebDriver, so only @javascript tests will be caugth; in
248             // behat_util::is_server_running() we already checked that the server is running.
249             $this->stop_execution($driverexceptionmsg);
250         } catch (DriverException $e) {
251             $this->stop_execution($driverexceptionmsg);
252         } catch (UnknownError $e) {
253             // Generic 'I have no idea' Selenium error. Custom exception to provide more feedback about possible solutions.
254             $this->stop_execution($e->getMessage());
255         }
257         // We need the Mink session to do it and we do it only before the first scenario.
258         if (self::is_first_scenario()) {
259             behat_selectors::register_moodle_selectors($session);
260             behat_context_helper::set_session($scope->getEnvironment());
261         }
263         // Reset mink session between the scenarios.
264         $session->reset();
266         // Reset $SESSION.
267         \core\session\manager::init_empty_session();
269         behat_util::reset_all_data();
271         // Assign valid data to admin user (some generator-related code needs a valid user).
272         $user = $DB->get_record('user', array('username' => 'admin'));
273         \core\session\manager::set_user($user);
275         // Reset the browser if specified in config.php.
276         if (!empty($CFG->behat_restart_browser_after) && $this->running_javascript()) {
277             $now = time();
278             if (self::$lastbrowsersessionstart + $CFG->behat_restart_browser_after < $now) {
279                 $session->restart();
280                 self::$lastbrowsersessionstart = $now;
281             }
282         }
284         // Start always in the the homepage.
285         try {
286             // Let's be conservative as we never know when new upstream issues will affect us.
287             $session->visit($this->locate_path('/'));
288         } catch (UnknownError $e) {
289             $this->stop_execution($e->getMessage());
290         }
293         // Checking that the root path is a Moodle test site.
294         if (self::is_first_scenario()) {
295             $notestsiteexception = new Exception('The base URL (' . $CFG->wwwroot . ') is not a behat test site, ' .
296                 'ensure you started the built-in web server in the correct directory or your web server is correctly started and set up');
297             $this->find("xpath", "//head/child::title[normalize-space(.)='" . behat_util::BEHATSITENAME . "']", $notestsiteexception);
299             self::$initprocessesfinished = true;
300         }
301         // Run all test with medium (1024x768) screen size, to avoid responsive problems.
302         $this->resize_window('medium');
303     }
305     /**
306      * Wait for JS to complete before beginning interacting with the DOM.
307      *
308      * Executed only when running against a real browser. We wrap it
309      * all in a try & catch to forward the exception to i_look_for_exceptions
310      * so the exception will be at scenario level, which causes a failure, by
311      * default would be at framework level, which will stop the execution of
312      * the run.
313      *
314      * @BeforeStep
315      */
316     public function before_step_javascript(BeforeStepScope $scope) {
317         self::$currentstepexception = null;
319         // Only run if JS.
320         if ($this->running_javascript()) {
321             try {
322                 $this->wait_for_pending_js();
323             } catch (Exception $e) {
324                 self::$currentstepexception = $e;
325             }
326         }
327     }
329     /**
330      * Wait for JS to complete after finishing the step.
331      *
332      * With this we ensure that there are not AJAX calls
333      * still in progress.
334      *
335      * Executed only when running against a real browser. We wrap it
336      * all in a try & catch to forward the exception to i_look_for_exceptions
337      * so the exception will be at scenario level, which causes a failure, by
338      * default would be at framework level, which will stop the execution of
339      * the run.
340      *
341      * @AfterStep
342      */
343     public function after_step_javascript(AfterStepScope $scope) {
344         global $CFG, $DB;
346         // Save the page content if the step failed.
347         if (!empty($CFG->behat_faildump_path) &&
348             $scope->getTestResult()->getResultCode() === Behat\Testwork\Tester\Result\TestResult::FAILED) {
349             $this->take_contentdump($scope);
350         }
352         // Abort any open transactions to prevent subsequent tests hanging.
353         // This does the same as abort_all_db_transactions(), but doesn't call error_log() as we don't
354         // want to see a message in the behat output.
355         if (($scope->getTestResult() instanceof \Behat\Behat\Tester\Result\ExecutedStepResult) &&
356             $scope->getTestResult()->hasException()) {
357             if ($DB && $DB->is_transaction_started()) {
358                 $DB->force_transaction_rollback();
359             }
360         }
362         // Only run if JS.
363         if (!$this->running_javascript()) {
364             return;
365         }
367         // Save a screenshot if the step failed.
368         if (!empty($CFG->behat_faildump_path) &&
369             $scope->getTestResult()->getResultCode() === Behat\Testwork\Tester\Result\TestResult::FAILED) {
370             $this->take_screenshot($scope);
371         }
373         try {
374             $this->wait_for_pending_js();
375             self::$currentstepexception = null;
376         } catch (UnexpectedAlertOpen $e) {
377             self::$currentstepexception = $e;
379             // Accepting the alert so the framework can continue properly running
380             // the following scenarios. Some browsers already closes the alert, so
381             // wrapping in a try & catch.
382             try {
383                 $this->getSession()->getDriver()->getWebDriverSession()->accept_alert();
384             } catch (Exception $e) {
385                 // Catching the generic one as we never know how drivers reacts here.
386             }
387         } catch (Exception $e) {
388             self::$currentstepexception = $e;
389         }
390     }
392     /**
393      * Executed after scenario having switch window to restart session.
394      * This is needed to close all extra browser windows and starting
395      * one browser window.
396      *
397      * @param AfterScenarioScope $event event fired after scenario.
398      * @AfterScenario @_switch_window
399      */
400     public function after_scenario_switchwindow(AfterScenarioScope $event) {
401         for ($count = 0; $count < self::EXTENDED_TIMEOUT; $count) {
402             try {
403                 $this->getSession()->restart();
404                 break;
405             } catch (DriverException $e) {
406                 // Wait for timeout and try again.
407                 sleep(self::TIMEOUT);
408             }
409         }
410         // If session is not restarted above then it will try to start session before next scenario
411         // and if that fails then exception will be thrown.
412     }
414     /**
415      * Getter for self::$faildumpdirname
416      *
417      * @return string
418      */
419     protected function get_run_faildump_dir() {
420         return self::$faildumpdirname;
421     }
423     /**
424      * Take screenshot when a step fails.
425      *
426      * @throws Exception
427      * @param AfterStepScope $scope
428      */
429     protected function take_screenshot(AfterStepScope $scope) {
430         // Goutte can't save screenshots.
431         if (!$this->running_javascript()) {
432             return false;
433         }
435         list ($dir, $filename) = $this->get_faildump_filename($scope, 'png');
436         $this->saveScreenshot($filename, $dir);
437     }
439     /**
440      * Take a dump of the page content when a step fails.
441      *
442      * @throws Exception
443      * @param AfterStepScope $scope
444      */
445     protected function take_contentdump(AfterStepScope $scope) {
446         list ($dir, $filename) = $this->get_faildump_filename($scope, 'html');
448         $fh = fopen($dir . DIRECTORY_SEPARATOR . $filename, 'w');
449         fwrite($fh, $this->getSession()->getPage()->getContent());
450         fclose($fh);
451     }
453     /**
454      * Determine the full pathname to store a failure-related dump.
455      *
456      * This is used for content such as the DOM, and screenshots.
457      *
458      * @param AfterStepScope $scope
459      * @param String $filetype The file suffix to use. Limited to 4 chars.
460      */
461     protected function get_faildump_filename(AfterStepScope $scope, $filetype) {
462         global $CFG;
464         // All the contentdumps should be in the same parent dir.
465         if (!$faildumpdir = self::get_run_faildump_dir()) {
466             $faildumpdir = self::$faildumpdirname = date('Ymd_His');
468             $dir = $CFG->behat_faildump_path . DIRECTORY_SEPARATOR . $faildumpdir;
470             if (!is_dir($dir) && !mkdir($dir, $CFG->directorypermissions, true)) {
471                 // It shouldn't, we already checked that the directory is writable.
472                 throw new Exception('No directories can be created inside $CFG->behat_faildump_path, check the directory permissions.');
473             }
474         } else {
475             // We will always need to know the full path.
476             $dir = $CFG->behat_faildump_path . DIRECTORY_SEPARATOR . $faildumpdir;
477         }
479         // The scenario title + the failed step text.
480         // We want a i-am-the-scenario-title_i-am-the-failed-step.$filetype format.
481         $filename = $scope->getFeature()->getTitle() . '_' . $scope->getStep()->getText();
482         $filename = preg_replace('/([^a-zA-Z0-9\_]+)/', '-', $filename);
484         // File name limited to 255 characters. Leaving 5 chars for line number and 4 chars for the file.
485         // extension as we allow .png for images and .html for DOM contents.
486         $filename = substr($filename, 0, 245) . '_' . $scope->getStep()->getLine() . '.' . $filetype;
488         return array($dir, $filename);
489     }
491     /**
492      * Internal step definition to find exceptions, debugging() messages and PHP debug messages.
493      *
494      * Part of behat_hooks class as is part of the testing framework, is auto-executed
495      * after each step so no features will splicitly use it.
496      *
497      * @Given /^I look for exceptions$/
498      * @throw Exception Unknown type, depending on what we caught in the hook or basic \Exception.
499      * @see Moodle\BehatExtension\Tester\MoodleStepTester
500      */
501     public function i_look_for_exceptions() {
503         // If the step already failed in a hook throw the exception.
504         if (!is_null(self::$currentstepexception)) {
505             throw self::$currentstepexception;
506         }
508         // Wrap in try in case we were interacting with a closed window.
509         try {
511             // Exceptions.
512             $exceptionsxpath = "//div[@data-rel='fatalerror']";
513             // Debugging messages.
514             $debuggingxpath = "//div[@data-rel='debugging']";
515             // PHP debug messages.
516             $phperrorxpath = "//div[@data-rel='phpdebugmessage']";
517             // Any other backtrace.
518             $othersxpath = "(//*[contains(., ': call to ')])[1]";
520             $xpaths = array($exceptionsxpath, $debuggingxpath, $phperrorxpath, $othersxpath);
521             $joinedxpath = implode(' | ', $xpaths);
523             // Joined xpath expression. Most of the time there will be no exceptions, so this pre-check
524             // is faster than to send the 4 xpath queries for each step.
525             if (!$this->getSession()->getDriver()->find($joinedxpath)) {
526                 return;
527             }
529             // Exceptions.
530             if ($errormsg = $this->getSession()->getPage()->find('xpath', $exceptionsxpath)) {
532                 // Getting the debugging info and the backtrace.
533                 $errorinfoboxes = $this->getSession()->getPage()->findAll('css', 'div.alert-error');
534                 // If errorinfoboxes is empty, try find notifytiny (original) class.
535                 if (empty($errorinfoboxes)) {
536                     $errorinfoboxes = $this->getSession()->getPage()->findAll('css', 'div.notifytiny');
537                 }
538                 $errorinfo = $this->get_debug_text($errorinfoboxes[0]->getHtml()) . "\n" .
539                     $this->get_debug_text($errorinfoboxes[1]->getHtml());
541                 $msg = "Moodle exception: " . $errormsg->getText() . "\n" . $errorinfo;
542                 throw new \Exception(html_entity_decode($msg));
543             }
545             // Debugging messages.
546             if ($debuggingmessages = $this->getSession()->getPage()->findAll('xpath', $debuggingxpath)) {
547                 $msgs = array();
548                 foreach ($debuggingmessages as $debuggingmessage) {
549                     $msgs[] = $this->get_debug_text($debuggingmessage->getHtml());
550                 }
551                 $msg = "debugging() message/s found:\n" . implode("\n", $msgs);
552                 throw new \Exception(html_entity_decode($msg));
553             }
555             // PHP debug messages.
556             if ($phpmessages = $this->getSession()->getPage()->findAll('xpath', $phperrorxpath)) {
558                 $msgs = array();
559                 foreach ($phpmessages as $phpmessage) {
560                     $msgs[] = $this->get_debug_text($phpmessage->getHtml());
561                 }
562                 $msg = "PHP debug message/s found:\n" . implode("\n", $msgs);
563                 throw new \Exception(html_entity_decode($msg));
564             }
566             // Any other backtrace.
567             // First looking through xpath as it is faster than get and parse the whole page contents,
568             // we get the contents and look for matches once we found something to suspect that there is a backtrace.
569             if ($this->getSession()->getDriver()->find($othersxpath)) {
570                 $backtracespattern = '/(line [0-9]* of [^:]*: call to [\->&;:a-zA-Z_\x7f-\xff][\->&;:a-zA-Z0-9_\x7f-\xff]*)/';
571                 if (preg_match_all($backtracespattern, $this->getSession()->getPage()->getContent(), $backtraces)) {
572                     $msgs = array();
573                     foreach ($backtraces[0] as $backtrace) {
574                         $msgs[] = $backtrace . '()';
575                     }
576                     $msg = "Other backtraces found:\n" . implode("\n", $msgs);
577                     throw new \Exception(htmlentities($msg));
578                 }
579             }
581         } catch (NoSuchWindow $e) {
582             // If we were interacting with a popup window it will not exists after closing it.
583         }
584     }
586     /**
587      * Converts HTML tags to line breaks to display the info in CLI
588      *
589      * @param string $html
590      * @return string
591      */
592     protected function get_debug_text($html) {
594         // Replacing HTML tags for new lines and keeping only the text.
595         $notags = preg_replace('/<+\s*\/*\s*([A-Z][A-Z0-9]*)\b[^>]*\/*\s*>*/i', "\n", $html);
596         return preg_replace("/(\n)+/s", "\n", $notags);
597     }
599     /**
600      * Returns whether the first scenario of the suite is running
601      *
602      * @return bool
603      */
604     protected static function is_first_scenario() {
605         return !(self::$initprocessesfinished);
606     }
608     /**
609      * Stops execution because of some exception.
610      *
611      * @param string $exception
612      * @return void
613      */
614     protected function stop_execution($exception) {
615         $text = get_string('unknownexceptioninfo', 'tool_behat');
616         echo $text . PHP_EOL . $exception . PHP_EOL;
617         exit(1);
618     }