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