Merge branch 'MDL-41587_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\Behat\Event\SuiteEvent as SuiteEvent,
33     Behat\Behat\Event\ScenarioEvent as ScenarioEvent,
34     Behat\Behat\Event\StepEvent as StepEvent,
35     WebDriver\Exception\NoSuchWindow as NoSuchWindow,
36     WebDriver\Exception\UnexpectedAlertOpen as UnexpectedAlertOpen,
37     WebDriver\Exception\UnknownError as UnknownError,
38     WebDriver\Exception\CurlExec as CurlExec,
39     WebDriver\Exception\NoAlertOpenError as NoAlertOpenError;
41 /**
42  * Hooks to the behat process.
43  *
44  * Behat accepts hooks after and before each
45  * suite, feature, scenario and step.
46  *
47  * They can not call other steps as part of their process
48  * like regular steps definitions does.
49  *
50  * Throws generic Exception because they are captured by Behat.
51  *
52  * @package   core
53  * @category  test
54  * @copyright 2012 David MonllaĆ³
55  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
56  */
57 class behat_hooks extends behat_base {
59     /**
60      * @var Last browser session start time.
61      */
62     protected static $lastbrowsersessionstart = 0;
64     /**
65      * @var For actions that should only run once.
66      */
67     protected static $initprocessesfinished = false;
69     /**
70      * Gives access to moodle codebase, ensures all is ready and sets up the test lock.
71      *
72      * Includes config.php to use moodle codebase with $CFG->behat_*
73      * instead of $CFG->prefix and $CFG->dataroot, called once per suite.
74      *
75      * @static
76      * @throws Exception
77      * @BeforeSuite
78      */
79     public static function before_suite($event) {
80         global $CFG;
82         // Defined only when the behat CLI command is running, the moodle init setup process will
83         // read this value and switch to $CFG->behat_dataroot and $CFG->behat_prefix instead of
84         // the normal site.
85         define('BEHAT_TEST', 1);
87         define('CLI_SCRIPT', 1);
89         // With BEHAT_TEST we will be using $CFG->behat_* instead of $CFG->dataroot, $CFG->prefix and $CFG->wwwroot.
90         require_once(__DIR__ . '/../../../config.php');
92         // Now that we are MOODLE_INTERNAL.
93         require_once(__DIR__ . '/../../behat/classes/behat_command.php');
94         require_once(__DIR__ . '/../../behat/classes/behat_selectors.php');
95         require_once(__DIR__ . '/../../behat/classes/util.php');
96         require_once(__DIR__ . '/../../testing/classes/test_lock.php');
97         require_once(__DIR__ . '/../../testing/classes/nasty_strings.php');
99         // Avoids vendor/bin/behat to be executed directly without test environment enabled
100         // to prevent undesired db & dataroot modifications, this is also checked
101         // before each scenario (accidental user deletes) in the BeforeScenario hook.
103         if (!behat_util::is_test_mode_enabled()) {
104             throw new Exception('Behat only can run if test mode is enabled. More info in ' . behat_command::DOCS_URL . '#Running_tests');
105         }
107         if (!behat_util::is_server_running()) {
108             throw new Exception($CFG->behat_wwwroot .
109                 ' is not available, ensure you started your PHP built-in server or your web server is correctly started and set up.' .
110                 ' More info in ' . behat_command::DOCS_URL . '#Running_tests');
111         }
113         // Prevents using outdated data, upgrade script would start and tests would fail.
114         if (!behat_util::is_test_data_updated()) {
115             $commandpath = 'php admin/tool/behat/cli/init.php';
116             throw new Exception('Your behat test site is outdated, please run ' . $commandpath . ' from your moodle dirroot to drop and install the behat test site again.');
117         }
118         // Avoid parallel tests execution, it continues when the previous lock is released.
119         test_lock::acquire('behat');
121         // Store the browser reset time if reset after N seconds is specified in config.php.
122         if (!empty($CFG->behat_restart_browser_after)) {
123             // Store the initial browser session opening.
124             self::$lastbrowsersessionstart = time();
125         }
126     }
128     /**
129      * Resets the test environment.
130      *
131      * @throws coding_exception If here we are not using the test database it should be because of a coding error
132      * @BeforeScenario
133      */
134     public function before_scenario($event) {
135         global $DB, $SESSION, $CFG;
137         // As many checks as we can.
138         if (!defined('BEHAT_TEST') ||
139                !defined('BEHAT_SITE_RUNNING') ||
140                php_sapi_name() != 'cli' ||
141                !behat_util::is_test_mode_enabled() ||
142                !behat_util::is_test_site()) {
143             throw new coding_exception('Behat only can modify the test database and the test dataroot!');
144         }
146         // We need the Mink session to do it and we do it only before the first scenario.
147         if (self::is_first_scenario()) {
148             behat_selectors::register_moodle_selectors($this->getSession());
149         }
151         // Avoid some notices / warnings.
152         $SESSION = new stdClass();
154         behat_util::reset_database();
155         behat_util::reset_dataroot();
157         purge_all_caches();
158         accesslib_clear_all_caches(true);
160         // Reset the nasty strings list used during the last test.
161         nasty_strings::reset_used_strings();
163         // Assing valid data to admin user (some generator-related code needs a valid user).
164         $user = $DB->get_record('user', array('username' => 'admin'));
165         session_set_user($user);
167         // Reset the browser if specified in config.php.
168         if (!empty($CFG->behat_restart_browser_after) && $this->running_javascript()) {
169             $now = time();
170             if (self::$lastbrowsersessionstart + $CFG->behat_restart_browser_after < $now) {
171                 $this->getSession()->restart();
172                 self::$lastbrowsersessionstart = $now;
173             }
174         }
176         // Start always in the the homepage.
177         try {
178             $this->getSession()->visit($this->locate_path('/'));
179         } catch (CurlExec $e) {
180             // Exception thrown by WebDriver, so only @javascript tests will be caugth; in
181             // behat_util::is_server_running() we already checked that the server is running.
182             $moreinfo = 'More info in ' . behat_command::DOCS_URL . '#Running_tests';
183             $msg = 'Selenium server is not running, you need to start it to run tests that involve Javascript. ' . $moreinfo;
184             throw new Exception($msg);
185         } catch (UnknownError $e) {
186             // Generic 'I have no idea' Selenium error. Custom exception to provide more feedback about possible solutions.
187             $this->throw_unknown_exception($e);
188         }
190         // Checking that the root path is a Moodle test site.
191         if (self::is_first_scenario()) {
192             $notestsiteexception = new Exception('The base URL (' . $CFG->wwwroot . ') is not a behat test site, ' .
193                 'ensure you started the built-in web server in the correct directory or your web server is correctly started and set up');
194             $this->find("xpath", "//head/child::title[normalize-space(.)='" . behat_util::BEHATSITENAME . "']", $notestsiteexception);
196             self::$initprocessesfinished = true;
197         }
199         // Closing JS dialogs if present. Otherwise they would block this scenario execution.
200         if ($this->running_javascript()) {
201             try {
202                 $this->getSession()->getDriver()->getWebDriverSession()->accept_alert();
203             } catch (NoAlertOpenError $e) {
204                 // All ok, there should not be JS dialogs in theory.
205             }
206         }
208     }
210     /**
211      * Checks that all DOM is ready.
212      *
213      * Executed only when running against a real browser.
214      *
215      * @AfterStep @javascript
216      */
217     public function after_step_javascript($event) {
219         // If it doesn't have definition or it fails there is no need to check it.
220         if ($event->getResult() != StepEvent::PASSED ||
221             !$event->hasDefinition()) {
222             return;
223         }
225        // Wait until the page is ready.
226        // We are already checking that we use a JS browser, this could
227        // change in case we use another JS driver.
228        try {
230             // Safari and Internet Explorer requires time between steps,
231             // otherwise Selenium tries to click in the previous page's DOM.
232             if ($this->getSession()->getDriver()->getBrowserName() == 'safari' ||
233                     $this->getSession()->getDriver()->getBrowserName() == 'internet explorer') {
234                 $this->getSession()->wait(self::TIMEOUT * 1000, false);
236             } else {
237                 // With other browsers we just wait for the DOM ready.
238                 $this->getSession()->wait(self::TIMEOUT * 1000, '(document.readyState === "complete")');
239             }
241         } catch (NoSuchWindow $e) {
242             // If we were interacting with a popup window it will not exists after closing it.
243         } catch (UnknownError $e) {
244             // Custom exception to provide more feedback about possible solutions.
245             $this->throw_unknown_exception($e);
246         }
247     }
249     /**
250      * Internal step definition to find exceptions, debugging() messages and PHP debug messages.
251      *
252      * Part of behat_hooks class as is part of the testing framework, is auto-executed
253      * after each step so no features will splicitly use it.
254      *
255      * @Given /^I look for exceptions$/
256      * @see Moodle\BehatExtension\Tester\MoodleStepTester
257      */
258     public function i_look_for_exceptions() {
260         // Wrap in try in case we were interacting with a closed window.
261         try {
263             // Exceptions.
264             $exceptionsxpath = "//div[@data-rel='fatalerror']";
265             // Debugging messages.
266             $debuggingxpath = "//div[@data-rel='debugging']";
267             // PHP debug messages.
268             $phperrorxpath = "//div[@data-rel='phpdebugmessage']";
269             // Any other backtrace.
270             $othersxpath = "(//*[contains(., ': call to ')])[1]";
272             $xpaths = array($exceptionsxpath, $debuggingxpath, $phperrorxpath, $othersxpath);
273             $joinedxpath = implode(' | ', $xpaths);
275             // Joined xpath expression. Most of the time there will be no exceptions, so this pre-check
276             // is faster than to send the 4 xpath queries for each step.
277             if (!$this->getSession()->getDriver()->find($joinedxpath)) {
278                 return;
279             }
281             // Exceptions.
282             if ($errormsg = $this->getSession()->getPage()->find('xpath', $exceptionsxpath)) {
284                 // Getting the debugging info and the backtrace.
285                 $errorinfoboxes = $this->getSession()->getPage()->findAll('css', 'div.notifytiny');
286                 $errorinfo = $this->get_debug_text($errorinfoboxes[0]->getHtml()) . "\n" .
287                     $this->get_debug_text($errorinfoboxes[1]->getHtml());
289                 $msg = "Moodle exception: " . $errormsg->getText() . "\n" . $errorinfo;
290                 throw new \Exception(html_entity_decode($msg));
291             }
293             // Debugging messages.
294             if ($debuggingmessages = $this->getSession()->getPage()->findAll('xpath', $debuggingxpath)) {
295                 $msgs = array();
296                 foreach ($debuggingmessages as $debuggingmessage) {
297                     $msgs[] = $this->get_debug_text($debuggingmessage->getHtml());
298                 }
299                 $msg = "debugging() message/s found:\n" . implode("\n", $msgs);
300                 throw new \Exception(html_entity_decode($msg));
301             }
303             // PHP debug messages.
304             if ($phpmessages = $this->getSession()->getPage()->findAll('xpath', $phperrorxpath)) {
306                 $msgs = array();
307                 foreach ($phpmessages as $phpmessage) {
308                     $msgs[] = $this->get_debug_text($phpmessage->getHtml());
309                 }
310                 $msg = "PHP debug message/s found:\n" . implode("\n", $msgs);
311                 throw new \Exception(html_entity_decode($msg));
312             }
314             // Any other backtrace.
315             // First looking through xpath as it is faster than get and parse the whole page contents,
316             // we get the contents and look for matches once we found something to suspect that there is a backtrace.
317             if ($this->getSession()->getDriver()->find($othersxpath)) {
318                 $backtracespattern = '/(line [0-9]* of [^:]*: call to [\->&;:a-zA-Z_\x7f-\xff][\->&;:a-zA-Z0-9_\x7f-\xff]*)/';
319                 if (preg_match_all($backtracespattern, $this->getSession()->getPage()->getContent(), $backtraces)) {
320                     $msgs = array();
321                     foreach ($backtraces[0] as $backtrace) {
322                         $msgs[] = $backtrace . '()';
323                     }
324                     $msg = "Other backtraces found:\n" . implode("\n", $msgs);
325                     throw new \Exception(htmlentities($msg));
326                 }
327             }
329         } catch (NoSuchWindow $e) {
330             // If we were interacting with a popup window it will not exists after closing it.
331         } catch (UnexpectedAlertOpen $e) {
332             // We fail the scenario if we find an opened JS alert/confirm, in most of the cases it
333             // will be there because we are leaving an edited form without submitting/cancelling
334             // it, but moodle is using JS confirms and we can not just cancel the JS dialog
335             // as in some cases (delete activity with JS enabled for example) the test writer should
336             // use extra steps to deal with moodle's behaviour.
337             throw new Exception('Modal window present. Ensure there are no edited forms pending to submit/cancel.');
338         }
339     }
341     /**
342      * Converts HTML tags to line breaks to display the info in CLI
343      *
344      * @param string $html
345      * @return string
346      */
347     protected function get_debug_text($html) {
349         // Replacing HTML tags for new lines and keeping only the text.
350         $notags = preg_replace('/<+\s*\/*\s*([A-Z][A-Z0-9]*)\b[^>]*\/*\s*>*/i', "\n", $html);
351         return preg_replace("/(\n)+/s", "\n", $notags);
352     }
354     /**
355      * Returns whether the first scenario of the suite is running
356      *
357      * @return bool
358      */
359     protected static function is_first_scenario() {
360         return !(self::$initprocessesfinished);
361     }
363     /**
364      * Throws an exception after appending an extra info text.
365      *
366      * @throws Exception
367      * @param UnknownError $exception
368      * @return void
369      */
370     protected function throw_unknown_exception(UnknownError $exception) {
371         $text = get_string('unknownexceptioninfo', 'tool_behat');
372         throw new Exception($text . PHP_EOL . $exception->getMessage());
373     }