[ Index ]

PHP Cross Reference of moodle-2.8

title

Body

[close]

/lib/tests/behat/ -> behat_hooks.php (source)

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


Generated: Fri Nov 28 20:29:05 2014 Cross-referenced by PHPXref 0.7.1