MDL-69232 behat: Make selenium start more fault tolerant
[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\ExpectationException,
41     Behat\Mink\Exception\DriverException as DriverException,
42     WebDriver\Exception\NoSuchWindow as NoSuchWindow,
43     WebDriver\Exception\UnexpectedAlertOpen as UnexpectedAlertOpen,
44     WebDriver\Exception\UnknownError as UnknownError,
45     WebDriver\Exception\CurlExec as CurlExec,
46     WebDriver\Exception\NoAlertOpenError as NoAlertOpenError;
48 /**
49  * Hooks to the behat process.
50  *
51  * Behat accepts hooks after and before each
52  * suite, feature, scenario and step.
53  *
54  * They can not call other steps as part of their process
55  * like regular steps definitions does.
56  *
57  * Throws generic Exception because they are captured by Behat.
58  *
59  * @package   core
60  * @category  test
61  * @copyright 2012 David MonllaĆ³
62  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
63  */
64 class behat_hooks extends behat_base {
66     /**
67      * @var For actions that should only run once.
68      */
69     protected static $initprocessesfinished = false;
71     /**
72      * @var bool Scenario running
73      */
74     protected $scenariorunning = false;
76     /**
77      * Some exceptions can only be caught in a before or after step hook,
78      * they can not be thrown there as they will provoke a framework level
79      * failure, but we can store them here to fail the step in i_look_for_exceptions()
80      * which result will be parsed by the framework as the last step result.
81      *
82      * @var Null or the exception last step throw in the before or after hook.
83      */
84     protected static $currentstepexception = null;
86     /**
87      * If we are saving any kind of dump on failure we should use the same parent dir during a run.
88      *
89      * @var The parent dir name
90      */
91     protected static $faildumpdirname = false;
93     /**
94      * Keeps track of time taken by feature to execute.
95      *
96      * @var array list of feature timings
97      */
98     protected static $timings = array();
100     /**
101      * Keeps track of current running suite name.
102      *
103      * @var string current running suite name
104      */
105     protected static $runningsuite = '';
107     /**
108      * @var array Array (with tag names in keys) of all tags in current scenario.
109      */
110     protected static $scenariotags;
112     /**
113      * Hook to capture BeforeSuite event so as to give access to moodle codebase.
114      * This will try and catch any exception and exists if anything fails.
115      *
116      * @param BeforeSuiteScope $scope scope passed by event fired before suite.
117      * @BeforeSuite
118      */
119     public static function before_suite_hook(BeforeSuiteScope $scope) {
120         // If behat has been initialised then no need to do this again.
121         if (self::$initprocessesfinished) {
122             return;
123         }
125         try {
126             self::before_suite($scope);
127         } catch (behat_stop_exception $e) {
128             echo $e->getMessage() . PHP_EOL;
129             exit(1);
130         }
131     }
133     /**
134      * Gives access to moodle codebase, ensures all is ready and sets up the test lock.
135      *
136      * Includes config.php to use moodle codebase with $CFG->behat_*
137      * instead of $CFG->prefix and $CFG->dataroot, called once per suite.
138      *
139      * @param BeforeSuiteScope $scope scope passed by event fired before suite.
140      * @static
141      * @throws behat_stop_exception
142      */
143     public static function before_suite(BeforeSuiteScope $scope) {
144         global $CFG;
146         // Defined only when the behat CLI command is running, the moodle init setup process will
147         // read this value and switch to $CFG->behat_dataroot and $CFG->behat_prefix instead of
148         // the normal site.
149         if (!defined('BEHAT_TEST')) {
150             define('BEHAT_TEST', 1);
151         }
153         if (!defined('CLI_SCRIPT')) {
154             define('CLI_SCRIPT', 1);
155         }
157         // With BEHAT_TEST we will be using $CFG->behat_* instead of $CFG->dataroot, $CFG->prefix and $CFG->wwwroot.
158         require_once(__DIR__ . '/../../../config.php');
160         // Now that we are MOODLE_INTERNAL.
161         require_once(__DIR__ . '/../../behat/classes/behat_command.php');
162         require_once(__DIR__ . '/../../behat/classes/behat_selectors.php');
163         require_once(__DIR__ . '/../../behat/classes/behat_context_helper.php');
164         require_once(__DIR__ . '/../../behat/classes/util.php');
165         require_once(__DIR__ . '/../../testing/classes/test_lock.php');
166         require_once(__DIR__ . '/../../testing/classes/nasty_strings.php');
168         // Avoids vendor/bin/behat to be executed directly without test environment enabled
169         // to prevent undesired db & dataroot modifications, this is also checked
170         // before each scenario (accidental user deletes) in the BeforeScenario hook.
172         if (!behat_util::is_test_mode_enabled()) {
173             throw new behat_stop_exception('Behat only can run if test mode is enabled. More info in ' .
174                 behat_command::DOCS_URL);
175         }
177         // Reset all data, before checking for check_server_status.
178         // If not done, then it can return apache error, while running tests.
179         behat_util::clean_tables_updated_by_scenario_list();
180         behat_util::reset_all_data();
182         // Check if server is running and using same version for cli and apache.
183         behat_util::check_server_status();
185         // Prevents using outdated data, upgrade script would start and tests would fail.
186         if (!behat_util::is_test_data_updated()) {
187             $commandpath = 'php admin/tool/behat/cli/init.php';
188             throw new behat_stop_exception("Your behat test site is outdated, please run\n\n    " .
189                     $commandpath . "\n\nfrom your moodle dirroot to drop and install the behat test site again.");
190         }
191         // Avoid parallel tests execution, it continues when the previous lock is released.
192         test_lock::acquire('behat');
194         if (!empty($CFG->behat_faildump_path) && !is_writable($CFG->behat_faildump_path)) {
195             throw new behat_stop_exception('You set $CFG->behat_faildump_path to a non-writable directory');
196         }
198         // Handle interrupts on PHP7.
199         if (extension_loaded('pcntl')) {
200             $disabled = explode(',', ini_get('disable_functions'));
201             if (!in_array('pcntl_signal', $disabled)) {
202                 declare(ticks = 1);
203             }
204         }
205     }
207     /**
208      * Run final tests before running the suite.
209      *
210      * @BeforeSuite
211      * @param BeforeSuiteScope $scope scope passed by event fired before suite.
212      */
213     public static function before_suite_final_checks(BeforeSuiteScope $scope) {
214         $happy = defined('BEHAT_TEST');
215         $happy = $happy && defined('BEHAT_SITE_RUNNING');
216         $happy = $happy && php_sapi_name() == 'cli';
217         $happy = $happy && behat_util::is_test_mode_enabled();
218         $happy = $happy && behat_util::is_test_site();
220         if (!$happy) {
221             error_log('Behat only can modify the test database and the test dataroot!');
222             exit(1);
223         }
224     }
226     /**
227      * Gives access to moodle codebase, to keep track of feature start time.
228      *
229      * @param BeforeFeatureScope $scope scope passed by event fired before feature.
230      * @BeforeFeature
231      */
232     public static function before_feature(BeforeFeatureScope $scope) {
233         if (!defined('BEHAT_FEATURE_TIMING_FILE')) {
234             return;
235         }
236         $file = $scope->getFeature()->getFile();
237         self::$timings[$file] = microtime(true);
238     }
240     /**
241      * Gives access to moodle codebase, to keep track of feature end time.
242      *
243      * @param AfterFeatureScope $scope scope passed by event fired after feature.
244      * @AfterFeature
245      */
246     public static function after_feature(AfterFeatureScope $scope) {
247         if (!defined('BEHAT_FEATURE_TIMING_FILE')) {
248             return;
249         }
250         $file = $scope->getFeature()->getFile();
251         self::$timings[$file] = microtime(true) - self::$timings[$file];
252         // Probably didn't actually run this, don't output it.
253         if (self::$timings[$file] < 1) {
254             unset(self::$timings[$file]);
255         }
256     }
258     /**
259      * Gives access to moodle codebase, to keep track of suite timings.
260      *
261      * @param AfterSuiteScope $scope scope passed by event fired after suite.
262      * @AfterSuite
263      */
264     public static function after_suite(AfterSuiteScope $scope) {
265         if (!defined('BEHAT_FEATURE_TIMING_FILE')) {
266             return;
267         }
268         $realroot = realpath(__DIR__.'/../../../').'/';
269         foreach (self::$timings as $k => $v) {
270             $new = str_replace($realroot, '', $k);
271             self::$timings[$new] = round($v, 1);
272             unset(self::$timings[$k]);
273         }
274         if ($existing = @json_decode(file_get_contents(BEHAT_FEATURE_TIMING_FILE), true)) {
275             self::$timings = array_merge($existing, self::$timings);
276         }
277         arsort(self::$timings);
278         @file_put_contents(BEHAT_FEATURE_TIMING_FILE, json_encode(self::$timings, JSON_PRETTY_PRINT));
279     }
281     /**
282      * Hook to capture before scenario event to get scope.
283      *
284      * @param BeforeScenarioScope $scope scope passed by event fired before scenario.
285      * @BeforeScenario
286      */
287     public function before_scenario_hook(BeforeScenarioScope $scope) {
288         try {
289             $this->before_scenario($scope);
290         } catch (behat_stop_exception $e) {
291             echo $e->getMessage() . PHP_EOL;
292             exit(1);
293         }
294     }
296     /**
297      * Helper function to restart the Mink session.
298      */
299     protected function restart_session(): void {
300         $session = $this->getSession();
301         if ($session->isStarted()) {
302             $session->restart();
303         } else {
304             $session->start();
305         }
306         if ($this->running_javascript() && $this->getSession()->getDriver()->getWebDriverSessionId() === 'session') {
307             throw new DriverException('Unable to create valid session');
308         }
309     }
311     /**
312      * Resets the test environment.
313      *
314      * @param BeforeScenarioScope $scope scope passed by event fired before scenario.
315      * @throws behat_stop_exception If here we are not using the test database it should be because of a coding error
316      */
317     public function before_scenario(BeforeScenarioScope $scope) {
318         global $DB, $CFG;
320         if (self::$initprocessesfinished) {
321             $this->restart_session();
322         } else {
323             $moreinfo = 'More info in ' . behat_command::DOCS_URL;
324             $driverexceptionmsg = 'Selenium server is not running, you need to start it to run tests that involve Javascript. ' . $moreinfo;
326             try {
327                 $this->restart_session();
328             } catch (CurlExec $e) {
329                 // Exception thrown by WebDriver, so only @javascript tests will be caugth; in
330                 // behat_util::check_server_status() we already checked that the server is running.
331                 throw new behat_stop_exception(
332                     $driverexceptionmsg . '. ' .
333                     $e->getMessage() . "\n\n" .
334                     format_backtrace($e->getTrace(), true)
335                 );
336             } catch (DriverException $e) {
337                 throw new behat_stop_exception(
338                     $driverexceptionmsg . '. ' .
339                     $e->getMessage() . "\n\n" .
340                     format_backtrace($e->getTrace(), true)
341                 );
342             } catch (UnknownError $e) {
343                 // Generic 'I have no idea' Selenium error. Custom exception to provide more feedback about possible solutions.
344                 throw new behat_stop_exception(
345                     $e->getMessage() . "\n\n" .
346                     format_backtrace($e->getTrace(), true)
347                 );
348             }
349         }
351         $suitename = $scope->getSuite()->getName();
353         // Register behat selectors for theme, if suite is changed. We do it for every suite change.
354         if ($suitename !== self::$runningsuite) {
355             self::$runningsuite = $suitename;
356             behat_context_helper::set_environment($scope->getEnvironment());
358             // We need the Mink session to do it and we do it only before the first scenario.
359             $namedpartialclass = 'behat_partial_named_selector';
360             $namedexactclass = 'behat_exact_named_selector';
362             // If override selector exist, then set it as default behat selectors class.
363             $overrideclass = behat_config_util::get_behat_theme_selector_override_classname($suitename, 'named_partial', true);
364             if (class_exists($overrideclass)) {
365                 $namedpartialclass = $overrideclass;
366             }
368             // If override selector exist, then set it as default behat selectors class.
369             $overrideclass = behat_config_util::get_behat_theme_selector_override_classname($suitename, 'named_exact', true);
370             if (class_exists($overrideclass)) {
371                 $namedexactclass = $overrideclass;
372             }
374             $this->getSession()->getSelectorsHandler()->registerSelector('named_partial', new $namedpartialclass());
375             $this->getSession()->getSelectorsHandler()->registerSelector('named_exact', new $namedexactclass());
377             // Register component named selectors.
378             foreach (\core_component::get_component_names() as $component) {
379                 $this->register_component_selectors_for_component($component);
380             }
382         }
384         // Reset $SESSION.
385         \core\session\manager::init_empty_session();
387         // Ignore E_NOTICE and E_WARNING during reset, as this might be caused because of some existing process
388         // running ajax. This will be investigated in another issue.
389         $errorlevel = error_reporting();
390         error_reporting($errorlevel & ~E_NOTICE & ~E_WARNING);
391         behat_util::reset_all_data();
392         error_reporting($errorlevel);
394         if ($this->running_javascript()) {
395             // Fetch the user agent.
396             // This isused to choose between the SVG/Non-SVG versions of themes.
397             $useragent = $this->getSession()->evaluateScript('return navigator.userAgent;');
398             \core_useragent::instance(true, $useragent);
400             // Restore the saved themes.
401             behat_util::restore_saved_themes();
402         }
404         // Assign valid data to admin user (some generator-related code needs a valid user).
405         $user = $DB->get_record('user', array('username' => 'admin'));
406         \core\session\manager::set_user($user);
408         // Set the theme if not default.
409         if ($suitename !== "default") {
410             set_config('theme', $suitename);
411         }
413         // Reset the scenariorunning variable to ensure that Step 0 occurs.
414         $this->scenariorunning = false;
416         // Set up the tags for current scenario.
417         self::fetch_tags_for_scenario($scope);
419         // If scenario requires the Moodle app to be running, set this up.
420         if ($this->has_tag('app')) {
421             $this->execute('behat_app::start_scenario');
423             return;
424         }
426         // Run all test with medium (1024x768) screen size, to avoid responsive problems.
427         $this->resize_window('medium');
428     }
430     /**
431      * Hook to open the site root before the first step in the suite.
432      * Yes, this is in a strange location and should be in the BeforeScenario hook, but failures in the test setUp lead
433      * to the test being incorrectly marked as skipped with no way to force the test to be failed.
434      *
435      * @param BeforeStepScope $scope
436      * @BeforeStep
437      */
438     public function before_step(BeforeStepScope $scope) {
439         global $CFG;
441         if (!$this->scenariorunning) {
442             // We need to visit / before the first step in any Scenario.
443             // This is our Step 0.
444             // Ideally this would be in the BeforeScenario hook, but any exception in there will lead to the test being
445             // skipped rather than it being failed.
446             //
447             // We also need to check that the site returned is a Behat site.
448             // Again, this would be better in the BeforeSuite hook, but that does not have access to the selectors in
449             // order to perform the necessary searches.
450             $session = $this->getSession();
451             $session->visit($this->locate_path('/'));
453             // Checking that the root path is a Moodle test site.
454             if (self::is_first_scenario()) {
455                 $message = "The base URL ({$CFG->wwwroot}) is not a behat test site. " .
456                     'Ensure that you started the built-in web server in the correct directory, ' .
457                     'or that your web server is correctly set up and started.';
459                 $this->find(
460                         "xpath", "//head/child::title[normalize-space(.)='" . behat_util::BEHATSITENAME . "']",
461                         new ExpectationException($message, $session)
462                     );
464             }
465             $this->scenariorunning = true;
466         }
467     }
469     /**
470      * Sets up the tags for the current scenario.
471      *
472      * @param \Behat\Behat\Hook\Scope\BeforeScenarioScope $scope Scope
473      */
474     protected static function fetch_tags_for_scenario(\Behat\Behat\Hook\Scope\BeforeScenarioScope $scope) {
475         self::$scenariotags = array_flip(array_merge(
476             $scope->getScenario()->getTags(),
477             $scope->getFeature()->getTags()
478         ));
479     }
481     /**
482      * Gets the tags for the current scenario
483      *
484      * @return array Array where key is tag name and value is an integer
485      */
486     public static function get_tags_for_scenario() : array {
487         return self::$scenariotags;
488     }
490     /**
491      * Wait for JS to complete before beginning interacting with the DOM.
492      *
493      * Executed only when running against a real browser. We wrap it
494      * all in a try & catch to forward the exception to i_look_for_exceptions
495      * so the exception will be at scenario level, which causes a failure, by
496      * default would be at framework level, which will stop the execution of
497      * the run.
498      *
499      * @param BeforeStepScope $scope scope passed by event fired before step.
500      * @BeforeStep
501      */
502     public function before_step_javascript(BeforeStepScope $scope) {
503         self::$currentstepexception = null;
505         // Only run if JS.
506         if ($this->running_javascript()) {
507             try {
508                 $this->wait_for_pending_js();
509             } catch (Exception $e) {
510                 self::$currentstepexception = $e;
511             }
512         }
513     }
515     /**
516      * Wait for JS to complete after finishing the step.
517      *
518      * With this we ensure that there are not AJAX calls
519      * still in progress.
520      *
521      * Executed only when running against a real browser. We wrap it
522      * all in a try & catch to forward the exception to i_look_for_exceptions
523      * so the exception will be at scenario level, which causes a failure, by
524      * default would be at framework level, which will stop the execution of
525      * the run.
526      *
527      * @param AfterStepScope $scope scope passed by event fired after step..
528      * @AfterStep
529      */
530     public function after_step_javascript(AfterStepScope $scope) {
531         global $CFG, $DB;
533         // If step is undefined then throw exception, to get failed exit code.
534         if ($scope->getTestResult()->getResultCode() === Behat\Behat\Tester\Result\StepResult::UNDEFINED) {
535             throw new coding_exception("Step '" . $scope->getStep()->getText() . "'' is undefined.");
536         }
538         $isfailed = $scope->getTestResult()->getResultCode() === Behat\Testwork\Tester\Result\TestResult::FAILED;
540         // Abort any open transactions to prevent subsequent tests hanging.
541         // This does the same as abort_all_db_transactions(), but doesn't call error_log() as we don't
542         // want to see a message in the behat output.
543         if (($scope->getTestResult() instanceof \Behat\Behat\Tester\Result\ExecutedStepResult) &&
544             $scope->getTestResult()->hasException()) {
545             if ($DB && $DB->is_transaction_started()) {
546                 $DB->force_transaction_rollback();
547             }
548         }
550         if ($isfailed && !empty($CFG->behat_faildump_path)) {
551             // Save the page content (html).
552             $this->take_contentdump($scope);
554             if ($this->running_javascript()) {
555                 // Save a screenshot.
556                 $this->take_screenshot($scope);
557             }
558         }
560         if ($isfailed && !empty($CFG->behat_pause_on_fail)) {
561             $exception = $scope->getTestResult()->getException();
562             $message = "<colour:lightRed>Scenario failed. ";
563             $message .= "<colour:lightYellow>Paused for inspection. Press <colour:lightRed>Enter/Return<colour:lightYellow> to continue.<newline>";
564             $message .= "<colour:lightRed>Exception follows:<newline>";
565             $message .= trim($exception->getMessage());
566             behat_util::pause($this->getSession(), $message);
567         }
569         // Only run if JS.
570         if (!$this->running_javascript()) {
571             return;
572         }
574         try {
575             $this->wait_for_pending_js();
576             self::$currentstepexception = null;
577         } catch (UnexpectedAlertOpen $e) {
578             self::$currentstepexception = $e;
580             // Accepting the alert so the framework can continue properly running
581             // the following scenarios. Some browsers already closes the alert, so
582             // wrapping in a try & catch.
583             try {
584                 $this->getSession()->getDriver()->getWebDriverSession()->accept_alert();
585             } catch (Exception $e) {
586                 // Catching the generic one as we never know how drivers reacts here.
587             }
588         } catch (Exception $e) {
589             self::$currentstepexception = $e;
590         }
591     }
593     /**
594      * Reset the session between each scenario.
595      *
596      * @param AfterScenarioScope $scope scope passed by event fired after scenario.
597      * @AfterScenario
598      */
599     public function reset_webdriver_between_scenarios(AfterScenarioScope $scope) {
600         $this->getSession()->stop();
601     }
603     /**
604      * Getter for self::$faildumpdirname
605      *
606      * @return string
607      */
608     protected function get_run_faildump_dir() {
609         return self::$faildumpdirname;
610     }
612     /**
613      * Take screenshot when a step fails.
614      *
615      * @throws Exception
616      * @param AfterStepScope $scope scope passed by event after step.
617      */
618     protected function take_screenshot(AfterStepScope $scope) {
619         // Goutte can't save screenshots.
620         if (!$this->running_javascript()) {
621             return false;
622         }
624         // Some drivers (e.g. chromedriver) may throw an exception while trying to take a screenshot.  If this isn't handled,
625         // the behat run dies.  We don't want to lose the information about the failure that triggered the screenshot,
626         // so let's log the exception message to a file (to explain why there's no screenshot) and allow the run to continue,
627         // handling the failure as normal.
628         try {
629             list ($dir, $filename) = $this->get_faildump_filename($scope, 'png');
630             $this->saveScreenshot($filename, $dir);
631         } catch (Exception $e) {
632             // Catching all exceptions as we don't know what the driver might throw.
633             list ($dir, $filename) = $this->get_faildump_filename($scope, 'txt');
634             $message = "Could not save screenshot due to an error\n" . $e->getMessage();
635             file_put_contents($dir . DIRECTORY_SEPARATOR . $filename, $message);
636         }
637     }
639     /**
640      * Take a dump of the page content when a step fails.
641      *
642      * @throws Exception
643      * @param AfterStepScope $scope scope passed by event after step.
644      */
645     protected function take_contentdump(AfterStepScope $scope) {
646         list ($dir, $filename) = $this->get_faildump_filename($scope, 'html');
648         try {
649             // Driver may throw an exception during getContent(), so do it first to avoid getting an empty file.
650             $content = $this->getSession()->getPage()->getContent();
651         } catch (Exception $e) {
652             // Catching all exceptions as we don't know what the driver might throw.
653             $content = "Could not save contentdump due to an error\n" . $e->getMessage();
654         }
655         file_put_contents($dir . DIRECTORY_SEPARATOR . $filename, $content);
656     }
658     /**
659      * Determine the full pathname to store a failure-related dump.
660      *
661      * This is used for content such as the DOM, and screenshots.
662      *
663      * @param AfterStepScope $scope scope passed by event after step.
664      * @param String $filetype The file suffix to use. Limited to 4 chars.
665      */
666     protected function get_faildump_filename(AfterStepScope $scope, $filetype) {
667         global $CFG;
669         // All the contentdumps should be in the same parent dir.
670         if (!$faildumpdir = self::get_run_faildump_dir()) {
671             $faildumpdir = self::$faildumpdirname = date('Ymd_His');
673             $dir = $CFG->behat_faildump_path . DIRECTORY_SEPARATOR . $faildumpdir;
675             if (!is_dir($dir) && !mkdir($dir, $CFG->directorypermissions, true)) {
676                 // It shouldn't, we already checked that the directory is writable.
677                 throw new Exception('No directories can be created inside $CFG->behat_faildump_path, check the directory permissions.');
678             }
679         } else {
680             // We will always need to know the full path.
681             $dir = $CFG->behat_faildump_path . DIRECTORY_SEPARATOR . $faildumpdir;
682         }
684         // The scenario title + the failed step text.
685         // We want a i-am-the-scenario-title_i-am-the-failed-step.$filetype format.
686         $filename = $scope->getFeature()->getTitle() . '_' . $scope->getStep()->getText();
688         // As file name is limited to 255 characters. Leaving 5 chars for line number and 4 chars for the file.
689         // extension as we allow .png for images and .html for DOM contents.
690         $filenamelen = 245;
692         // Suffix suite name to faildump file, if it's not default suite.
693         $suitename = $scope->getSuite()->getName();
694         if ($suitename != 'default') {
695             $suitename = '_' . $suitename;
696             $filenamelen = $filenamelen - strlen($suitename);
697         } else {
698             // No need to append suite name for default.
699             $suitename = '';
700         }
702         $filename = preg_replace('/([^a-zA-Z0-9\_]+)/', '-', $filename);
703         $filename = substr($filename, 0, $filenamelen) . $suitename . '_' . $scope->getStep()->getLine() . '.' . $filetype;
705         return array($dir, $filename);
706     }
708     /**
709      * Internal step definition to find exceptions, debugging() messages and PHP debug messages.
710      *
711      * Part of behat_hooks class as is part of the testing framework, is auto-executed
712      * after each step so no features will splicitly use it.
713      *
714      * @Given /^I look for exceptions$/
715      * @throw Exception Unknown type, depending on what we caught in the hook or basic \Exception.
716      * @see Moodle\BehatExtension\EventDispatcher\Tester\ChainedStepTester
717      */
718     public function i_look_for_exceptions() {
719         // If the step already failed in a hook throw the exception.
720         if (!is_null(self::$currentstepexception)) {
721             throw self::$currentstepexception;
722         }
724         $this->look_for_exceptions();
725     }
727     /**
728      * Returns whether the first scenario of the suite is running
729      *
730      * @return bool
731      */
732     protected static function is_first_scenario() {
733         return !(self::$initprocessesfinished);
734     }
736     /**
737      * Register a set of component selectors.
738      *
739      * @param string $component
740      */
741     public function register_component_selectors_for_component(string $component): void {
742         $context = behat_context_helper::get_component_context($component);
744         if ($context === null) {
745             return;
746         }
748         $namedpartial = $this->getSession()->getSelectorsHandler()->getSelector('named_partial');
749         $namedexact = $this->getSession()->getSelectorsHandler()->getSelector('named_exact');
751         // Replacements must come before selectors as they are used in the selectors.
752         foreach ($context->get_named_replacements() as $replacement) {
753             $namedpartial->register_replacement($component, $replacement);
754             $namedexact->register_replacement($component, $replacement);
755         }
757         foreach ($context->get_partial_named_selectors() as $selector) {
758             $namedpartial->register_component_selector($component, $selector);
759         }
761         foreach ($context->get_exact_named_selectors() as $selector) {
762             $namedexact->register_component_selector($component, $selector);
763         }
765     }
767     /**
768      * Mark the first step as having been completed.
769      *
770      * This must be the last BeforeStep hook in the setup.
771      *
772      * @param BeforeStepScope $scope
773      * @BeforeStep
774      */
775     public function first_step_setup_complete(BeforeStepScope $scope) {
776         self::$initprocessesfinished = true;
777     }
781 /**
782  * Behat stop exception
783  *
784  * This exception is thrown from before suite or scenario if any setup problem found.
785  *
786  * @package    core_test
787  * @copyright  2016 Rajesh Taneja <rajesh@moodle.com>
788  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
789  */
790 class behat_stop_exception extends \Exception {