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