mirrored from git://git.moodle.org/moodle.git
/
behat_hooks.php
569 lines (489 loc) · 22.4 KB
/
behat_hooks.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Behat hooks steps definitions.
*
* This methods are used by Behat CLI command.
*
* @package core
* @category test
* @copyright 2012 David Monllaó
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
// NOTE: no MOODLE_INTERNAL test here, this file may be required by behat before including /config.php.
require_once(__DIR__ . '/../../behat/behat_base.php');
use Behat\Behat\Event\SuiteEvent as SuiteEvent,
Behat\Behat\Event\ScenarioEvent as ScenarioEvent,
Behat\Behat\Event\StepEvent as StepEvent,
Behat\Mink\Exception\DriverException as DriverException,
WebDriver\Exception\NoSuchWindow as NoSuchWindow,
WebDriver\Exception\UnexpectedAlertOpen as UnexpectedAlertOpen,
WebDriver\Exception\UnknownError as UnknownError,
WebDriver\Exception\CurlExec as CurlExec,
WebDriver\Exception\NoAlertOpenError as NoAlertOpenError;
/**
* Hooks to the behat process.
*
* Behat accepts hooks after and before each
* suite, feature, scenario and step.
*
* They can not call other steps as part of their process
* like regular steps definitions does.
*
* Throws generic Exception because they are captured by Behat.
*
* @package core
* @category test
* @copyright 2012 David Monllaó
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class behat_hooks extends behat_base {
/**
* @var Last browser session start time.
*/
protected static $lastbrowsersessionstart = 0;
/**
* @var For actions that should only run once.
*/
protected static $initprocessesfinished = false;
/**
* Some exceptions can only be caught in a before or after step hook,
* they can not be thrown there as they will provoke a framework level
* failure, but we can store them here to fail the step in i_look_for_exceptions()
* which result will be parsed by the framework as the last step result.
*
* @var Null or the exception last step throw in the before or after hook.
*/
protected static $currentstepexception = null;
/**
* If we are saving any kind of dump on failure we should use the same parent dir during a run.
*
* @var The parent dir name
*/
protected static $faildumpdirname = false;
/**
* Gives access to moodle codebase, ensures all is ready and sets up the test lock.
*
* Includes config.php to use moodle codebase with $CFG->behat_*
* instead of $CFG->prefix and $CFG->dataroot, called once per suite.
*
* @static
* @throws Exception
* @BeforeSuite
*/
public static function before_suite($event) {
global $CFG;
// Defined only when the behat CLI command is running, the moodle init setup process will
// read this value and switch to $CFG->behat_dataroot and $CFG->behat_prefix instead of
// the normal site.
define('BEHAT_TEST', 1);
define('CLI_SCRIPT', 1);
// With BEHAT_TEST we will be using $CFG->behat_* instead of $CFG->dataroot, $CFG->prefix and $CFG->wwwroot.
require_once(__DIR__ . '/../../../config.php');
// Now that we are MOODLE_INTERNAL.
require_once(__DIR__ . '/../../behat/classes/behat_command.php');
require_once(__DIR__ . '/../../behat/classes/behat_selectors.php');
require_once(__DIR__ . '/../../behat/classes/util.php');
require_once(__DIR__ . '/../../testing/classes/test_lock.php');
require_once(__DIR__ . '/../../testing/classes/nasty_strings.php');
// Avoids vendor/bin/behat to be executed directly without test environment enabled
// to prevent undesired db & dataroot modifications, this is also checked
// before each scenario (accidental user deletes) in the BeforeScenario hook.
if (!behat_util::is_test_mode_enabled()) {
throw new Exception('Behat only can run if test mode is enabled. More info in ' . behat_command::DOCS_URL . '#Running_tests');
}
if (!behat_util::is_server_running()) {
throw new Exception($CFG->behat_wwwroot .
' is not available, ensure you specified correct url and that the server is set up and started.' .
' More info in ' . behat_command::DOCS_URL . '#Running_tests');
}
// Prevents using outdated data, upgrade script would start and tests would fail.
if (!behat_util::is_test_data_updated()) {
$commandpath = 'php admin/tool/behat/cli/init.php';
throw new Exception('Your behat test site is outdated, please run ' . $commandpath . ' from your moodle dirroot to drop and install the behat test site again.');
}
// Avoid parallel tests execution, it continues when the previous lock is released.
test_lock::acquire('behat');
// Store the browser reset time if reset after N seconds is specified in config.php.
if (!empty($CFG->behat_restart_browser_after)) {
// Store the initial browser session opening.
self::$lastbrowsersessionstart = time();
}
if (!empty($CFG->behat_faildump_path) && !is_writable($CFG->behat_faildump_path)) {
throw new Exception('You set $CFG->behat_faildump_path to a non-writable directory');
}
}
/**
* Resets the test environment.
*
* @throws coding_exception If here we are not using the test database it should be because of a coding error
* @BeforeScenario
*/
public function before_scenario($event) {
global $DB, $SESSION, $CFG;
// As many checks as we can.
if (!defined('BEHAT_TEST') ||
!defined('BEHAT_SITE_RUNNING') ||
php_sapi_name() != 'cli' ||
!behat_util::is_test_mode_enabled() ||
!behat_util::is_test_site()) {
throw new coding_exception('Behat only can modify the test database and the test dataroot!');
}
$moreinfo = 'More info in ' . behat_command::DOCS_URL . '#Running_tests';
$driverexceptionmsg = 'Selenium server is not running, you need to start it to run tests that involve Javascript. ' . $moreinfo;
try {
$session = $this->getSession();
} catch (CurlExec $e) {
// Exception thrown by WebDriver, so only @javascript tests will be caugth; in
// behat_util::is_server_running() we already checked that the server is running.
throw new Exception($driverexceptionmsg);
} catch (DriverException $e) {
throw new Exception($driverexceptionmsg);
} catch (UnknownError $e) {
// Generic 'I have no idea' Selenium error. Custom exception to provide more feedback about possible solutions.
$this->throw_unknown_exception($e);
}
// We need the Mink session to do it and we do it only before the first scenario.
if (self::is_first_scenario()) {
behat_selectors::register_moodle_selectors($session);
}
// Reset $SESSION.
$_SESSION = array();
$SESSION = new stdClass();
$_SESSION['SESSION'] =& $SESSION;
behat_util::reset_database();
behat_util::reset_dataroot();
purge_all_caches();
accesslib_clear_all_caches(true);
// Reset the nasty strings list used during the last test.
nasty_strings::reset_used_strings();
// Assign valid data to admin user (some generator-related code needs a valid user).
$user = $DB->get_record('user', array('username' => 'admin'));
\core\session\manager::set_user($user);
// Reset the browser if specified in config.php.
if (!empty($CFG->behat_restart_browser_after) && $this->running_javascript()) {
$now = time();
if (self::$lastbrowsersessionstart + $CFG->behat_restart_browser_after < $now) {
$session->restart();
self::$lastbrowsersessionstart = $now;
}
}
// Start always in the the homepage.
try {
// Let's be conservative as we never know when new upstream issues will affect us.
$session->visit($this->locate_path('/'));
} catch (UnknownError $e) {
$this->throw_unknown_exception($e);
}
// Checking that the root path is a Moodle test site.
if (self::is_first_scenario()) {
$notestsiteexception = new Exception('The base URL (' . $CFG->wwwroot . ') is not a behat test site, ' .
'ensure you started the built-in web server in the correct directory or your web server is correctly started and set up');
$this->find("xpath", "//head/child::title[normalize-space(.)='" . behat_util::BEHATSITENAME . "']", $notestsiteexception);
self::$initprocessesfinished = true;
}
// Run all test with medium (1024x768) screen size, to avoid responsive problems.
$this->resize_window('medium');
}
/**
* Wait for JS to complete before beginning interacting with the DOM.
*
* Executed only when running against a real browser. We wrap it
* all in a try & catch to forward the exception to i_look_for_exceptions
* so the exception will be at scenario level, which causes a failure, by
* default would be at framework level, which will stop the execution of
* the run.
*
* @BeforeStep @javascript
*/
public function before_step_javascript($event) {
try {
$this->wait_for_pending_js();
self::$currentstepexception = null;
} catch (Exception $e) {
self::$currentstepexception = $e;
}
}
/**
* Wait for JS to complete after finishing the step.
*
* With this we ensure that there are not AJAX calls
* still in progress.
*
* Executed only when running against a real browser. We wrap it
* all in a try & catch to forward the exception to i_look_for_exceptions
* so the exception will be at scenario level, which causes a failure, by
* default would be at framework level, which will stop the execution of
* the run.
*
* @AfterStep @javascript
*/
public function after_step_javascript($event) {
global $CFG;
// Save a screenshot if the step failed.
if (!empty($CFG->behat_faildump_path) &&
$event->getResult() === StepEvent::FAILED) {
$this->take_screenshot($event);
}
try {
$this->wait_for_pending_js();
self::$currentstepexception = null;
} catch (UnexpectedAlertOpen $e) {
self::$currentstepexception = $e;
// Accepting the alert so the framework can continue properly running
// the following scenarios. Some browsers already closes the alert, so
// wrapping in a try & catch.
try {
$this->getSession()->getDriver()->getWebDriverSession()->accept_alert();
} catch (Exception $e) {
// Catching the generic one as we never know how drivers reacts here.
}
} catch (Exception $e) {
self::$currentstepexception = $e;
}
}
/**
* Execute any steps required after the step has finished.
*
* This includes creating an HTML dump of the content if there was a failure.
*
* @AfterStep
*/
public function after_step($event) {
global $CFG;
// Save the page content if the step failed.
if (!empty($CFG->behat_faildump_path) &&
$event->getResult() === StepEvent::FAILED) {
$this->take_contentdump($event);
}
}
/**
* Getter for self::$faildumpdirname
*
* @return string
*/
protected function get_run_faildump_dir() {
return self::$faildumpdirname;
}
/**
* Take screenshot when a step fails.
*
* @throws Exception
* @param StepEvent $event
*/
protected function take_screenshot(StepEvent $event) {
// Goutte can't save screenshots.
if (!$this->running_javascript()) {
return false;
}
list ($dir, $filename) = $this->get_faildump_filename($event, 'png');
$this->saveScreenshot($filename, $dir);
}
/**
* Take a dump of the page content when a step fails.
*
* @throws Exception
* @param StepEvent $event
*/
protected function take_contentdump(StepEvent $event) {
list ($dir, $filename) = $this->get_faildump_filename($event, 'html');
$fh = fopen($dir . DIRECTORY_SEPARATOR . $filename, 'w');
fwrite($fh, $this->getSession()->getPage()->getContent());
fclose($fh);
}
/**
* Determine the full pathname to store a failure-related dump.
*
* This is used for content such as the DOM, and screenshots.
*
* @param StepEvent $event
* @param String $filetype The file suffix to use. Limited to 4 chars.
*/
protected function get_faildump_filename(StepEvent $event, $filetype) {
global $CFG;
// All the contentdumps should be in the same parent dir.
if (!$faildumpdir = self::get_run_faildump_dir()) {
$faildumpdir = self::$faildumpdirname = date('Ymd_His');
$dir = $CFG->behat_faildump_path . DIRECTORY_SEPARATOR . $faildumpdir;
if (!is_dir($dir) && !mkdir($dir, $CFG->directorypermissions, true)) {
// It shouldn't, we already checked that the directory is writable.
throw new Exception('No directories can be created inside $CFG->behat_faildump_path, check the directory permissions.');
}
} else {
// We will always need to know the full path.
$dir = $CFG->behat_faildump_path . DIRECTORY_SEPARATOR . $faildumpdir;
}
// The scenario title + the failed step text.
// We want a i-am-the-scenario-title_i-am-the-failed-step.$filetype format.
$filename = $event->getStep()->getParent()->getTitle() . '_' . $event->getStep()->getText();
$filename = preg_replace('/([^a-zA-Z0-9\_]+)/', '-', $filename);
// File name limited to 255 characters. Leaving 4 chars for the file
// extension as we allow .png for images and .html for DOM contents.
$filename = substr($filename, 0, 250) . '.' . $filetype;
return array($dir, $filename);
}
/**
* Waits for all the JS to be loaded.
*
* @throws \Exception
* @throws NoSuchWindow
* @throws UnknownError
* @return bool True or false depending whether all the JS is loaded or not.
*/
protected function wait_for_pending_js() {
// We don't use behat_base::spin() here as we don't want to end up with an exception
// if the page & JSs don't finish loading properly.
for ($i = 0; $i < self::EXTENDED_TIMEOUT * 10; $i++) {
$pending = '';
try {
$jscode = 'return ' . self::PAGE_READY_JS . ' ? "" : M.util.pending_js.join(":");';
$pending = $this->getSession()->evaluateScript($jscode);
} catch (NoSuchWindow $nsw) {
// We catch an exception here, in case we just closed the window we were interacting with.
// No javascript is running if there is no window right?
$pending = '';
} catch (UnknownError $e) {
// M is not defined when the window or the frame don't exist anymore.
if (strstr($e->getMessage(), 'M is not defined') != false) {
$pending = '';
}
}
// If there are no pending JS we stop waiting.
if ($pending === '') {
return true;
}
// 0.1 seconds.
usleep(100000);
}
// Timeout waiting for JS to complete. It will be catched and forwarded to behat_hooks::i_look_for_exceptions().
// It is unlikely that Javascript code of a page or an AJAX request needs more than self::EXTENDED_TIMEOUT seconds
// to be loaded, although when pages contains Javascript errors M.util.js_complete() can not be executed, so the
// number of JS pending code and JS completed code will not match and we will reach this point.
throw new \Exception('Javascript code and/or AJAX requests are not ready after ' . self::EXTENDED_TIMEOUT .
' seconds. There is a Javascript error or the code is extremely slow.');
}
/**
* Internal step definition to find exceptions, debugging() messages and PHP debug messages.
*
* Part of behat_hooks class as is part of the testing framework, is auto-executed
* after each step so no features will splicitly use it.
*
* @Given /^I look for exceptions$/
* @throw Exception Unknown type, depending on what we caught in the hook or basic \Exception.
* @see Moodle\BehatExtension\Tester\MoodleStepTester
*/
public function i_look_for_exceptions() {
// If the step already failed in a hook throw the exception.
if (!is_null(self::$currentstepexception)) {
throw self::$currentstepexception;
}
// Wrap in try in case we were interacting with a closed window.
try {
// Exceptions.
$exceptionsxpath = "//div[@data-rel='fatalerror']";
// Debugging messages.
$debuggingxpath = "//div[@data-rel='debugging']";
// PHP debug messages.
$phperrorxpath = "//div[@data-rel='phpdebugmessage']";
// Any other backtrace.
$othersxpath = "(//*[contains(., ': call to ')])[1]";
$xpaths = array($exceptionsxpath, $debuggingxpath, $phperrorxpath, $othersxpath);
$joinedxpath = implode(' | ', $xpaths);
// Joined xpath expression. Most of the time there will be no exceptions, so this pre-check
// is faster than to send the 4 xpath queries for each step.
if (!$this->getSession()->getDriver()->find($joinedxpath)) {
return;
}
// Exceptions.
if ($errormsg = $this->getSession()->getPage()->find('xpath', $exceptionsxpath)) {
// Getting the debugging info and the backtrace.
$errorinfoboxes = $this->getSession()->getPage()->findAll('css', 'div.alert-error');
// If errorinfoboxes is empty, try find notifytiny (original) class.
if (empty($errorinfoboxes)) {
$errorinfoboxes = $this->getSession()->getPage()->findAll('css', 'div.notifytiny');
}
$errorinfo = $this->get_debug_text($errorinfoboxes[0]->getHtml()) . "\n" .
$this->get_debug_text($errorinfoboxes[1]->getHtml());
$msg = "Moodle exception: " . $errormsg->getText() . "\n" . $errorinfo;
throw new \Exception(html_entity_decode($msg));
}
// Debugging messages.
if ($debuggingmessages = $this->getSession()->getPage()->findAll('xpath', $debuggingxpath)) {
$msgs = array();
foreach ($debuggingmessages as $debuggingmessage) {
$msgs[] = $this->get_debug_text($debuggingmessage->getHtml());
}
$msg = "debugging() message/s found:\n" . implode("\n", $msgs);
throw new \Exception(html_entity_decode($msg));
}
// PHP debug messages.
if ($phpmessages = $this->getSession()->getPage()->findAll('xpath', $phperrorxpath)) {
$msgs = array();
foreach ($phpmessages as $phpmessage) {
$msgs[] = $this->get_debug_text($phpmessage->getHtml());
}
$msg = "PHP debug message/s found:\n" . implode("\n", $msgs);
throw new \Exception(html_entity_decode($msg));
}
// Any other backtrace.
// First looking through xpath as it is faster than get and parse the whole page contents,
// we get the contents and look for matches once we found something to suspect that there is a backtrace.
if ($this->getSession()->getDriver()->find($othersxpath)) {
$backtracespattern = '/(line [0-9]* of [^:]*: call to [\->&;:a-zA-Z_\x7f-\xff][\->&;:a-zA-Z0-9_\x7f-\xff]*)/';
if (preg_match_all($backtracespattern, $this->getSession()->getPage()->getContent(), $backtraces)) {
$msgs = array();
foreach ($backtraces[0] as $backtrace) {
$msgs[] = $backtrace . '()';
}
$msg = "Other backtraces found:\n" . implode("\n", $msgs);
throw new \Exception(htmlentities($msg));
}
}
} catch (NoSuchWindow $e) {
// If we were interacting with a popup window it will not exists after closing it.
}
}
/**
* Converts HTML tags to line breaks to display the info in CLI
*
* @param string $html
* @return string
*/
protected function get_debug_text($html) {
// Replacing HTML tags for new lines and keeping only the text.
$notags = preg_replace('/<+\s*\/*\s*([A-Z][A-Z0-9]*)\b[^>]*\/*\s*>*/i', "\n", $html);
return preg_replace("/(\n)+/s", "\n", $notags);
}
/**
* Returns whether the first scenario of the suite is running
*
* @return bool
*/
protected static function is_first_scenario() {
return !(self::$initprocessesfinished);
}
/**
* Throws an exception after appending an extra info text.
*
* @throws Exception
* @param UnknownError $exception
* @return void
*/
protected function throw_unknown_exception(UnknownError $exception) {
$text = get_string('unknownexceptioninfo', 'tool_behat');
throw new Exception($text . PHP_EOL . $exception->getMessage());
}
}