behat_base.php 40.3 KB
Newer Older
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
<?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/>.

/**
 * Base class of all steps definitions.
 *
 * This script is only called from Behat as part of it's integration
 * in Moodle.
 *
 * @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.

31
32
33
34
35
use Behat\Mink\Exception\DriverException;
use Behat\Mink\Exception\ExpectationException;
use Behat\Mink\Exception\ElementNotFoundException;
use Behat\Mink\Element\NodeElement;
use Behat\Mink\Session;
36

37
38
39
40
41
42
43
44
/**
 * Steps definitions base class.
 *
 * To extend by the steps definitions of the different Moodle components.
 *
 * It can not contain steps definitions to avoid duplicates, only utility
 * methods shared between steps.
 *
45
46
47
48
49
 * @method NodeElement find_field(string $locator) Finds a form element
 * @method NodeElement find_button(string $locator) Finds a form input submit element or a button
 * @method NodeElement find_link(string $locator) Finds a link on a page
 * @method NodeElement find_file(string $locator) Finds a forum input file element
 *
50
51
52
53
54
55
56
 * @package   core
 * @category  test
 * @copyright 2012 David Monllaó
 * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */
class behat_base extends Behat\MinkExtension\Context\RawMinkContext {

57
58
59
60
61
62
63
64
65
    /**
     * Small timeout.
     *
     * A reduced timeout for cases where self::TIMEOUT is too much
     * and a simple $this->getSession()->getPage()->find() could not
     * be enough.
     */
    const REDUCED_TIMEOUT = 2;

66
67
68
    /**
     * The timeout for each Behat step (load page, wait for an element to load...).
     */
69
    const TIMEOUT = 6;
70
71
72
73
74
75
76
77
78

    /**
     * And extended timeout for specific cases.
     */
    const EXTENDED_TIMEOUT = 10;

    /**
     * The JS code to check that the page is ready.
     */
79
    const PAGE_READY_JS = '(typeof M !== "undefined" && M.util && M.util.pending_js && !Boolean(M.util.pending_js.length)) && (document.readyState === "complete")';
80

81
82
83
84
85
86
87
88
    /**
     * Locates url, based on provided path.
     * Override to provide custom routing mechanism.
     *
     * @see Behat\MinkExtension\Context\MinkContext
     * @param string $path
     * @return string
     */
89
    protected function locate_path($path) {
90
91
        $starturl = rtrim($this->getMinkParameter('base_url'), '/') . '/';
        return 0 !== strpos($path, 'http') ? $starturl . ltrim($path, '/') : $path;
92
93
    }

94
    /**
95
     * Returns the first matching element.
96
97
98
99
     *
     * @link http://mink.behat.org/#traverse-the-page-selectors
     * @param string $selector The selector type (css, xpath, named...)
     * @param mixed $locator It depends on the $selector, can be the xpath, a name, a css locator...
100
     * @param Exception $exception Otherwise we throw exception with generic info
101
     * @param NodeElement $node Spins around certain DOM node instead of the whole page
102
     * @param int $timeout Forces a specific time out (in seconds).
103
104
     * @return NodeElement
     */
105
    protected function find($selector, $locator, $exception = false, $node = false, $timeout = false) {
106

107
108
109
110
111
112
113
        // Throw exception, so dev knows it is not supported.
        if ($selector === 'named') {
            $exception = 'Using the "named" selector is deprecated as of 3.1. '
                .' Use the "named_partial" or use the "named_exact" selector instead.';
            throw new ExpectationException($exception, $this->getSession());
        }

114
        // Returns the first match.
115
        $items = $this->find_all($selector, $locator, $exception, $node, $timeout);
116
117
118
119
120
121
122
123
124
125
126
127
128
        return count($items) ? reset($items) : null;
    }

    /**
     * Returns all matching elements.
     *
     * Adapter to Behat\Mink\Element\Element::findAll() using the spin() method.
     *
     * @link http://mink.behat.org/#traverse-the-page-selectors
     * @param string $selector The selector type (css, xpath, named...)
     * @param mixed $locator It depends on the $selector, can be the xpath, a name, a css locator...
     * @param Exception $exception Otherwise we throw expcetion with generic info
     * @param NodeElement $node Spins around certain DOM node instead of the whole page
129
     * @param int $timeout Forces a specific time out (in seconds). If 0 is provided the default timeout will be applied.
130
131
     * @return array NodeElements list
     */
132
    protected function find_all($selector, $locator, $exception = false, $node = false, $timeout = false) {
133

134
135
136
137
138
139
140
        // Throw exception, so dev knows it is not supported.
        if ($selector === 'named') {
            $exception = 'Using the "named" selector is deprecated as of 3.1. '
                .' Use the "named_partial" or use the "named_exact" selector instead.';
            throw new ExpectationException($exception, $this->getSession());
        }

141
142
143
144
        // Generic info.
        if (!$exception) {

            // With named selectors we can be more specific.
145
            if (($selector == 'named_exact') || ($selector == 'named_partial')) {
146
147
                $exceptiontype = $locator[0];
                $exceptionlocator = $locator[1];
148
149

                // If we are in a @javascript session all contents would be displayed as HTML characters.
150
                if ($this->running_javascript()) {
151
152
153
                    $locator[1] = html_entity_decode($locator[1], ENT_NOQUOTES);
                }

154
155
156
157
158
159
160
161
            } else {
                $exceptiontype = $selector;
                $exceptionlocator = $locator;
            }

            $exception = new ElementNotFoundException($this->getSession(), $exceptiontype, null, $exceptionlocator);
        }

162
163
164
165
166
167
        $params = array('selector' => $selector, 'locator' => $locator);
        // Pushing $node if required.
        if ($node) {
            $params['node'] = $node;
        }

168
169
170
171
172
173
174
175
176
177
178
        // How much we will be waiting for the element to appear.
        if (!$timeout) {
            $timeout = self::TIMEOUT;
            $microsleep = false;
        } else {
            // Spinning each 0.1 seconds if the timeout was forced as we understand
            // that is a special case and is good to refine the performance as much
            // as possible.
            $microsleep = true;
        }

179
180
181
        // Waits for the node to appear if it exists, otherwise will timeout and throw the provided exception.
        return $this->spin(
            function($context, $args) {
182
183
184

                // If no DOM node provided look in all the page.
                if (empty($args['node'])) {
185
                    return $context->getSession()->getPage()->findAll($args['selector'], $args['locator']);
186
187
188
189
190
191
192
193
194
195
196
197
198
199
                }

                // For nodes contained in other nodes we can not use the basic named selectors
                // as they include unions and they would look for matches in the DOM root.
                $elementxpath = $context->getSession()->getSelectorsHandler()->selectorToXpath($args['selector'], $args['locator']);

                // Split the xpath in unions and prefix them with the container xpath.
                $unions = explode('|', $elementxpath);
                foreach ($unions as $key => $union) {
                    $union = trim($union);

                    // We are in the container node.
                    if (strpos($union, '.') === 0) {
                        $union = substr($union, 1);
200
201
202
                    } else if (strpos($union, '/') !== 0) {
                        // Adding the path separator in case it is not there.
                        $union = '/' . $union;
203
204
205
206
207
                    }
                    $unions[$key] = $args['node']->getXpath() . $union;
                }

                // We can not use usual Element::find() as it prefixes with DOM root.
208
                return $context->getSession()->getDriver()->find(implode('|', $unions));
209
            },
210
            $params,
211
212
213
            $timeout,
            $exception,
            $microsleep
214
        );
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
    }

    /**
     * Finds DOM nodes in the page using named selectors.
     *
     * The point of using this method instead of Mink ones is the spin
     * method of behat_base::find() that looks for the element until it
     * is available or it timeouts, this avoids the false failures received
     * when selenium tries to execute commands on elements that are not
     * ready to be used.
     *
     * All steps that requires elements to be available before interact with
     * them should use one of the find* methods.
     *
     * The methods calls requires a {'find_' . $elementtype}($locator)
     * format, like find_link($locator), find_select($locator),
     * find_button($locator)...
     *
     * @link http://mink.behat.org/#named-selectors
     * @throws coding_exception
235
     * @param string $name The name of the called method
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
     * @param mixed $arguments
     * @return NodeElement
     */
    public function __call($name, $arguments) {

        if (substr($name, 0, 5) !== 'find_') {
            throw new coding_exception('The "' . $name . '" method does not exist');
        }

        // Only the named selector identifier.
        $cleanname = substr($name, 5);

        // All named selectors shares the interface.
        if (count($arguments) !== 1) {
            throw new coding_exception('The "' . $cleanname . '" named selector needs the locator as it\'s single argument');
        }

        // Redirecting execution to the find method with the specified selector.
        // It will detect if it's pointing to an unexisting named selector.
255
        return $this->find('named_partial',
256
257
            array(
                $cleanname,
258
                behat_context_helper::escape($arguments[0])
259
260
261
262
            )
        );
    }

263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
    /**
     * Escapes the double quote character.
     *
     * Double quote is the argument delimiter, it can be escaped
     * with a backslash, but we auto-remove this backslashes
     * before the step execution, this method is useful when using
     * arguments as arguments for other steps.
     *
     * @param string $string
     * @return string
     */
    public function escape($string) {
        return str_replace('"', '\"', $string);
    }

278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
    /**
     * Executes the passed closure until returns true or time outs.
     *
     * In most cases the document.readyState === 'complete' will be enough, but sometimes JS
     * requires more time to be completely loaded or an element to be visible or whatever is required to
     * perform some action on an element; this method receives a closure which should contain the
     * required statements to ensure the step definition actions and assertions have all their needs
     * satisfied and executes it until they are satisfied or it timeouts. Redirects the return of the
     * closure to the caller.
     *
     * The closures requirements to work well with this spin method are:
     * - Must return false, null or '' if something goes wrong
     * - Must return something != false if finishes as expected, this will be the (mixed) value
     * returned by spin()
     *
293
     * The arguments of the closure are mixed, use $args depending on your needs.
294
     *
295
     * You can provide an exception to give more accurate feedback to tests writers, otherwise the
296
     * closure exception will be used, but you must provide an exception if the closure does not throw
297
298
     * an exception.
     *
299
300
301
302
303
304
     * @throws Exception If it timeouts without receiving something != false from the closure
     * @param Function|array|string $lambda The function to execute or an array passed to call_user_func (maps to a class method)
     * @param mixed $args Arguments to pass to the closure
     * @param int $timeout Timeout in seconds
     * @param Exception $exception The exception to throw in case it time outs.
     * @param bool $microsleep If set to true it'll sleep micro seconds rather than seconds.
305
306
     * @return mixed The value returned by the closure
     */
307
    protected function spin($lambda, $args = false, $timeout = false, $exception = false, $microsleep = false) {
308
309
310
311
312

        // Using default timeout which is pretty high.
        if (!$timeout) {
            $timeout = self::TIMEOUT;
        }
313
314
315
316
317
318
319
        if ($microsleep) {
            // Will sleep 1/10th of a second by default for self::TIMEOUT seconds.
            $loops = $timeout * 10;
        } else {
            // Will sleep for self::TIMEOUT seconds.
            $loops = $timeout;
        }
320

321
322
323
324
325
        // DOM will never change on non-javascript case; do not wait or try again.
        if (!$this->running_javascript()) {
            $loops = 1;
        }

326
        for ($i = 0; $i < $loops; $i++) {
327
328
329
            // We catch the exception thrown by the step definition to execute it again.
            try {
                // We don't check with !== because most of the time closures will return
330
331
                // direct Behat methods returns and we are not sure it will be always (bool)false
                // if it just runs the behat method without returning anything $return == null.
332
                if ($return = call_user_func($lambda, $this, $args)) {
333
334
                    return $return;
                }
335
            } catch (Exception $e) {
336
337
338
339
                // We would use the first closure exception if no exception has been provided.
                if (!$exception) {
                    $exception = $e;
                }
340
341
            }

342
343
344
345
346
347
            if ($this->running_javascript()) {
                if ($microsleep) {
                    usleep(100000);
                } else {
                    sleep(1);
                }
348
            }
349
350
        }

351
352
        // Using coding_exception as is a development issue if no exception has been provided.
        if (!$exception) {
353
            $exception = new coding_exception('spin method requires an exception if the callback does not throw an exception');
354
355
        }

356
357
358
359
        // Throwing exception to the user.
        throw $exception;
    }

360
361
362
    /**
     * Gets a NodeElement based on the locator and selector type received as argument from steps definitions.
     *
363
364
     * Use behat_base::get_text_selector_node() for text-based selectors.
     *
365
366
367
368
369
370
371
372
373
374
375
376
377
378
     * @throws ElementNotFoundException Thrown by behat_base::find
     * @param string $selectortype
     * @param string $element
     * @return NodeElement
     */
    protected function get_selected_node($selectortype, $element) {

        // Getting Mink selector and locator.
        list($selector, $locator) = $this->transform_selector($selectortype, $element);

        // Returns the NodeElement.
        return $this->find($selector, $locator);
    }

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
    /**
     * Gets a NodeElement based on the locator and selector type received as argument from steps definitions.
     *
     * @throws ElementNotFoundException Thrown by behat_base::find
     * @param string $selectortype
     * @param string $element
     * @return NodeElement
     */
    protected function get_text_selector_node($selectortype, $element) {

        // Getting Mink selector and locator.
        list($selector, $locator) = $this->transform_text_selector($selectortype, $element);

        // Returns the NodeElement.
        return $this->find($selector, $locator);
    }

    /**
     * Gets the requested element inside the specified container.
     *
     * @throws ElementNotFoundException Thrown by behat_base::find
     * @param mixed $selectortype The element selector type.
     * @param mixed $element The element locator.
     * @param mixed $containerselectortype The container selector type.
     * @param mixed $containerelement The container locator.
     * @return NodeElement
     */
    protected function get_node_in_container($selectortype, $element, $containerselectortype, $containerelement) {

        // Gets the container, it will always be text based.
        $containernode = $this->get_text_selector_node($containerselectortype, $containerelement);

        list($selector, $locator) = $this->transform_selector($selectortype, $element);

        // Specific exception giving info about where can't we find the element.
        $locatorexceptionmsg = $element . '" in the "' . $containerelement. '" "' . $containerselectortype. '"';
        $exception = new ElementNotFoundException($this->getSession(), $selectortype, null, $locatorexceptionmsg);

        // Looks for the requested node inside the container node.
        return $this->find($selector, $locator, $exception, $containernode);
    }

421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
    /**
     * Transforms from step definition's argument style to Mink format.
     *
     * Mink has 3 different selectors css, xpath and named, where named
     * selectors includes link, button, field... to simplify and group multiple
     * steps in one we use the same interface, considering all link, buttons...
     * at the same level as css selectors and xpath; this method makes the
     * conversion from the arguments received by the steps to the selectors and locators
     * required to interact with Mink.
     *
     * @throws ExpectationException
     * @param string $selectortype It can be css, xpath or any of the named selectors.
     * @param string $element The locator (or string) we are looking for.
     * @return array Contains the selector and the locator expected by Mink.
     */
    protected function transform_selector($selectortype, $element) {

438
439
440
        // Here we don't know if an allowed text selector is being used.
        $selectors = behat_selectors::get_allowed_selectors();
        if (!isset($selectors[$selectortype])) {
441
442
443
            throw new ExpectationException('The "' . $selectortype . '" selector type does not exist', $this->getSession());
        }

444
        return behat_selectors::get_behat_selector($selectortype, $element, $this->getSession());
445
446
    }

447
448
449
450
451
452
453
454
455
456
457
458
459
    /**
     * Transforms from step definition's argument style to Mink format.
     *
     * Delegates all the process to behat_base::transform_selector() checking
     * the provided $selectortype.
     *
     * @throws ExpectationException
     * @param string $selectortype It can be css, xpath or any of the named selectors.
     * @param string $element The locator (or string) we are looking for.
     * @return array Contains the selector and the locator expected by Mink.
     */
    protected function transform_text_selector($selectortype, $element) {

460
461
        $selectors = behat_selectors::get_allowed_text_selectors();
        if (empty($selectors[$selectortype])) {
462
463
464
465
466
467
            throw new ExpectationException('The "' . $selectortype . '" selector can not be used to select text nodes', $this->getSession());
        }

        return $this->transform_selector($selectortype, $element);
    }

468
469
470
471
472
473
    /**
     * Returns whether the scenario is running in a browser that can run Javascript or not.
     *
     * @return boolean
     */
    protected function running_javascript() {
474
        return get_class($this->getSession()->getDriver()) !== 'Behat\Mink\Driver\GoutteDriver';
475
476
    }

477
478
479
480
481
482
483
484
485
486
487
488
489
490
    /**
     * Spins around an element until it exists
     *
     * @throws ExpectationException
     * @param string $element
     * @param string $selectortype
     * @return void
     */
    protected function ensure_element_exists($element, $selectortype) {

        // Getting the behat selector & locator.
        list($selector, $locator) = $this->transform_selector($selectortype, $element);

        // Exception if it timesout and the element is still there.
491
        $msg = 'The "' . $element . '" element does not exist and should exist';
492
493
        $exception = new ExpectationException($msg, $this->getSession());

494
        // It will stop spinning once the find() method returns true.
495
496
497
498
499
500
        $this->spin(
            function($context, $args) {
                // We don't use behat_base::find as it is already spinning.
                if ($context->getSession()->getPage()->find($args['selector'], $args['locator'])) {
                    return true;
                }
501
                return false;
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
            },
            array('selector' => $selector, 'locator' => $locator),
            self::EXTENDED_TIMEOUT,
            $exception,
            true
        );

    }

    /**
     * Spins until the element does not exist
     *
     * @throws ExpectationException
     * @param string $element
     * @param string $selectortype
     * @return void
     */
    protected function ensure_element_does_not_exist($element, $selectortype) {

        // Getting the behat selector & locator.
        list($selector, $locator) = $this->transform_selector($selectortype, $element);

        // Exception if it timesout and the element is still there.
525
        $msg = 'The "' . $element . '" element exists and should not exist';
526
527
        $exception = new ExpectationException($msg, $this->getSession());

528
        // It will stop spinning once the find() method returns false.
529
530
        $this->spin(
            function($context, $args) {
531
                // We don't use behat_base::find() as we are already spinning.
532
533
534
                if (!$context->getSession()->getPage()->find($args['selector'], $args['locator'])) {
                    return true;
                }
535
                return false;
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
            },
            array('selector' => $selector, 'locator' => $locator),
            self::EXTENDED_TIMEOUT,
            $exception,
            true
        );
    }

    /**
     * Ensures that the provided node is visible and we can interact with it.
     *
     * @throws ExpectationException
     * @param NodeElement $node
     * @return void Throws an exception if it times out without the element being visible
     */
    protected function ensure_node_is_visible($node) {

        if (!$this->running_javascript()) {
            return;
        }

        // Exception if it timesout and the element is still there.
558
        $msg = 'The "' . $node->getXPath() . '" xpath node is not visible and it should be visible';
559
560
        $exception = new ExpectationException($msg, $this->getSession());

561
        // It will stop spinning once the isVisible() method returns true.
562
563
564
565
566
        $this->spin(
            function($context, $args) {
                if ($args->isVisible()) {
                    return true;
                }
567
                return false;
568
569
570
571
572
573
574
575
            },
            $node,
            self::EXTENDED_TIMEOUT,
            $exception,
            true
        );
    }

576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
    /**
     * Ensures that the provided node has a attribute value set. This step can be used to check if specific
     * JS has finished modifying the node.
     *
     * @throws ExpectationException
     * @param NodeElement $node
     * @param string $attribute attribute name
     * @param string $attributevalue attribute value to check.
     * @return void Throws an exception if it times out without the element being visible
     */
    protected function ensure_node_attribute_is_set($node, $attribute, $attributevalue) {

        if (!$this->running_javascript()) {
            return;
        }

        // Exception if it timesout and the element is still there.
        $msg = 'The "' . $node->getXPath() . '" xpath node is not visible and it should be visible';
        $exception = new ExpectationException($msg, $this->getSession());

        // It will stop spinning once the $args[1]) == $args[2], and method returns true.
        $this->spin(
            function($context, $args) {
                if ($args[0]->getAttribute($args[1]) == $args[2]) {
                    return true;
                }
                return false;
            },
            array($node, $attribute, $attributevalue),
            self::EXTENDED_TIMEOUT,
            $exception,
            true
        );
    }

611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
    /**
     * Ensures that the provided element is visible and we can interact with it.
     *
     * Returns the node in case other actions are interested in using it.
     *
     * @throws ExpectationException
     * @param string $element
     * @param string $selectortype
     * @return NodeElement Throws an exception if it times out without being visible
     */
    protected function ensure_element_is_visible($element, $selectortype) {

        if (!$this->running_javascript()) {
            return;
        }

        $node = $this->get_selected_node($selectortype, $element);
        $this->ensure_node_is_visible($node);

        return $node;
    }

    /**
     * Ensures that all the page's editors are loaded.
     *
636
     * @deprecated since Moodle 2.7 MDL-44084 - please do not use this function any more.
637
     * @throws ElementNotFoundException
638
     * @throws ExpectationException
639
640
641
     * @return void
     */
    protected function ensure_editors_are_loaded() {
642
643
644
645
646
        global $CFG;

        if (empty($CFG->behat_usedeprecated)) {
            debugging('Function behat_base::ensure_editors_are_loaded() is deprecated. It is no longer required.');
        }
647
        return;
648
    }
649

650
651
652
653
654
655
656
    /**
     * Change browser window size.
     *   - small: 640x480
     *   - medium: 1024x768
     *   - large: 2560x1600
     *
     * @param string $windowsize size of window.
657
     * @param bool $viewport If true, changes viewport rather than window size
658
659
     * @throws ExpectationException
     */
660
    protected function resize_window($windowsize, $viewport = false) {
661
662
663
664
665
        // Non JS don't support resize window.
        if (!$this->running_javascript()) {
            return;
        }

666
667
668
669
670
671
672
673
674
675
676
677
678
679
        switch ($windowsize) {
            case "small":
                $width = 640;
                $height = 480;
                break;
            case "medium":
                $width = 1024;
                $height = 768;
                break;
            case "large":
                $width = 2560;
                $height = 1600;
                break;
            default:
680
                preg_match('/^(\d+x\d+)$/', $windowsize, $matches);
681
682
683
684
685
686
687
                if (empty($matches) || (count($matches) != 2)) {
                    throw new ExpectationException("Invalid screen size, can't resize", $this->getSession());
                }
                $size = explode('x', $windowsize);
                $width = (int) $size[0];
                $height = (int) $size[1];
        }
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
        if ($viewport) {
            // When setting viewport size, we set it so that the document width will be exactly
            // as specified, assuming that there is a vertical scrollbar. (In cases where there is
            // no scrollbar it will be slightly wider. We presume this is rare and predictable.)
            // The window inner height will be as specified, which means the available viewport will
            // actually be smaller if there is a horizontal scrollbar. We assume that horizontal
            // scrollbars are rare so this doesn't matter.
            $offset = $this->getSession()->getDriver()->evaluateScript(
                    'return (function() { var before = document.body.style.overflowY;' .
                    'document.body.style.overflowY = "scroll";' .
                    'var result = {};' .
                    'result.x = window.outerWidth - document.body.offsetWidth;' .
                    'result.y = window.outerHeight - window.innerHeight;' .
                    'document.body.style.overflowY = before;' .
                    'return result; })();');
            $width += $offset['x'];
            $height += $offset['y'];
        }

707
708
        $this->getSession()->getDriver()->resizeWindow($width, $height);
    }
709
710
711
712

    /**
     * Waits for all the JS to be loaded.
     *
713
     * @return  bool Whether any JS is still pending completion.
714
715
716
     */
    public function wait_for_pending_js() {
        if (!$this->running_javascript()) {
717
718
            // JS is not available therefore there is nothing to wait for.
            return false;
719
720
        }

721
722
723
724
725
726
727
728
729
730
        return static::wait_for_pending_js_in_session($this->getSession());
    }

    /**
     * Waits for all the JS to be loaded.
     *
     * @param   Session $session The Mink Session where JS can be run
     * @return  bool Whether any JS is still pending completion.
     */
    public static function wait_for_pending_js_in_session(Session $session) {
731
732
733
734
735
        // 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 {
736
                $jscode = trim(preg_replace('/\s+/', ' ', '
737
                    return (function() {
738
739
740
741
742
743
744
745
                        if (typeof M === "undefined") {
                            if (document.readyState === "complete") {
                                return "";
                            } else {
                                return "incomplete";
                            }
                        } else if (' . self::PAGE_READY_JS . ') {
                            return "";
746
                        } else if (typeof M.util !== "undefined") {
747
                            return M.util.pending_js.join(":");
748
749
                        } else {
                            return "incomplete"
750
                        }
751
752
                    }());'));
                $pending = $session->evaluateScript($jscode);
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
            } 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);
        }

773
        // Timeout waiting for JS to complete. It will be caught and forwarded to behat_hooks::i_look_for_exceptions().
774
775
776
777
778
779
        // 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.');
    }
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808

    /**
     * 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.
     *
     * @throws Exception Unknown type, depending on what we caught in the hook or basic \Exception.
     * @see Moodle\BehatExtension\Tester\MoodleStepTester
     */
    public function look_for_exceptions() {
        // 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)) {
809
810
811
812
813
814
815
816
817
818
819
                // Check if we have recorded any errors in driver process.
                $phperrors = behat_get_shutdown_process_errors();
                if (!empty($phperrors)) {
                    foreach ($phperrors as $error) {
                        $errnostring = behat_get_error_string($error['type']);
                        $msgs[] = $errnostring . ": " .$error['message'] . " at " . $error['file'] . ": " . $error['line'];
                    }
                    $msg = "PHP errors found:\n" . implode("\n", $msgs);
                    throw new \Exception(htmlentities($msg));
                }

820
821
822
823
824
825
826
827
                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');
828
829
830
831
                // If errorinfoboxes is empty, try find alert-danger (bootstrap4) class.
                if (empty($errorinfoboxes)) {
                    $errorinfoboxes = $this->getSession()->getPage()->findAll('css', 'div.alert-danger');
                }
832
833
834
835
                // If errorinfoboxes is empty, try find notifytiny (original) class.
                if (empty($errorinfoboxes)) {
                    $errorinfoboxes = $this->getSession()->getPage()->findAll('css', 'div.notifytiny');
                }
836
837
838
839
840
841
842
843
844
845
846
847
848
849

                // If errorinfoboxes is empty, try find ajax/JS exception in dialogue.
                if (empty($errorinfoboxes)) {
                    $errorinfoboxes = $this->getSession()->getPage()->findAll('css', 'div.moodle-exception-message');

                    // If ajax/JS exception.
                    if ($errorinfoboxes) {
                        $errorinfo = $this->get_debug_text($errorinfoboxes[0]->getHtml());
                    }

                } else {
                    $errorinfo = $this->get_debug_text($errorinfoboxes[0]->getHtml()) . "\n" .
                        $this->get_debug_text($errorinfoboxes[1]->getHtml());
                }
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892

                $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.
893
894
        } catch (DriverException $e) {
            // Same reason as above.
895
896
897
        }
    }

898
899
900
901
902
903
904
905
906
907
908
909
910
    /**
     * 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);
    }

911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
    /**
     * Helper function to execute api in a given context.
     *
     * @param string $contextapi context in which api is defined.
     * @param array $params list of params to pass.
     * @throws Exception
     */
    protected function execute($contextapi, $params = array()) {
        if (!is_array($params)) {
            $params = array($params);
        }

        // Get required context and execute the api.
        $contextapi = explode("::", $contextapi);
        $context = behat_context_helper::get($contextapi[0]);
        call_user_func_array(array($context, $contextapi[1]), $params);

        // NOTE: Wait for pending js and look for exception are not optional, as this might lead to unexpected results.
        // Don't make them optional for performance reasons.

        // Wait for pending js.
        $this->wait_for_pending_js();

        // Look for exceptions.
        $this->look_for_exceptions();
    }
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955

    /**
     * Get the actual user in the behat session (note $USER does not correspond to the behat session's user).
     * @return mixed
     * @throws coding_exception
     */
    protected function get_session_user() {
        global $DB;

        $sid = $this->getSession()->getCookie('MoodleSession');
        if (empty($sid)) {
            throw new coding_exception('failed to get moodle session');
        }
        $userid = $DB->get_field('sessions', 'userid', ['sid' => $sid]);
        if (empty($userid)) {
            throw new coding_exception('failed to get user from seession id '.$sid);
        }
        return $DB->get_record('user', ['id' => $userid]);
    }
956
957

    /**
958
     * Set current $USER, reset access cache.
959
960
961
962
     *
     * In some cases, behat will execute the code as admin but in many cases we need to set an specific user as some
     * API's might rely on the logged user to take some action.
     *
963
964
965
966
967
968
969
970
971
972
973
     * @param null|int|stdClass $user user record, null or 0 means non-logged-in, positive integer means userid
     */
    public static function set_user($user = null) {
        global $DB;

        if (is_object($user)) {
            $user = clone($user);
        } else if (!$user) {
            // Assign valid data to admin user (some generator-related code needs a valid user).
            $user = $DB->get_record('user', array('username' => 'admin'));
        } else {
974
            $user = $DB->get_record('user', array('id' => $user));
975
976
977
978
979
        }
        unset($user->description);
        unset($user->access);
        unset($user->preference);

980
        // Ensure session is empty, as it may contain caches and user specific info.
981
982
983
984
985
        \core\session\manager::init_empty_session();

        \core\session\manager::set_user($user);
    }
    /**
986
987
988
989
990
991
992
993
994
995
996
997
998
     * Trigger click on node via javascript instead of actually clicking on it via pointer.
     *
     * This function resolves the issue of nested elements with click listeners or links - in these cases clicking via
     * the pointer may accidentally cause a click on the wrong element.
     * Example of issue: clicking to expand navigation nodes when the config value linkadmincategories is enabled.
     * @param NodeElement $node
     */
    protected function js_trigger_click($node) {
        if (!$this->running_javascript()) {
            $node->click();
        }
        $this->ensure_node_is_visible($node); // Ensures hidden elements can't be clicked.
        $xpath = $node->getXpath();
999
1000
        $driver = $this->getSession()->getDriver();
        if ($driver instanceof \Moodle\BehatExtension\Driver\MoodleSelenium2Driver) {
For faster browsing, not all history is shown. View entire blame