MDL-70397 behat: Prevent browser restarting after initial start
[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     /** @var bool Whether the first javascript scenario has been seen yet */
72     protected static $firstjavascriptscenarioseen = false;
74     /**
75      * @var bool Scenario running
76      */
77     protected $scenariorunning = false;
79     /**
80      * Some exceptions can only be caught in a before or after step hook,
81      * they can not be thrown there as they will provoke a framework level
82      * failure, but we can store them here to fail the step in i_look_for_exceptions()
83      * which result will be parsed by the framework as the last step result.
84      *
85      * @var Null or the exception last step throw in the before or after hook.
86      */
87     protected static $currentstepexception = null;
89     /**
90      * If an Exception is thrown in the BeforeScenario hook it will cause the Scenario to be skipped, and the exit code
91      * to be non-zero triggering a potential rerun.
92      *
93      * To combat this the exception is stored and re-thrown when looking for exceptions.
94      * This allows the test to instead be failed and re-run correctly.
95      *
96      * @var null|Exception
97      */
98     protected static $currentscenarioexception = null;
100     /**
101      * If we are saving any kind of dump on failure we should use the same parent dir during a run.
102      *
103      * @var The parent dir name
104      */
105     protected static $faildumpdirname = false;
107     /**
108      * Keeps track of time taken by feature to execute.
109      *
110      * @var array list of feature timings
111      */
112     protected static $timings = array();
114     /**
115      * Keeps track of current running suite name.
116      *
117      * @var string current running suite name
118      */
119     protected static $runningsuite = '';
121     /**
122      * @var array Array (with tag names in keys) of all tags in current scenario.
123      */
124     protected static $scenariotags;
126     /**
127      * Gives access to moodle codebase, ensures all is ready and sets up the test lock.
128      *
129      * Includes config.php to use moodle codebase with $CFG->behat_* instead of $CFG->prefix and $CFG->dataroot, called
130      * once per suite.
131      *
132      * @BeforeSuite
133      * @param BeforeSuiteScope $scope scope passed by event fired before suite.
134      */
135     public static function before_suite_hook(BeforeSuiteScope $scope) {
136         global $CFG;
138         // If behat has been initialised then no need to do this again.
139         if (!self::is_first_scenario()) {
140             return;
141         }
143         // Defined only when the behat CLI command is running, the moodle init setup process will
144         // read this value and switch to $CFG->behat_dataroot and $CFG->behat_prefix instead of
145         // the normal site.
146         if (!defined('BEHAT_TEST')) {
147             define('BEHAT_TEST', 1);
148         }
150         if (!defined('CLI_SCRIPT')) {
151             define('CLI_SCRIPT', 1);
152         }
154         // With BEHAT_TEST we will be using $CFG->behat_* instead of $CFG->dataroot, $CFG->prefix and $CFG->wwwroot.
155         require_once(__DIR__ . '/../../../config.php');
157         // Now that we are MOODLE_INTERNAL.
158         require_once(__DIR__ . '/../../behat/classes/behat_command.php');
159         require_once(__DIR__ . '/../../behat/classes/behat_selectors.php');
160         require_once(__DIR__ . '/../../behat/classes/behat_context_helper.php');
161         require_once(__DIR__ . '/../../behat/classes/util.php');
162         require_once(__DIR__ . '/../../testing/classes/test_lock.php');
163         require_once(__DIR__ . '/../../testing/classes/nasty_strings.php');
165         // Avoids vendor/bin/behat to be executed directly without test environment enabled
166         // to prevent undesired db & dataroot modifications, this is also checked
167         // before each scenario (accidental user deletes) in the BeforeScenario hook.
169         if (!behat_util::is_test_mode_enabled()) {
170             self::log_and_stop('Behat only can run if test mode is enabled. More info in ' .  behat_command::DOCS_URL);
171         }
173         // Reset all data, before checking for check_server_status.
174         // If not done, then it can return apache error, while running tests.
175         behat_util::clean_tables_updated_by_scenario_list();
176         behat_util::reset_all_data();
178         // Check if the web server is running and using same version for cli and apache.
179         behat_util::check_server_status();
181         // Prevents using outdated data, upgrade script would start and tests would fail.
182         if (!behat_util::is_test_data_updated()) {
183             $commandpath = 'php admin/tool/behat/cli/init.php';
184             $message = <<<EOF
185 Your behat test site is outdated, please run the following command from your Moodle dirroot to drop, and reinstall the Behat test site.
187     {$commandpath}
189 EOF;
190             self::log_and_stop($message);
191         }
193         // Avoid parallel tests execution, it continues when the previous lock is released.
194         test_lock::acquire('behat');
196         if (!empty($CFG->behat_faildump_path) && !is_writable($CFG->behat_faildump_path)) {
197             self::log_and_stop(
198                 "The \$CFG->behat_faildump_path value is set to a non-writable directory ({$CFG->behat_faildump_path})."
199             );
200         }
202         // Handle interrupts on PHP7.
203         if (extension_loaded('pcntl')) {
204             $disabled = explode(',', ini_get('disable_functions'));
205             if (!in_array('pcntl_signal', $disabled)) {
206                 declare(ticks = 1);
207             }
208         }
209     }
211     /**
212      * Run final tests before running the suite.
213      *
214      * @BeforeSuite
215      * @param BeforeSuiteScope $scope scope passed by event fired before suite.
216      */
217     public static function before_suite_final_checks(BeforeSuiteScope $scope) {
218         $happy = defined('BEHAT_TEST');
219         $happy = $happy && defined('BEHAT_SITE_RUNNING');
220         $happy = $happy && php_sapi_name() == 'cli';
221         $happy = $happy && behat_util::is_test_mode_enabled();
222         $happy = $happy && behat_util::is_test_site();
224         if (!$happy) {
225             error_log('Behat only can modify the test database and the test dataroot!');
226             exit(1);
227         }
228     }
230     /**
231      * Gives access to moodle codebase, to keep track of feature start time.
232      *
233      * @param BeforeFeatureScope $scope scope passed by event fired before feature.
234      * @BeforeFeature
235      */
236     public static function before_feature(BeforeFeatureScope $scope) {
237         if (!defined('BEHAT_FEATURE_TIMING_FILE')) {
238             return;
239         }
240         $file = $scope->getFeature()->getFile();
241         self::$timings[$file] = microtime(true);
242     }
244     /**
245      * Gives access to moodle codebase, to keep track of feature end time.
246      *
247      * @param AfterFeatureScope $scope scope passed by event fired after feature.
248      * @AfterFeature
249      */
250     public static function after_feature(AfterFeatureScope $scope) {
251         if (!defined('BEHAT_FEATURE_TIMING_FILE')) {
252             return;
253         }
254         $file = $scope->getFeature()->getFile();
255         self::$timings[$file] = microtime(true) - self::$timings[$file];
256         // Probably didn't actually run this, don't output it.
257         if (self::$timings[$file] < 1) {
258             unset(self::$timings[$file]);
259         }
260     }
262     /**
263      * Gives access to moodle codebase, to keep track of suite timings.
264      *
265      * @param AfterSuiteScope $scope scope passed by event fired after suite.
266      * @AfterSuite
267      */
268     public static function after_suite(AfterSuiteScope $scope) {
269         if (!defined('BEHAT_FEATURE_TIMING_FILE')) {
270             return;
271         }
272         $realroot = realpath(__DIR__.'/../../../').'/';
273         foreach (self::$timings as $k => $v) {
274             $new = str_replace($realroot, '', $k);
275             self::$timings[$new] = round($v, 1);
276             unset(self::$timings[$k]);
277         }
278         if ($existing = @json_decode(file_get_contents(BEHAT_FEATURE_TIMING_FILE), true)) {
279             self::$timings = array_merge($existing, self::$timings);
280         }
281         arsort(self::$timings);
282         @file_put_contents(BEHAT_FEATURE_TIMING_FILE, json_encode(self::$timings, JSON_PRETTY_PRINT));
283     }
285     /**
286      * Helper function to restart the Mink session.
287      */
288     protected function restart_session(): void {
289         $session = $this->getSession();
290         if ($session->isStarted()) {
291             $session->restart();
292         } else {
293             $session->start();
294         }
295         if ($this->running_javascript() && $this->getSession()->getDriver()->getWebDriverSessionId() === 'session') {
296             throw new DriverException('Unable to create a valid session');
297         }
298     }
300     /**
301      * Restart the session before each non-javascript scenario.
302      *
303      * @BeforeScenario @~javascript
304      * @param BeforeScenarioScope $scope scope passed by event fired before scenario.
305      */
306     public function before_goutte_scenarios(BeforeScenarioScope $scope) {
307         if ($this->running_javascript()) {
308             // A bug in the BeforeScenario filtering prevents the @~javascript filter on this hook from working
309             // properly.
310             // See https://github.com/Behat/Behat/issues/1235 for further information.
311             return;
312         }
314         $this->restart_session();
315     }
317     /**
318      * Start the session before the first javascript scenario.
319      *
320      * This is treated slightly differently to try to capture when Selenium is not running at all.
321      *
322      * @BeforeScenario @javascript
323      * @param BeforeScenarioScope $scope scope passed by event fired before scenario.
324      */
325     public function before_first_scenario_start_session(BeforeScenarioScope $scope) {
326         if (!self::is_first_javascript_scenario()) {
327             // The first Scenario has started.
328             // The `before_subsequent_scenario_start_session` function will restart the session instead.
329             return;
330         }
332         $docsurl = behat_command::DOCS_URL;
333         $driverexceptionmsg = <<<EOF
335 The Selenium or WebDriver server is not running. You must start it to run tests that involve Javascript.
336 See {$docsurl} for more information.
338 The following debugging information is available:
340 EOF;
343         try {
344             $this->restart_session();
345         } catch (CurlExec | DriverException $e) {
346             // The CurlExec Exception is thrown by WebDriver.
347             self::log_and_stop(
348                 $driverexceptionmsg . '. ' .
349                 $e->getMessage() . "\n\n" .
350                 format_backtrace($e->getTrace(), true)
351             );
352         } catch (UnknownError $e) {
353             // Generic 'I have no idea' Selenium error. Custom exception to provide more feedback about possible solutions.
354             self::log_and_stop(
355                 $e->getMessage() . "\n\n" .
356                 format_backtrace($e->getTrace(), true)
357             );
358         }
359     }
361     /**
362      * Start the session before each javascript scenario.
363      *
364      * Note: Before the first scenario the @see before_first_scenario_start_session() function is used instead.
365      *
366      * @BeforeScenario @javascript
367      * @param BeforeScenarioScope $scope scope passed by event fired before scenario.
368      */
369     public function before_subsequent_scenario_start_session(BeforeScenarioScope $scope) {
370         if (self::is_first_javascript_scenario()) {
371             // The initial init has not yet finished.
372             // The `before_first_scenario_start_session` function will have started the session instead.
373             return;
374         }
375         self::$currentscenarioexception = null;
377         try {
378             $this->restart_session();
379         } catch (Exception $e) {
380             self::$currentscenarioexception = $e;
381         }
382     }
384     /**
385      * Resets the test environment.
386      *
387      * @BeforeScenario
388      * @param BeforeScenarioScope $scope scope passed by event fired before scenario.
389      */
390     public function before_scenario_hook(BeforeScenarioScope $scope) {
391         global $DB;
392         if (self::$currentscenarioexception) {
393             // A BeforeScenario hook triggered an exception and marked this test as failed.
394             // Skip this hook as it will likely fail.
395             return;
396         }
398         $suitename = $scope->getSuite()->getName();
400         // Register behat selectors for theme, if suite is changed. We do it for every suite change.
401         if ($suitename !== self::$runningsuite) {
402             self::$runningsuite = $suitename;
403             behat_context_helper::set_environment($scope->getEnvironment());
405             // We need the Mink session to do it and we do it only before the first scenario.
406             $namedpartialclass = 'behat_partial_named_selector';
407             $namedexactclass = 'behat_exact_named_selector';
409             // If override selector exist, then set it as default behat selectors class.
410             $overrideclass = behat_config_util::get_behat_theme_selector_override_classname($suitename, 'named_partial', true);
411             if (class_exists($overrideclass)) {
412                 $namedpartialclass = $overrideclass;
413             }
415             // If override selector exist, then set it as default behat selectors class.
416             $overrideclass = behat_config_util::get_behat_theme_selector_override_classname($suitename, 'named_exact', true);
417             if (class_exists($overrideclass)) {
418                 $namedexactclass = $overrideclass;
419             }
421             $this->getSession()->getSelectorsHandler()->registerSelector('named_partial', new $namedpartialclass());
422             $this->getSession()->getSelectorsHandler()->registerSelector('named_exact', new $namedexactclass());
424             // Register component named selectors.
425             foreach (\core_component::get_component_names() as $component) {
426                 $this->register_component_selectors_for_component($component);
427             }
429         }
431         // Reset $SESSION.
432         \core\session\manager::init_empty_session();
434         // Ignore E_NOTICE and E_WARNING during reset, as this might be caused because of some existing process
435         // running ajax. This will be investigated in another issue.
436         $errorlevel = error_reporting();
437         error_reporting($errorlevel & ~E_NOTICE & ~E_WARNING);
438         behat_util::reset_all_data();
439         error_reporting($errorlevel);
441         if ($this->running_javascript()) {
442             // Fetch the user agent.
443             // This isused to choose between the SVG/Non-SVG versions of themes.
444             $useragent = $this->getSession()->evaluateScript('return navigator.userAgent;');
445             \core_useragent::instance(true, $useragent);
447             // Restore the saved themes.
448             behat_util::restore_saved_themes();
449         }
451         // Assign valid data to admin user (some generator-related code needs a valid user).
452         $user = $DB->get_record('user', array('username' => 'admin'));
453         \core\session\manager::set_user($user);
455         // Set the theme if not default.
456         if ($suitename !== "default") {
457             set_config('theme', $suitename);
458         }
460         // Reset the scenariorunning variable to ensure that Step 0 occurs.
461         $this->scenariorunning = false;
463         // Set up the tags for current scenario.
464         self::fetch_tags_for_scenario($scope);
466         // If scenario requires the Moodle app to be running, set this up.
467         if ($this->has_tag('app')) {
468             $this->execute('behat_app::start_scenario');
470             return;
471         }
473         // Run all test with medium (1024x768) screen size, to avoid responsive problems.
474         $this->resize_window('medium');
475     }
477     /**
478      * Mark the first Javascript Scenario as have been seen.
479      *
480      * @BeforeScenario
481      * @param BeforeScenarioScope $scope scope passed by event fired before scenario.
482      */
483     public function mark_first_js_scenario_as_seen(BeforeScenarioScope $scope) {
484         self::$firstjavascriptscenarioseen = true;
485     }
487     /**
488      * Hook to open the site root before the first step in the suite.
489      * Yes, this is in a strange location and should be in the BeforeScenario hook, but failures in the test setUp lead
490      * to the test being incorrectly marked as skipped with no way to force the test to be failed.
491      *
492      * @param BeforeStepScope $scope
493      * @BeforeStep
494      */
495     public function before_step(BeforeStepScope $scope) {
496         global $CFG;
498         if (!$this->scenariorunning) {
499             // We need to visit / before the first step in any Scenario.
500             // This is our Step 0.
501             // Ideally this would be in the BeforeScenario hook, but any exception in there will lead to the test being
502             // skipped rather than it being failed.
503             //
504             // We also need to check that the site returned is a Behat site.
505             // Again, this would be better in the BeforeSuite hook, but that does not have access to the selectors in
506             // order to perform the necessary searches.
507             $session = $this->getSession();
508             $this->execute('behat_general::i_visit', ['/']);
510             // Checking that the root path is a Moodle test site.
511             if (self::is_first_scenario()) {
512                 $message = "The base URL ({$CFG->wwwroot}) is not a behat test site. " .
513                     'Ensure that you started the built-in web server in the correct directory, ' .
514                     'or that your web server is correctly set up and started.';
516                 $this->find(
517                         "xpath", "//head/child::title[normalize-space(.)='" . behat_util::BEHATSITENAME . "']",
518                         new ExpectationException($message, $session)
519                     );
521             }
522             $this->scenariorunning = true;
523         }
524     }
526     /**
527      * Sets up the tags for the current scenario.
528      *
529      * @param \Behat\Behat\Hook\Scope\BeforeScenarioScope $scope Scope
530      */
531     protected static function fetch_tags_for_scenario(\Behat\Behat\Hook\Scope\BeforeScenarioScope $scope) {
532         self::$scenariotags = array_flip(array_merge(
533             $scope->getScenario()->getTags(),
534             $scope->getFeature()->getTags()
535         ));
536     }
538     /**
539      * Gets the tags for the current scenario
540      *
541      * @return array Array where key is tag name and value is an integer
542      */
543     public static function get_tags_for_scenario() : array {
544         return self::$scenariotags;
545     }
547     /**
548      * Wait for JS to complete before beginning interacting with the DOM.
549      *
550      * Executed only when running against a real browser. We wrap it
551      * all in a try & catch to forward the exception to i_look_for_exceptions
552      * so the exception will be at scenario level, which causes a failure, by
553      * default would be at framework level, which will stop the execution of
554      * the run.
555      *
556      * @param BeforeStepScope $scope scope passed by event fired before step.
557      * @BeforeStep
558      */
559     public function before_step_javascript(BeforeStepScope $scope) {
560         if (self::$currentscenarioexception) {
561             // A BeforeScenario hook triggered an exception and marked this test as failed.
562             // Skip this hook as it will likely fail.
563             return;
564         }
566         self::$currentstepexception = null;
568         // Only run if JS.
569         if ($this->running_javascript()) {
570             try {
571                 $this->wait_for_pending_js();
572             } catch (Exception $e) {
573                 self::$currentstepexception = $e;
574             }
575         }
576     }
578     /**
579      * Wait for JS to complete after finishing the step.
580      *
581      * With this we ensure that there are not AJAX calls
582      * still in progress.
583      *
584      * Executed only when running against a real browser. We wrap it
585      * all in a try & catch to forward the exception to i_look_for_exceptions
586      * so the exception will be at scenario level, which causes a failure, by
587      * default would be at framework level, which will stop the execution of
588      * the run.
589      *
590      * @param AfterStepScope $scope scope passed by event fired after step..
591      * @AfterStep
592      */
593     public function after_step_javascript(AfterStepScope $scope) {
594         global $CFG, $DB;
596         // If step is undefined then throw exception, to get failed exit code.
597         if ($scope->getTestResult()->getResultCode() === Behat\Behat\Tester\Result\StepResult::UNDEFINED) {
598             throw new coding_exception("Step '" . $scope->getStep()->getText() . "'' is undefined.");
599         }
601         $isfailed = $scope->getTestResult()->getResultCode() === Behat\Testwork\Tester\Result\TestResult::FAILED;
603         // Abort any open transactions to prevent subsequent tests hanging.
604         // This does the same as abort_all_db_transactions(), but doesn't call error_log() as we don't
605         // want to see a message in the behat output.
606         if (($scope->getTestResult() instanceof \Behat\Behat\Tester\Result\ExecutedStepResult) &&
607             $scope->getTestResult()->hasException()) {
608             if ($DB && $DB->is_transaction_started()) {
609                 $DB->force_transaction_rollback();
610             }
611         }
613         if ($isfailed && !empty($CFG->behat_faildump_path)) {
614             // Save the page content (html).
615             $this->take_contentdump($scope);
617             if ($this->running_javascript()) {
618                 // Save a screenshot.
619                 $this->take_screenshot($scope);
620             }
621         }
623         if ($isfailed && !empty($CFG->behat_pause_on_fail)) {
624             $exception = $scope->getTestResult()->getException();
625             $message = "<colour:lightRed>Scenario failed. ";
626             $message .= "<colour:lightYellow>Paused for inspection. Press <colour:lightRed>Enter/Return<colour:lightYellow> to continue.<newline>";
627             $message .= "<colour:lightRed>Exception follows:<newline>";
628             $message .= trim($exception->getMessage());
629             behat_util::pause($this->getSession(), $message);
630         }
632         // Only run if JS.
633         if (!$this->running_javascript()) {
634             return;
635         }
637         try {
638             $this->wait_for_pending_js();
639             self::$currentstepexception = null;
640         } catch (UnexpectedAlertOpen $e) {
641             self::$currentstepexception = $e;
643             // Accepting the alert so the framework can continue properly running
644             // the following scenarios. Some browsers already closes the alert, so
645             // wrapping in a try & catch.
646             try {
647                 $this->getSession()->getDriver()->getWebDriverSession()->accept_alert();
648             } catch (Exception $e) {
649                 // Catching the generic one as we never know how drivers reacts here.
650             }
651         } catch (Exception $e) {
652             self::$currentstepexception = $e;
653         }
654     }
656     /**
657      * Reset the session between each scenario.
658      *
659      * @param AfterScenarioScope $scope scope passed by event fired after scenario.
660      * @AfterScenario
661      */
662     public function reset_webdriver_between_scenarios(AfterScenarioScope $scope) {
663         $this->getSession()->stop();
664     }
666     /**
667      * Getter for self::$faildumpdirname
668      *
669      * @return string
670      */
671     protected function get_run_faildump_dir() {
672         return self::$faildumpdirname;
673     }
675     /**
676      * Take screenshot when a step fails.
677      *
678      * @throws Exception
679      * @param AfterStepScope $scope scope passed by event after step.
680      */
681     protected function take_screenshot(AfterStepScope $scope) {
682         // Goutte can't save screenshots.
683         if (!$this->running_javascript()) {
684             return false;
685         }
687         // Some drivers (e.g. chromedriver) may throw an exception while trying to take a screenshot.  If this isn't handled,
688         // the behat run dies.  We don't want to lose the information about the failure that triggered the screenshot,
689         // so let's log the exception message to a file (to explain why there's no screenshot) and allow the run to continue,
690         // handling the failure as normal.
691         try {
692             list ($dir, $filename) = $this->get_faildump_filename($scope, 'png');
693             $this->saveScreenshot($filename, $dir);
694         } catch (Exception $e) {
695             // Catching all exceptions as we don't know what the driver might throw.
696             list ($dir, $filename) = $this->get_faildump_filename($scope, 'txt');
697             $message = "Could not save screenshot due to an error\n" . $e->getMessage();
698             file_put_contents($dir . DIRECTORY_SEPARATOR . $filename, $message);
699         }
700     }
702     /**
703      * Take a dump of the page content when a step fails.
704      *
705      * @throws Exception
706      * @param AfterStepScope $scope scope passed by event after step.
707      */
708     protected function take_contentdump(AfterStepScope $scope) {
709         list ($dir, $filename) = $this->get_faildump_filename($scope, 'html');
711         try {
712             // Driver may throw an exception during getContent(), so do it first to avoid getting an empty file.
713             $content = $this->getSession()->getPage()->getContent();
714         } catch (Exception $e) {
715             // Catching all exceptions as we don't know what the driver might throw.
716             $content = "Could not save contentdump due to an error\n" . $e->getMessage();
717         }
718         file_put_contents($dir . DIRECTORY_SEPARATOR . $filename, $content);
719     }
721     /**
722      * Determine the full pathname to store a failure-related dump.
723      *
724      * This is used for content such as the DOM, and screenshots.
725      *
726      * @param AfterStepScope $scope scope passed by event after step.
727      * @param String $filetype The file suffix to use. Limited to 4 chars.
728      */
729     protected function get_faildump_filename(AfterStepScope $scope, $filetype) {
730         global $CFG;
732         // All the contentdumps should be in the same parent dir.
733         if (!$faildumpdir = self::get_run_faildump_dir()) {
734             $faildumpdir = self::$faildumpdirname = date('Ymd_His');
736             $dir = $CFG->behat_faildump_path . DIRECTORY_SEPARATOR . $faildumpdir;
738             if (!is_dir($dir) && !mkdir($dir, $CFG->directorypermissions, true)) {
739                 // It shouldn't, we already checked that the directory is writable.
740                 throw new Exception('No directories can be created inside $CFG->behat_faildump_path, check the directory permissions.');
741             }
742         } else {
743             // We will always need to know the full path.
744             $dir = $CFG->behat_faildump_path . DIRECTORY_SEPARATOR . $faildumpdir;
745         }
747         // The scenario title + the failed step text.
748         // We want a i-am-the-scenario-title_i-am-the-failed-step.$filetype format.
749         $filename = $scope->getFeature()->getTitle() . '_' . $scope->getStep()->getText();
751         // As file name is limited to 255 characters. Leaving 5 chars for line number and 4 chars for the file.
752         // extension as we allow .png for images and .html for DOM contents.
753         $filenamelen = 245;
755         // Suffix suite name to faildump file, if it's not default suite.
756         $suitename = $scope->getSuite()->getName();
757         if ($suitename != 'default') {
758             $suitename = '_' . $suitename;
759             $filenamelen = $filenamelen - strlen($suitename);
760         } else {
761             // No need to append suite name for default.
762             $suitename = '';
763         }
765         $filename = preg_replace('/([^a-zA-Z0-9\_]+)/', '-', $filename);
766         $filename = substr($filename, 0, $filenamelen) . $suitename . '_' . $scope->getStep()->getLine() . '.' . $filetype;
768         return array($dir, $filename);
769     }
771     /**
772      * Internal step definition to find exceptions, debugging() messages and PHP debug messages.
773      *
774      * Part of behat_hooks class as is part of the testing framework, is auto-executed
775      * after each step so no features will splicitly use it.
776      *
777      * @Given /^I look for exceptions$/
778      * @throw Exception Unknown type, depending on what we caught in the hook or basic \Exception.
779      * @see Moodle\BehatExtension\EventDispatcher\Tester\ChainedStepTester
780      */
781     public function i_look_for_exceptions() {
782         // If the scenario already failed in a hook throw the exception.
783         if (!is_null(self::$currentscenarioexception)) {
784             throw self::$currentscenarioexception;
785         }
787         // If the step already failed in a hook throw the exception.
788         if (!is_null(self::$currentstepexception)) {
789             throw self::$currentstepexception;
790         }
792         $this->look_for_exceptions();
793     }
795     /**
796      * Returns whether the first scenario of the suite is running
797      *
798      * @return bool
799      */
800     protected static function is_first_scenario() {
801         return !(self::$initprocessesfinished);
802     }
804     /**
805      * Returns whether the first scenario of the suite is running
806      *
807      * @return bool
808      */
809     protected static function is_first_javascript_scenario(): bool {
810         return !self::$firstjavascriptscenarioseen;
811     }
813     /**
814      * Register a set of component selectors.
815      *
816      * @param string $component
817      */
818     public function register_component_selectors_for_component(string $component): void {
819         $context = behat_context_helper::get_component_context($component);
821         if ($context === null) {
822             return;
823         }
825         $namedpartial = $this->getSession()->getSelectorsHandler()->getSelector('named_partial');
826         $namedexact = $this->getSession()->getSelectorsHandler()->getSelector('named_exact');
828         // Replacements must come before selectors as they are used in the selectors.
829         foreach ($context->get_named_replacements() as $replacement) {
830             $namedpartial->register_replacement($component, $replacement);
831             $namedexact->register_replacement($component, $replacement);
832         }
834         foreach ($context->get_partial_named_selectors() as $selector) {
835             $namedpartial->register_component_selector($component, $selector);
836         }
838         foreach ($context->get_exact_named_selectors() as $selector) {
839             $namedexact->register_component_selector($component, $selector);
840         }
842     }
844     /**
845      * Mark the first step as having been completed.
846      *
847      * This must be the last BeforeStep hook in the setup.
848      *
849      * @param BeforeStepScope $scope
850      * @BeforeStep
851      */
852     public function first_step_setup_complete(BeforeStepScope $scope): void {
853         self::$initprocessesfinished = true;
854     }
856     /**
857      * Log a notification, and then exit.
858      *
859      * @param   string $message The content to dispaly
860      */
861     protected static function log_and_stop(string $message): void {
862         error_log($message);
864         exit(1);
865     }