MDL-69232 behat: Make selenium start more fault tolerant
[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
bee40dbf
AN
71 /**
72 * @var bool Scenario running
73 */
74 protected $scenariorunning = false;
75
f49cede7
DM
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;
85
5c0dfe32 86 /**
a964ead0 87 * If we are saving any kind of dump on failure we should use the same parent dir during a run.
5c0dfe32
DM
88 *
89 * @var The parent dir name
90 */
a964ead0 91 protected static $faildumpdirname = false;
5c0dfe32 92
3c71c15c
RT
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();
99
5aa9b5ce
RT
100 /**
101 * Keeps track of current running suite name.
102 *
103 * @var string current running suite name
104 */
b4bc4286 105 protected static $runningsuite = '';
5aa9b5ce 106
66901a69 107 /**
108 * @var array Array (with tag names in keys) of all tags in current scenario.
109 */
110 protected static $scenariotags;
111
25fbce21
RT
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) {
b468dbf5
RT
120 // If behat has been initialised then no need to do this again.
121 if (self::$initprocessesfinished) {
122 return;
123 }
124
25fbce21
RT
125 try {
126 self::before_suite($scope);
127 } catch (behat_stop_exception $e) {
128 echo $e->getMessage() . PHP_EOL;
129 exit(1);
130 }
131 }
132
f5ceb6c2
DM
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 *
25fbce21 139 * @param BeforeSuiteScope $scope scope passed by event fired before suite.
f5ceb6c2 140 * @static
25fbce21 141 * @throws behat_stop_exception
f5ceb6c2 142 */
42ad096f 143 public static function before_suite(BeforeSuiteScope $scope) {
f5ceb6c2
DM
144 global $CFG;
145
cfcbc34a
DM
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.
5aa9b5ce
RT
149 if (!defined('BEHAT_TEST')) {
150 define('BEHAT_TEST', 1);
151 }
152
153 if (!defined('CLI_SCRIPT')) {
154 define('CLI_SCRIPT', 1);
155 }
cfcbc34a 156
cfcbc34a 157 // With BEHAT_TEST we will be using $CFG->behat_* instead of $CFG->dataroot, $CFG->prefix and $CFG->wwwroot.
f5ceb6c2
DM
158 require_once(__DIR__ . '/../../../config.php');
159
160 // Now that we are MOODLE_INTERNAL.
161 require_once(__DIR__ . '/../../behat/classes/behat_command.php');
30374580 162 require_once(__DIR__ . '/../../behat/classes/behat_selectors.php');
af4830a2 163 require_once(__DIR__ . '/../../behat/classes/behat_context_helper.php');
f5ceb6c2
DM
164 require_once(__DIR__ . '/../../behat/classes/util.php');
165 require_once(__DIR__ . '/../../testing/classes/test_lock.php');
b08b0a28 166 require_once(__DIR__ . '/../../testing/classes/nasty_strings.php');
f5ceb6c2
DM
167
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.
171
172 if (!behat_util::is_test_mode_enabled()) {
25fbce21 173 throw new behat_stop_exception('Behat only can run if test mode is enabled. More info in ' .
d1ac356a 174 behat_command::DOCS_URL);
f5ceb6c2
DM
175 }
176
d9c55725 177 // Reset all data, before checking for check_server_status.
ab25d8a7 178 // If not done, then it can return apache error, while running tests.
39eb8abe 179 behat_util::clean_tables_updated_by_scenario_list();
ab25d8a7
RT
180 behat_util::reset_all_data();
181
e891c838
RT
182 // Check if server is running and using same version for cli and apache.
183 behat_util::check_server_status();
f5ceb6c2 184
b831d479
DM
185 // Prevents using outdated data, upgrade script would start and tests would fail.
186 if (!behat_util::is_test_data_updated()) {
b32ca4ca 187 $commandpath = 'php admin/tool/behat/cli/init.php';
25fbce21 188 throw new behat_stop_exception("Your behat test site is outdated, please run\n\n " .
d69a6ad9 189 $commandpath . "\n\nfrom your moodle dirroot to drop and install the behat test site again.");
b831d479 190 }
f5ceb6c2
DM
191 // Avoid parallel tests execution, it continues when the previous lock is released.
192 test_lock::acquire('behat');
41eb672b 193
a964ead0 194 if (!empty($CFG->behat_faildump_path) && !is_writable($CFG->behat_faildump_path)) {
25fbce21 195 throw new behat_stop_exception('You set $CFG->behat_faildump_path to a non-writable directory');
5c0dfe32 196 }
e5f2478b
RT
197
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 }
f5ceb6c2
DM
205 }
206
c3b7f2f9
AN
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();
219
220 if (!$happy) {
221 error_log('Behat only can modify the test database and the test dataroot!');
222 exit(1);
223 }
224 }
225
3c71c15c
RT
226 /**
227 * Gives access to moodle codebase, to keep track of feature start time.
228 *
25fbce21 229 * @param BeforeFeatureScope $scope scope passed by event fired before feature.
3c71c15c
RT
230 * @BeforeFeature
231 */
25fbce21 232 public static function before_feature(BeforeFeatureScope $scope) {
027212b0 233 if (!defined('BEHAT_FEATURE_TIMING_FILE')) {
3c71c15c
RT
234 return;
235 }
25fbce21 236 $file = $scope->getFeature()->getFile();
08e7f97e
TL
237 self::$timings[$file] = microtime(true);
238 }
239
3c71c15c
RT
240 /**
241 * Gives access to moodle codebase, to keep track of feature end time.
242 *
25fbce21 243 * @param AfterFeatureScope $scope scope passed by event fired after feature.
3c71c15c
RT
244 * @AfterFeature
245 */
25fbce21 246 public static function after_feature(AfterFeatureScope $scope) {
027212b0 247 if (!defined('BEHAT_FEATURE_TIMING_FILE')) {
3c71c15c
RT
248 return;
249 }
25fbce21 250 $file = $scope->getFeature()->getFile();
08e7f97e
TL
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 }
257
3c71c15c
RT
258 /**
259 * Gives access to moodle codebase, to keep track of suite timings.
260 *
25fbce21 261 * @param AfterSuiteScope $scope scope passed by event fired after suite.
3c71c15c
RT
262 * @AfterSuite
263 */
25fbce21 264 public static function after_suite(AfterSuiteScope $scope) {
027212b0 265 if (!defined('BEHAT_FEATURE_TIMING_FILE')) {
08e7f97e
TL
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 }
027212b0 274 if ($existing = @json_decode(file_get_contents(BEHAT_FEATURE_TIMING_FILE), true)) {
08e7f97e
TL
275 self::$timings = array_merge($existing, self::$timings);
276 }
277 arsort(self::$timings);
027212b0 278 @file_put_contents(BEHAT_FEATURE_TIMING_FILE, json_encode(self::$timings, JSON_PRETTY_PRINT));
08e7f97e
TL
279 }
280
f5ceb6c2 281 /**
25fbce21 282 * Hook to capture before scenario event to get scope.
f5ceb6c2 283 *
25fbce21 284 * @param BeforeScenarioScope $scope scope passed by event fired before scenario.
f5ceb6c2
DM
285 * @BeforeScenario
286 */
25fbce21
RT
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 }
295
8eb7f7e3
AN
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 }
310
25fbce21
RT
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 */
42ad096f 317 public function before_scenario(BeforeScenarioScope $scope) {
5aa9b5ce 318 global $DB, $CFG;
f5ceb6c2 319
8eb7f7e3
AN
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;
325
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 );
98f56e6f 348 }
f59f6b6b
DM
349 }
350
b4bc4286
RT
351 $suitename = $scope->getSuite()->getName();
352
353 // Register behat selectors for theme, if suite is changed. We do it for every suite change.
354 if ($suitename !== self::$runningsuite) {
cd5382b2 355 self::$runningsuite = $suitename;
b4bc4286
RT
356 behat_context_helper::set_environment($scope->getEnvironment());
357
358 // We need the Mink session to do it and we do it only before the first scenario.
520071f0
AN
359 $namedpartialclass = 'behat_partial_named_selector';
360 $namedexactclass = 'behat_exact_named_selector';
361
4422ffc5
RT
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;
b4bc4286 366 }
4422ffc5
RT
367
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 }
373
520071f0
AN
374 $this->getSession()->getSelectorsHandler()->registerSelector('named_partial', new $namedpartialclass());
375 $this->getSession()->getSelectorsHandler()->registerSelector('named_exact', new $namedexactclass());
e99a3cca
AN
376
377 // Register component named selectors.
378 foreach (\core_component::get_component_names() as $component) {
379 $this->register_component_selectors_for_component($component);
380 }
381
17344d4c
DM
382 }
383
de230fd3 384 // Reset $SESSION.
2e00d01d 385 \core\session\manager::init_empty_session();
c8619f33 386
8c35e49b
RT
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);
ef1d45b3 391 behat_util::reset_all_data();
8c35e49b 392 error_reporting($errorlevel);
b08b0a28 393
7af84414
AN
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);
399
400 // Restore the saved themes.
401 behat_util::restore_saved_themes();
402 }
403
de230fd3 404 // Assign valid data to admin user (some generator-related code needs a valid user).
f5ceb6c2 405 $user = $DB->get_record('user', array('username' => 'admin'));
d79d5ac2 406 \core\session\manager::set_user($user);
5f4b4e91 407
b4bc4286
RT
408 // Set the theme if not default.
409 if ($suitename !== "default") {
410 set_config('theme', $suitename);
b4bc4286
RT
411 }
412
bee40dbf
AN
413 // Reset the scenariorunning variable to ensure that Step 0 occurs.
414 $this->scenariorunning = false;
5aa9b5ce 415
66901a69 416 // Set up the tags for current scenario.
417 self::fetch_tags_for_scenario($scope);
a3892e0e 418
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');
d60b6af9
PFO
422
423 return;
a3892e0e 424 }
d60b6af9
PFO
425
426 // Run all test with medium (1024x768) screen size, to avoid responsive problems.
427 $this->resize_window('medium');
66901a69 428 }
429
bee40dbf
AN
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 *
61832fae 435 * @param BeforeStepScope $scope
bee40dbf
AN
436 * @BeforeStep
437 */
438 public function before_step(BeforeStepScope $scope) {
439 global $CFG;
440
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('/'));
452
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.';
458
459 $this->find(
460 "xpath", "//head/child::title[normalize-space(.)='" . behat_util::BEHATSITENAME . "']",
461 new ExpectationException($message, $session)
462 );
463
bee40dbf
AN
464 }
465 $this->scenariorunning = true;
466 }
467 }
468
66901a69 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 }
480
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;
f5ceb6c2
DM
488 }
489
f5ceb6c2 490 /**
d1e55a47 491 * Wait for JS to complete before beginning interacting with the DOM.
f5ceb6c2 492 *
f49cede7
DM
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.
f5ceb6c2 498 *
25fbce21 499 * @param BeforeStepScope $scope scope passed by event fired before step.
42ad096f 500 * @BeforeStep
f5ceb6c2 501 */
42ad096f
RT
502 public function before_step_javascript(BeforeStepScope $scope) {
503 self::$currentstepexception = null;
f49cede7 504
42ad096f
RT
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 }
f49cede7 512 }
d1e55a47
DM
513 }
514
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 *
f49cede7
DM
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.
d1e55a47 526 *
25fbce21 527 * @param AfterStepScope $scope scope passed by event fired after step..
42ad096f 528 * @AfterStep
d1e55a47 529 */
42ad096f
RT
530 public function after_step_javascript(AfterStepScope $scope) {
531 global $CFG, $DB;
532
ada21cb0
RT
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 }
537
0b07ea42 538 $isfailed = $scope->getTestResult()->getResultCode() === Behat\Testwork\Tester\Result\TestResult::FAILED;
42ad096f
RT
539
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 }
549
0b07ea42
AN
550 if ($isfailed && !empty($CFG->behat_faildump_path)) {
551 // Save the page content (html).
552 $this->take_contentdump($scope);
553
554 if ($this->running_javascript()) {
555 // Save a screenshot.
556 $this->take_screenshot($scope);
557 }
558 }
559
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 }
568
42ad096f
RT
569 // Only run if JS.
570 if (!$this->running_javascript()) {
571 return;
572 }
5c0dfe32 573
f49cede7
DM
574 try {
575 $this->wait_for_pending_js();
576 self::$currentstepexception = null;
577 } catch (UnexpectedAlertOpen $e) {
578 self::$currentstepexception = $e;
579
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 }
d1e55a47
DM
591 }
592
131d4ac2 593 /**
bb344d07 594 * Reset the session between each scenario.
131d4ac2 595 *
25fbce21 596 * @param AfterScenarioScope $scope scope passed by event fired after scenario.
bb344d07 597 * @AfterScenario
131d4ac2 598 */
bb344d07
AN
599 public function reset_webdriver_between_scenarios(AfterScenarioScope $scope) {
600 $this->getSession()->stop();
131d4ac2
T
601 }
602
a964ead0
AN
603 /**
604 * Getter for self::$faildumpdirname
5c0dfe32
DM
605 *
606 * @return string
607 */
a964ead0
AN
608 protected function get_run_faildump_dir() {
609 return self::$faildumpdirname;
5c0dfe32
DM
610 }
611
612 /**
613 * Take screenshot when a step fails.
3ec07614 614 *
5c0dfe32 615 * @throws Exception
25fbce21 616 * @param AfterStepScope $scope scope passed by event after step.
3ec07614 617 */
42ad096f 618 protected function take_screenshot(AfterStepScope $scope) {
5c0dfe32
DM
619 // Goutte can't save screenshots.
620 if (!$this->running_javascript()) {
621 return false;
3ec07614 622 }
5c0dfe32 623
be8b8950
MJ
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 }
a964ead0
AN
637 }
638
639 /**
640 * Take a dump of the page content when a step fails.
641 *
642 * @throws Exception
25fbce21 643 * @param AfterStepScope $scope scope passed by event after step.
a964ead0 644 */
42ad096f
RT
645 protected function take_contentdump(AfterStepScope $scope) {
646 list ($dir, $filename) = $this->get_faildump_filename($scope, 'html');
a964ead0 647
be8b8950
MJ
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);
a964ead0
AN
656 }
657
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 *
25fbce21 663 * @param AfterStepScope $scope scope passed by event after step.
167486b3 664 * @param String $filetype The file suffix to use. Limited to 4 chars.
a964ead0 665 */
42ad096f 666 protected function get_faildump_filename(AfterStepScope $scope, $filetype) {
a964ead0
AN
667 global $CFG;
668
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');
5c0dfe32 672
a964ead0 673 $dir = $CFG->behat_faildump_path . DIRECTORY_SEPARATOR . $faildumpdir;
5c0dfe32 674
a964ead0 675 if (!is_dir($dir) && !mkdir($dir, $CFG->directorypermissions, true)) {
5c0dfe32 676 // It shouldn't, we already checked that the directory is writable.
a964ead0 677 throw new Exception('No directories can be created inside $CFG->behat_faildump_path, check the directory permissions.');
5c0dfe32
DM
678 }
679 } else {
680 // We will always need to know the full path.
a964ead0 681 $dir = $CFG->behat_faildump_path . DIRECTORY_SEPARATOR . $faildumpdir;
5c0dfe32
DM
682 }
683
684 // The scenario title + the failed step text.
a964ead0 685 // We want a i-am-the-scenario-title_i-am-the-failed-step.$filetype format.
42ad096f 686 $filename = $scope->getFeature()->getTitle() . '_' . $scope->getStep()->getText();
167486b3 687
484503da 688 // As file name is limited to 255 characters. Leaving 5 chars for line number and 4 chars for the file.
167486b3 689 // extension as we allow .png for images and .html for DOM contents.
484503da
RT
690 $filenamelen = 245;
691
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 }
701
702 $filename = preg_replace('/([^a-zA-Z0-9\_]+)/', '-', $filename);
703 $filename = substr($filename, 0, $filenamelen) . $suitename . '_' . $scope->getStep()->getLine() . '.' . $filetype;
5c0dfe32 704
a964ead0 705 return array($dir, $filename);
3ec07614
DW
706 }
707
5f4b4e91
DM
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$/
f49cede7 715 * @throw Exception Unknown type, depending on what we caught in the hook or basic \Exception.
1b372a77 716 * @see Moodle\BehatExtension\EventDispatcher\Tester\ChainedStepTester
5f4b4e91
DM
717 */
718 public function i_look_for_exceptions() {
f49cede7
DM
719 // If the step already failed in a hook throw the exception.
720 if (!is_null(self::$currentstepexception)) {
721 throw self::$currentstepexception;
722 }
723
eb9ca848 724 $this->look_for_exceptions();
5f4b4e91
DM
725 }
726
95b43d6b
DM
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 }
61832fae 735
61832fae
AN
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 {
afe13dfe
AN
742 $context = behat_context_helper::get_component_context($component);
743
744 if ($context === null) {
745 return;
61832fae
AN
746 }
747
61832fae
AN
748 $namedpartial = $this->getSession()->getSelectorsHandler()->getSelector('named_partial');
749 $namedexact = $this->getSession()->getSelectorsHandler()->getSelector('named_exact');
750
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 }
756
757 foreach ($context->get_partial_named_selectors() as $selector) {
758 $namedpartial->register_component_selector($component, $selector);
759 }
760
761 foreach ($context->get_exact_named_selectors() as $selector) {
762 $namedexact->register_component_selector($component, $selector);
763 }
764
765 }
766
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 }
778
f5ceb6c2 779}
a964ead0 780
25fbce21
RT
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 */
e584e6ae 790class behat_stop_exception extends \Exception {
bd1edc83 791}