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