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