1<?php
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 * A trait containing functionality used by the behat base context, and form fields.
19 *
20 * @package    core
21 * @category   test
22 * @copyright  2020 Andrew Nicols <andrew@nicols.co.uk>
23 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
24 */
25
26use Behat\Mink\Element\NodeElement;
27use Behat\Mink\Element\Element;
28use Behat\Mink\Exception\DriverException;
29use Behat\Mink\Exception\ExpectationException;
30use Behat\Mink\Exception\ElementNotFoundException;
31use Behat\Mink\Exception\NoSuchWindowException;
32use Behat\Mink\Session;
33use Facebook\WebDriver\Exception\ScriptTimeoutException;
34use Facebook\WebDriver\WebDriverBy;
35use Facebook\WebDriver\WebDriverElement;
36
37// NOTE: no MOODLE_INTERNAL test here, this file may be required by behat before including /config.php.
38
39require_once(__DIR__ . '/component_named_replacement.php');
40require_once(__DIR__ . '/component_named_selector.php');
41
42// Alias the Facebook\WebDriver\WebDriverKeys class to behat_keys for better b/c with the older Instaclick driver.
43class_alias('Facebook\WebDriver\WebDriverKeys', 'behat_keys');
44
45/**
46 * A trait containing functionality used by the behat base context, and form fields.
47 *
48 * This trait should be used by the behat_base context, and behat form fields, and it should be paired with the
49 * behat_session_interface interface.
50 *
51 * It should not be necessary to use this trait, and the behat_session_interface interface in normal circumstances.
52 *
53 * @package    core
54 * @category   test
55 * @copyright  2020 Andrew Nicols <andrew@nicols.co.uk>
56 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
57 */
58trait behat_session_trait {
59
60    /**
61     * Locates url, based on provided path.
62     * Override to provide custom routing mechanism.
63     *
64     * @see Behat\MinkExtension\Context\MinkContext
65     * @param string $path
66     * @return string
67     */
68    protected function locate_path($path) {
69        $starturl = rtrim($this->getMinkParameter('base_url'), '/') . '/';
70        return 0 !== strpos($path, 'http') ? $starturl . ltrim($path, '/') : $path;
71    }
72
73    /**
74     * Returns the first matching element.
75     *
76     * @link http://mink.behat.org/#traverse-the-page-selectors
77     * @param string $selector The selector type (css, xpath, named...)
78     * @param mixed $locator It depends on the $selector, can be the xpath, a name, a css locator...
79     * @param Exception $exception Otherwise we throw exception with generic info
80     * @param NodeElement $node Spins around certain DOM node instead of the whole page
81     * @param int $timeout Forces a specific time out (in seconds).
82     * @return NodeElement
83     */
84    protected function find($selector, $locator, $exception = false, $node = false, $timeout = false) {
85        if ($selector === 'NodeElement' && is_a($locator, NodeElement::class)) {
86            // Support a NodeElement being passed in for use in step chaining.
87            return $locator;
88        }
89
90        // Returns the first match.
91        $items = $this->find_all($selector, $locator, $exception, $node, $timeout);
92        return count($items) ? reset($items) : null;
93    }
94
95    /**
96     * Returns all matching elements.
97     *
98     * Adapter to Behat\Mink\Element\Element::findAll() using the spin() method.
99     *
100     * @link http://mink.behat.org/#traverse-the-page-selectors
101     * @param string $selector The selector type (css, xpath, named...)
102     * @param mixed $locator It depends on the $selector, can be the xpath, a name, a css locator...
103     * @param Exception $exception Otherwise we throw expcetion with generic info
104     * @param NodeElement $container Restrict the search to just children of the specified container
105     * @param int $timeout Forces a specific time out (in seconds). If 0 is provided the default timeout will be applied.
106     * @return array NodeElements list
107     */
108    protected function find_all($selector, $locator, $exception = false, $container = false, $timeout = false) {
109        // Throw exception, so dev knows it is not supported.
110        if ($selector === 'named') {
111            $exception = 'Using the "named" selector is deprecated as of 3.1. '
112                .' Use the "named_partial" or use the "named_exact" selector instead.';
113            throw new ExpectationException($exception, $this->getSession());
114        }
115
116        // Generic info.
117        if (!$exception) {
118            // With named selectors we can be more specific.
119            if (($selector == 'named_exact') || ($selector == 'named_partial')) {
120                $exceptiontype = $locator[0];
121                $exceptionlocator = $locator[1];
122
123                // If we are in a @javascript session all contents would be displayed as HTML characters.
124                if ($this->running_javascript()) {
125                    $locator[1] = html_entity_decode($locator[1], ENT_NOQUOTES);
126                }
127
128            } else {
129                $exceptiontype = $selector;
130                $exceptionlocator = $locator;
131            }
132
133            $exception = new ElementNotFoundException($this->getSession(), $exceptiontype, null, $exceptionlocator);
134        }
135
136        // How much we will be waiting for the element to appear.
137        if (!$timeout) {
138            $timeout = self::get_timeout();
139            $microsleep = false;
140        } else {
141            // Spinning each 0.1 seconds if the timeout was forced as we understand
142            // that is a special case and is good to refine the performance as much
143            // as possible.
144            $microsleep = true;
145        }
146
147        // Normalise the values in order to perform the search.
148        [
149            'selector' => $selector,
150            'locator' => $locator,
151            'container' => $container,
152        ] = $this->normalise_selector($selector, $locator, $container ?: $this->getSession()->getPage());
153
154        // Waits for the node to appear if it exists, otherwise will timeout and throw the provided exception.
155        return $this->spin(
156            function() use ($selector, $locator, $container) {
157                return $container->findAll($selector, $locator);
158            }, [], $timeout, $exception, $microsleep
159        );
160    }
161
162    /**
163     * Normalise the locator and selector.
164     *
165     * @param string $selector The type of thing to search
166     * @param mixed $locator The locator value. Can be an array, but is more likely a string.
167     * @param Element $container An optional container to search within
168     * @return array The selector, locator, and container to search within
169     */
170    public function normalise_selector(string $selector, $locator, Element $container): array {
171        // Check for specific transformations for this selector type.
172        $transformfunction = "transform_find_for_{$selector}";
173        if (method_exists('behat_selectors', $transformfunction)) {
174            // A selector-specific transformation exists.
175            // Perform initial transformation of the selector within the current container.
176            [
177                'selector' => $selector,
178                'locator' => $locator,
179                'container' => $container,
180            ] = behat_selectors::{$transformfunction}($this, $locator, $container);
181        }
182
183        // Normalise the css and xpath selector types.
184        if ('css_element' === $selector) {
185            $selector = 'css';
186        } else if ('xpath_element' === $selector) {
187            $selector = 'xpath';
188        }
189
190        // Convert to a named selector where the selector type is not a known selector.
191        $converttonamed = !$this->getSession()->getSelectorsHandler()->isSelectorRegistered($selector);
192        $converttonamed = $converttonamed && 'xpath' !== $selector;
193        if ($converttonamed) {
194            if (behat_partial_named_selector::is_deprecated_selector($selector)) {
195                if ($replacement = behat_partial_named_selector::get_deprecated_replacement($selector)) {
196                    error_log("The '{$selector}' selector has been replaced with {$replacement}");
197                    $selector = $replacement;
198                }
199            } else if (behat_exact_named_selector::is_deprecated_selector($selector)) {
200                if ($replacement = behat_exact_named_selector::get_deprecated_replacement($selector)) {
201                    error_log("The '{$selector}' selector has been replaced with {$replacement}");
202                    $selector = $replacement;
203                }
204            }
205
206            $allowedpartialselectors = behat_partial_named_selector::get_allowed_selectors();
207            $allowedexactselectors = behat_exact_named_selector::get_allowed_selectors();
208            if (isset($allowedpartialselectors[$selector])) {
209                $locator = behat_selectors::normalise_named_selector($allowedpartialselectors[$selector], $locator);
210                $selector = 'named_partial';
211            } else if (isset($allowedexactselectors[$selector])) {
212                $locator = behat_selectors::normalise_named_selector($allowedexactselectors[$selector], $locator);
213                $selector = 'named_exact';
214            } else {
215                throw new ExpectationException("The '{$selector}' selector type is not registered.", $this->getSession()->getDriver());
216            }
217        }
218
219        return [
220            'selector' => $selector,
221            'locator' => $locator,
222            'container' => $container,
223        ];
224    }
225
226    /**
227     * Send key presses straight to the currently active element.
228     *
229     * The `$keys` array contains a list of key values to send to the session as defined in the WebDriver and JsonWire
230     * specifications:
231     * - JsonWire: https://github.com/SeleniumHQ/selenium/wiki/JsonWireProtocol#sessionsessionidkeys
232     * - W3C WebDriver: https://www.w3.org/TR/webdriver/#keyboard-actions
233     *
234     * This may be a combination of typable characters, modifier keys, and other supported keypoints.
235     *
236     * The NULL_KEY should be used to release modifier keys. If the NULL_KEY is not used then modifier keys will remain
237     * in the pressed state.
238     *
239     * Example usage:
240     *
241     *      behat_base::type_keys($this->getSession(), [behat_keys::SHIFT, behat_keys::TAB, behat_keys::NULL_KEY]);
242     *      behat_base::type_keys($this->getSession(), [behat_keys::ENTER, behat_keys::NULL_KEY]);
243     *      behat_base::type_keys($this->getSession(), [behat_keys::ESCAPE, behat_keys::NULL_KEY]);
244     *
245     * It can also be used to send text input, for example:
246     *
247     *      behat_base::type_keys(
248     *          $this->getSession(),
249     *          ['D', 'o', ' ', 'y', 'o', 'u', ' ', 'p', 'l', 'a' 'y', ' ', 'G', 'o', '?', behat_base::NULL_KEY]
250     *      );
251     *
252     *
253     * Please note: This function does not use the element/sendKeys variants but sends keys straight to the browser.
254     *
255     * @param Session $session
256     * @param string[] $keys
257     */
258    public static function type_keys(Session $session, array $keys): void {
259        $session->getDriver()->getWebDriver()->getKeyboard()->sendKeys($keys);
260    }
261
262    /**
263     * Finds DOM nodes in the page using named selectors.
264     *
265     * The point of using this method instead of Mink ones is the spin
266     * method of behat_base::find() that looks for the element until it
267     * is available or it timeouts, this avoids the false failures received
268     * when selenium tries to execute commands on elements that are not
269     * ready to be used.
270     *
271     * All steps that requires elements to be available before interact with
272     * them should use one of the find* methods.
273     *
274     * The methods calls requires a {'find_' . $elementtype}($locator)
275     * format, like find_link($locator), find_select($locator),
276     * find_button($locator)...
277     *
278     * @link http://mink.behat.org/#named-selectors
279     * @throws coding_exception
280     * @param string $name The name of the called method
281     * @param mixed $arguments
282     * @return NodeElement
283     */
284    public function __call($name, $arguments) {
285        if (substr($name, 0, 5) === 'find_') {
286            return call_user_func_array([$this, 'find'], array_merge(
287                [substr($name, 5)],
288                $arguments
289            ));
290        }
291
292        throw new coding_exception("The '{$name}' method does not exist");
293    }
294
295    /**
296     * Escapes the double quote character.
297     *
298     * Double quote is the argument delimiter, it can be escaped
299     * with a backslash, but we auto-remove this backslashes
300     * before the step execution, this method is useful when using
301     * arguments as arguments for other steps.
302     *
303     * @param string $string
304     * @return string
305     */
306    public function escape($string) {
307        return str_replace('"', '\"', $string);
308    }
309
310    /**
311     * Executes the passed closure until returns true or time outs.
312     *
313     * In most cases the document.readyState === 'complete' will be enough, but sometimes JS
314     * requires more time to be completely loaded or an element to be visible or whatever is required to
315     * perform some action on an element; this method receives a closure which should contain the
316     * required statements to ensure the step definition actions and assertions have all their needs
317     * satisfied and executes it until they are satisfied or it timeouts. Redirects the return of the
318     * closure to the caller.
319     *
320     * The closures requirements to work well with this spin method are:
321     * - Must return false, null or '' if something goes wrong
322     * - Must return something != false if finishes as expected, this will be the (mixed) value
323     * returned by spin()
324     *
325     * The arguments of the closure are mixed, use $args depending on your needs.
326     *
327     * You can provide an exception to give more accurate feedback to tests writers, otherwise the
328     * closure exception will be used, but you must provide an exception if the closure does not throw
329     * an exception.
330     *
331     * @throws Exception If it timeouts without receiving something != false from the closure
332     * @param Function|array|string $lambda The function to execute or an array passed to call_user_func (maps to a class method)
333     * @param mixed $args Arguments to pass to the closure
334     * @param int $timeout Timeout in seconds
335     * @param Exception $exception The exception to throw in case it time outs.
336     * @param bool $microsleep If set to true it'll sleep micro seconds rather than seconds.
337     * @return mixed The value returned by the closure
338     */
339    protected function spin($lambda, $args = false, $timeout = false, $exception = false, $microsleep = false) {
340
341        // Using default timeout which is pretty high.
342        if (!$timeout) {
343            $timeout = self::get_timeout();
344        }
345
346        $start = microtime(true);
347        $end = $start + $timeout;
348
349        do {
350            // We catch the exception thrown by the step definition to execute it again.
351            try {
352                // We don't check with !== because most of the time closures will return
353                // direct Behat methods returns and we are not sure it will be always (bool)false
354                // if it just runs the behat method without returning anything $return == null.
355                if ($return = call_user_func($lambda, $this, $args)) {
356                    return $return;
357                }
358            } catch (Exception $e) {
359                // We would use the first closure exception if no exception has been provided.
360                if (!$exception) {
361                    $exception = $e;
362                }
363            }
364
365            if (!$this->running_javascript()) {
366                break;
367            }
368
369            usleep(100000);
370
371        } while (microtime(true) < $end);
372
373        // Using coding_exception as is a development issue if no exception has been provided.
374        if (!$exception) {
375            $exception = new coding_exception('spin method requires an exception if the callback does not throw an exception');
376        }
377
378        // Throwing exception to the user.
379        throw $exception;
380    }
381
382    /**
383     * Gets a NodeElement based on the locator and selector type received as argument from steps definitions.
384     *
385     * Use behat_base::get_text_selector_node() for text-based selectors.
386     *
387     * @throws ElementNotFoundException Thrown by behat_base::find
388     * @param string $selectortype
389     * @param string $element
390     * @return NodeElement
391     */
392    protected function get_selected_node($selectortype, $element) {
393        return $this->find($selectortype, $element);
394    }
395
396    /**
397     * Gets a NodeElement based on the locator and selector type received as argument from steps definitions.
398     *
399     * @throws ElementNotFoundException Thrown by behat_base::find
400     * @param string $selectortype
401     * @param string $element
402     * @return NodeElement
403     */
404    protected function get_text_selector_node($selectortype, $element) {
405        // Getting Mink selector and locator.
406        list($selector, $locator) = $this->transform_text_selector($selectortype, $element);
407
408        // Returns the NodeElement.
409        return $this->find($selector, $locator);
410    }
411
412    /**
413     * Gets the requested element inside the specified container.
414     *
415     * @throws ElementNotFoundException Thrown by behat_base::find
416     * @param mixed $selectortype The element selector type.
417     * @param mixed $element The element locator.
418     * @param mixed $containerselectortype The container selector type.
419     * @param mixed $containerelement The container locator.
420     * @return NodeElement
421     */
422    protected function get_node_in_container($selectortype, $element, $containerselectortype, $containerelement) {
423        if ($containerselectortype === 'NodeElement' && is_a($containerelement, NodeElement::class)) {
424            // Support a NodeElement being passed in for use in step chaining.
425            $containernode = $containerelement;
426            $locatorexceptionmsg = $element;
427        } else {
428            // Gets the container, it will always be text based.
429            $containernode = $this->get_text_selector_node($containerselectortype, $containerelement);
430            $locatorexceptionmsg = $element . '" in the "' . $containerelement. '" "' . $containerselectortype. '"';
431        }
432
433        $exception = new ElementNotFoundException($this->getSession(), $selectortype, null, $locatorexceptionmsg);
434
435        return $this->find($selectortype, $element, $exception, $containernode);
436    }
437
438    /**
439     * Transforms from step definition's argument style to Mink format.
440     *
441     * Mink has 3 different selectors css, xpath and named, where named
442     * selectors includes link, button, field... to simplify and group multiple
443     * steps in one we use the same interface, considering all link, buttons...
444     * at the same level as css selectors and xpath; this method makes the
445     * conversion from the arguments received by the steps to the selectors and locators
446     * required to interact with Mink.
447     *
448     * @throws ExpectationException
449     * @param string $selectortype It can be css, xpath or any of the named selectors.
450     * @param string $element The locator (or string) we are looking for.
451     * @return array Contains the selector and the locator expected by Mink.
452     */
453    protected function transform_selector($selectortype, $element) {
454        // Here we don't know if an allowed text selector is being used.
455        $selectors = behat_selectors::get_allowed_selectors();
456        if (!isset($selectors[$selectortype])) {
457            throw new ExpectationException('The "' . $selectortype . '" selector type does not exist', $this->getSession());
458        }
459
460        [
461            'selector' => $selector,
462            'locator' => $locator,
463        ] = $this->normalise_selector($selectortype, $element, $this->getSession()->getPage());
464
465        return [$selector, $locator];
466    }
467
468    /**
469     * Transforms from step definition's argument style to Mink format.
470     *
471     * Delegates all the process to behat_base::transform_selector() checking
472     * the provided $selectortype.
473     *
474     * @throws ExpectationException
475     * @param string $selectortype It can be css, xpath or any of the named selectors.
476     * @param string $element The locator (or string) we are looking for.
477     * @return array Contains the selector and the locator expected by Mink.
478     */
479    protected function transform_text_selector($selectortype, $element) {
480
481        $selectors = behat_selectors::get_allowed_text_selectors();
482        if (empty($selectors[$selectortype])) {
483            throw new ExpectationException('The "' . $selectortype . '" selector can not be used to select text nodes', $this->getSession());
484        }
485
486        return $this->transform_selector($selectortype, $element);
487    }
488
489    /**
490     * Whether Javascript is available in the current Session.
491     *
492     * @return boolean
493     */
494    protected function running_javascript() {
495        return self::running_javascript_in_session($this->getSession());
496    }
497
498    /**
499     * Require that javascript be available in the current Session.
500     *
501     * @throws DriverException
502     */
503    protected function require_javascript() {
504        return self::require_javascript_in_session($this->getSession());
505    }
506
507    /**
508     * Whether Javascript is available in the specified Session.
509     *
510     * @param Session $session
511     * @return boolean
512     */
513    protected static function running_javascript_in_session(Session $session): bool {
514        return get_class($session->getDriver()) !== 'Behat\Mink\Driver\GoutteDriver';
515    }
516
517    /**
518     * Require that javascript be available for the specified Session.
519     *
520     * @param Session $session
521     * @throws DriverException
522     */
523    protected static function require_javascript_in_session(Session $session): void {
524        if (self::running_javascript_in_session($session)) {
525            return;
526        }
527
528        throw new DriverException('Javascript is required');
529    }
530
531    /**
532     * Checks if the current page is part of the mobile app.
533     *
534     * @return bool True if it's in the app
535     */
536    protected function is_in_app() : bool {
537        // Cannot be in the app if there's no @app tag on scenario.
538        if (!$this->has_tag('app')) {
539            return false;
540        }
541
542        // Check on page to see if it's an app page. Safest way is to look for added JavaScript.
543        return $this->evaluate_script('return typeof window.behat') === 'object';
544    }
545
546    /**
547     * Spins around an element until it exists
548     *
549     * @throws ExpectationException
550     * @param string $locator
551     * @param string $selectortype
552     * @return void
553     */
554    protected function ensure_element_exists($locator, $selectortype) {
555        // Exception if it timesout and the element is still there.
556        $msg = "The '{$locator}' element does not exist and should";
557        $exception = new ExpectationException($msg, $this->getSession());
558
559        // Normalise the values in order to perform the search.
560        [
561            'selector' => $selector,
562            'locator' => $locator,
563            'container' => $container,
564        ] = $this->normalise_selector($selectortype, $locator, $this->getSession()->getPage());
565
566        // It will stop spinning once the find() method returns true.
567        $this->spin(
568            function() use ($selector, $locator, $container) {
569                if ($container->find($selector, $locator)) {
570                    return true;
571                }
572                return false;
573            },
574            [],
575            self::get_extended_timeout(),
576            $exception,
577            true
578        );
579    }
580
581    /**
582     * Spins until the element does not exist
583     *
584     * @throws ExpectationException
585     * @param string $locator
586     * @param string $selectortype
587     * @return void
588     */
589    protected function ensure_element_does_not_exist($locator, $selectortype) {
590        // Exception if it timesout and the element is still there.
591        $msg = "The '{$locator}' element exists and should not exist";
592        $exception = new ExpectationException($msg, $this->getSession());
593
594        // Normalise the values in order to perform the search.
595        [
596            'selector' => $selector,
597            'locator' => $locator,
598            'container' => $container,
599        ] = $this->normalise_selector($selectortype, $locator, $this->getSession()->getPage());
600
601        // It will stop spinning once the find() method returns false.
602        $this->spin(
603            function() use ($selector, $locator, $container) {
604                if ($container->find($selector, $locator)) {
605                    return false;
606                }
607                return true;
608            },
609            // Note: We cannot use $this because the find will then be $this->find(), which leads us to a nested spin().
610            // We cannot nest spins because the outer spin times out before the inner spin completes.
611            [],
612            self::get_extended_timeout(),
613            $exception,
614            true
615        );
616    }
617
618    /**
619     * Ensures that the provided node is visible and we can interact with it.
620     *
621     * @throws ExpectationException
622     * @param NodeElement $node
623     * @return void Throws an exception if it times out without the element being visible
624     */
625    protected function ensure_node_is_visible($node) {
626
627        if (!$this->running_javascript()) {
628            return;
629        }
630
631        // Exception if it timesout and the element is still there.
632        $msg = 'The "' . $node->getXPath() . '" xpath node is not visible and it should be visible';
633        $exception = new ExpectationException($msg, $this->getSession());
634
635        // It will stop spinning once the isVisible() method returns true.
636        $this->spin(
637            function($context, $args) {
638                if ($args->isVisible()) {
639                    return true;
640                }
641                return false;
642            },
643            $node,
644            self::get_extended_timeout(),
645            $exception,
646            true
647        );
648    }
649
650    /**
651     * Ensures that the provided node has a attribute value set. This step can be used to check if specific
652     * JS has finished modifying the node.
653     *
654     * @throws ExpectationException
655     * @param NodeElement $node
656     * @param string $attribute attribute name
657     * @param string $attributevalue attribute value to check.
658     * @return void Throws an exception if it times out without the element being visible
659     */
660    protected function ensure_node_attribute_is_set($node, $attribute, $attributevalue) {
661
662        if (!$this->running_javascript()) {
663            return;
664        }
665
666        // Exception if it timesout and the element is still there.
667        $msg = 'The "' . $node->getXPath() . '" xpath node is not visible and it should be visible';
668        $exception = new ExpectationException($msg, $this->getSession());
669
670        // It will stop spinning once the $args[1]) == $args[2], and method returns true.
671        $this->spin(
672            function($context, $args) {
673                if ($args[0]->getAttribute($args[1]) == $args[2]) {
674                    return true;
675                }
676                return false;
677            },
678            array($node, $attribute, $attributevalue),
679            self::get_extended_timeout(),
680            $exception,
681            true
682        );
683    }
684
685    /**
686     * Ensures that the provided element is visible and we can interact with it.
687     *
688     * Returns the node in case other actions are interested in using it.
689     *
690     * @throws ExpectationException
691     * @param string $element
692     * @param string $selectortype
693     * @return NodeElement Throws an exception if it times out without being visible
694     */
695    protected function ensure_element_is_visible($element, $selectortype) {
696
697        if (!$this->running_javascript()) {
698            return;
699        }
700
701        $node = $this->get_selected_node($selectortype, $element);
702        $this->ensure_node_is_visible($node);
703
704        return $node;
705    }
706
707    /**
708     * Ensures that all the page's editors are loaded.
709     *
710     * @deprecated since Moodle 2.7 MDL-44084 - please do not use this function any more.
711     * @throws ElementNotFoundException
712     * @throws ExpectationException
713     * @return void
714     */
715    protected function ensure_editors_are_loaded() {
716        global $CFG;
717
718        if (empty($CFG->behat_usedeprecated)) {
719            debugging('Function behat_base::ensure_editors_are_loaded() is deprecated. It is no longer required.');
720        }
721        return;
722    }
723
724    /**
725     * Checks if the current scenario, or its feature, has a specified tag.
726     *
727     * @param string $tag Tag to check
728     * @return bool True if the tag exists in scenario or feature
729     */
730    public function has_tag(string $tag) : bool {
731        return array_key_exists($tag, behat_hooks::get_tags_for_scenario());
732    }
733
734    /**
735     * Change browser window size.
736     *   - small: 640x480
737     *   - medium: 1024x768
738     *   - large: 2560x1600
739     *
740     * @param string $windowsize size of window.
741     * @param bool $viewport If true, changes viewport rather than window size
742     * @throws ExpectationException
743     */
744    protected function resize_window($windowsize, $viewport = false) {
745        global $CFG;
746
747        // Non JS don't support resize window.
748        if (!$this->running_javascript()) {
749            return;
750        }
751
752        switch ($windowsize) {
753            case "small":
754                $width = 1024;
755                $height = 768;
756                break;
757            case "medium":
758                $width = 1366;
759                $height = 768;
760                break;
761            case "large":
762                $width = 2560;
763                $height = 1600;
764                break;
765            default:
766                preg_match('/^(\d+x\d+)$/', $windowsize, $matches);
767                if (empty($matches) || (count($matches) != 2)) {
768                    throw new ExpectationException("Invalid screen size, can't resize", $this->getSession());
769                }
770                $size = explode('x', $windowsize);
771                $width = (int) $size[0];
772                $height = (int) $size[1];
773        }
774
775        if (isset($CFG->behat_window_size_modifier) && is_numeric($CFG->behat_window_size_modifier)) {
776            $width *= $CFG->behat_window_size_modifier;
777            $height *= $CFG->behat_window_size_modifier;
778        }
779
780        if ($viewport) {
781            // When setting viewport size, we set it so that the document width will be exactly
782            // as specified, assuming that there is a vertical scrollbar. (In cases where there is
783            // no scrollbar it will be slightly wider. We presume this is rare and predictable.)
784            // The window inner height will be as specified, which means the available viewport will
785            // actually be smaller if there is a horizontal scrollbar. We assume that horizontal
786            // scrollbars are rare so this doesn't matter.
787            $js = <<<EOF
788return (function() {
789    var before = document.body.style.overflowY;
790    document.body.style.overflowY = "scroll";
791    var result = {};
792    result.x = window.outerWidth - document.body.offsetWidth;
793    result.y = window.outerHeight - window.innerHeight;
794    document.body.style.overflowY = before;
795    return result;
796})();
797EOF;
798            $offset = $this->evaluate_script($js);
799            $width += $offset['x'];
800            $height += $offset['y'];
801        }
802
803        $this->getSession()->getDriver()->resizeWindow($width, $height);
804    }
805
806    /**
807     * Waits for all the JS to be loaded.
808     *
809     * @return  bool Whether any JS is still pending completion.
810     */
811    public function wait_for_pending_js() {
812        return static::wait_for_pending_js_in_session($this->getSession());
813    }
814
815    /**
816     * Waits for all the JS to be loaded.
817     *
818     * @param   Session $session The Mink Session where JS can be run
819     * @return  bool Whether any JS is still pending completion.
820     */
821    public static function wait_for_pending_js_in_session(Session $session) {
822        if (!self::running_javascript_in_session($session)) {
823            // JS is not available therefore there is nothing to wait for.
824            return false;
825        }
826
827        // We don't use behat_base::spin() here as we don't want to end up with an exception
828        // if the page & JSs don't finish loading properly.
829        for ($i = 0; $i < self::get_extended_timeout() * 10; $i++) {
830            $pending = '';
831            try {
832                $jscode = trim(preg_replace('/\s+/', ' ', '
833                    return (function() {
834                        if (document.readyState !== "complete") {
835                            return "incomplete";
836                        }
837
838                        if (typeof M !== "object" || typeof M.util !== "object" || typeof M.util.pending_js === "undefined") {
839                            return "";
840                        }
841
842                        return M.util.pending_js.join(":");
843                    })()'));
844                $pending = self::evaluate_script_in_session($session, $jscode);
845            } catch (NoSuchWindowException $nsw) {
846                // We catch an exception here, in case we just closed the window we were interacting with.
847                // No javascript is running if there is no window right?
848                $pending = '';
849            } catch (UnknownError $e) {
850                // M is not defined when the window or the frame don't exist anymore.
851                if (strstr($e->getMessage(), 'M is not defined') != false) {
852                    $pending = '';
853                }
854            }
855
856            // If there are no pending JS we stop waiting.
857            if ($pending === '') {
858                return true;
859            }
860
861            // 0.1 seconds.
862            usleep(100000);
863        }
864
865        // Timeout waiting for JS to complete. It will be caught and forwarded to behat_hooks::i_look_for_exceptions().
866        // It is unlikely that Javascript code of a page or an AJAX request needs more than get_extended_timeout() seconds
867        // to be loaded, although when pages contains Javascript errors M.util.js_complete() can not be executed, so the
868        // number of JS pending code and JS completed code will not match and we will reach this point.
869        throw new \Exception('Javascript code and/or AJAX requests are not ready after ' .
870                self::get_extended_timeout() .
871                ' seconds. There is a Javascript error or the code is extremely slow (' . $pending .
872                '). If you are using a slow machine, consider setting $CFG->behat_increasetimeout.');
873    }
874
875    /**
876     * Internal step definition to find exceptions, debugging() messages and PHP debug messages.
877     *
878     * Part of behat_hooks class as is part of the testing framework, is auto-executed
879     * after each step so no features will splicitly use it.
880     *
881     * @throws Exception Unknown type, depending on what we caught in the hook or basic \Exception.
882     * @see Moodle\BehatExtension\Tester\MoodleStepTester
883     */
884    public function look_for_exceptions() {
885        // Wrap in try in case we were interacting with a closed window.
886        try {
887
888            // Exceptions.
889            $exceptionsxpath = "//div[@data-rel='fatalerror']";
890            // Debugging messages.
891            $debuggingxpath = "//div[@data-rel='debugging']";
892            // PHP debug messages.
893            $phperrorxpath = "//div[@data-rel='phpdebugmessage']";
894            // Any other backtrace.
895            $othersxpath = "(//*[contains(., ': call to ')])[1]";
896
897            $xpaths = array($exceptionsxpath, $debuggingxpath, $phperrorxpath, $othersxpath);
898            $joinedxpath = implode(' | ', $xpaths);
899
900            // Joined xpath expression. Most of the time there will be no exceptions, so this pre-check
901            // is faster than to send the 4 xpath queries for each step.
902            if (!$this->getSession()->getDriver()->find($joinedxpath)) {
903                // Check if we have recorded any errors in driver process.
904                $phperrors = behat_get_shutdown_process_errors();
905                if (!empty($phperrors)) {
906                    foreach ($phperrors as $error) {
907                        $errnostring = behat_get_error_string($error['type']);
908                        $msgs[] = $errnostring . ": " .$error['message'] . " at " . $error['file'] . ": " . $error['line'];
909                    }
910                    $msg = "PHP errors found:\n" . implode("\n", $msgs);
911                    throw new \Exception(htmlentities($msg));
912                }
913
914                return;
915            }
916
917            // Exceptions.
918            if ($errormsg = $this->getSession()->getPage()->find('xpath', $exceptionsxpath)) {
919
920                // Getting the debugging info and the backtrace.
921                $errorinfoboxes = $this->getSession()->getPage()->findAll('css', 'div.alert-error');
922                // If errorinfoboxes is empty, try find alert-danger (bootstrap4) class.
923                if (empty($errorinfoboxes)) {
924                    $errorinfoboxes = $this->getSession()->getPage()->findAll('css', 'div.alert-danger');
925                }
926                // If errorinfoboxes is empty, try find notifytiny (original) class.
927                if (empty($errorinfoboxes)) {
928                    $errorinfoboxes = $this->getSession()->getPage()->findAll('css', 'div.notifytiny');
929                }
930
931                // If errorinfoboxes is empty, try find ajax/JS exception in dialogue.
932                if (empty($errorinfoboxes)) {
933                    $errorinfoboxes = $this->getSession()->getPage()->findAll('css', 'div.moodle-exception-message');
934
935                    // If ajax/JS exception.
936                    if ($errorinfoboxes) {
937                        $errorinfo = $this->get_debug_text($errorinfoboxes[0]->getHtml());
938                    }
939
940                } else {
941                    $errorinfo = $this->get_debug_text($errorinfoboxes[0]->getHtml()) . "\n" .
942                        $this->get_debug_text($errorinfoboxes[1]->getHtml());
943                }
944
945                $msg = "Moodle exception: " . $errormsg->getText() . "\n" . $errorinfo;
946                throw new \Exception(html_entity_decode($msg));
947            }
948
949            // Debugging messages.
950            if ($debuggingmessages = $this->getSession()->getPage()->findAll('xpath', $debuggingxpath)) {
951                $msgs = array();
952                foreach ($debuggingmessages as $debuggingmessage) {
953                    $msgs[] = $this->get_debug_text($debuggingmessage->getHtml());
954                }
955                $msg = "debugging() message/s found:\n" . implode("\n", $msgs);
956                throw new \Exception(html_entity_decode($msg));
957            }
958
959            // PHP debug messages.
960            if ($phpmessages = $this->getSession()->getPage()->findAll('xpath', $phperrorxpath)) {
961
962                $msgs = array();
963                foreach ($phpmessages as $phpmessage) {
964                    $msgs[] = $this->get_debug_text($phpmessage->getHtml());
965                }
966                $msg = "PHP debug message/s found:\n" . implode("\n", $msgs);
967                throw new \Exception(html_entity_decode($msg));
968            }
969
970            // Any other backtrace.
971            // First looking through xpath as it is faster than get and parse the whole page contents,
972            // we get the contents and look for matches once we found something to suspect that there is a backtrace.
973            if ($this->getSession()->getDriver()->find($othersxpath)) {
974                $backtracespattern = '/(line [0-9]* of [^:]*: call to [\->&;:a-zA-Z_\x7f-\xff][\->&;:a-zA-Z0-9_\x7f-\xff]*)/';
975                if (preg_match_all($backtracespattern, $this->getSession()->getPage()->getContent(), $backtraces)) {
976                    $msgs = array();
977                    foreach ($backtraces[0] as $backtrace) {
978                        $msgs[] = $backtrace . '()';
979                    }
980                    $msg = "Other backtraces found:\n" . implode("\n", $msgs);
981                    throw new \Exception(htmlentities($msg));
982                }
983            }
984
985        } catch (NoSuchWindowException $e) {
986            // If we were interacting with a popup window it will not exists after closing it.
987        } catch (DriverException $e) {
988            // Same reason as above.
989        }
990    }
991
992    /**
993     * Converts HTML tags to line breaks to display the info in CLI
994     *
995     * @param string $html
996     * @return string
997     */
998    protected function get_debug_text($html) {
999
1000        // Replacing HTML tags for new lines and keeping only the text.
1001        $notags = preg_replace('/<+\s*\/*\s*([A-Z][A-Z0-9]*)\b[^>]*\/*\s*>*/i', "\n", $html);
1002        return preg_replace("/(\n)+/s", "\n", $notags);
1003    }
1004
1005    /**
1006     * Helper function to execute api in a given context.
1007     *
1008     * @param string $contextapi context in which api is defined.
1009     * @param array $params list of params to pass.
1010     * @throws Exception
1011     */
1012    protected function execute($contextapi, $params = array()) {
1013        if (!is_array($params)) {
1014            $params = array($params);
1015        }
1016
1017        // Get required context and execute the api.
1018        $contextapi = explode("::", $contextapi);
1019        $context = behat_context_helper::get($contextapi[0]);
1020        call_user_func_array(array($context, $contextapi[1]), $params);
1021
1022        // NOTE: Wait for pending js and look for exception are not optional, as this might lead to unexpected results.
1023        // Don't make them optional for performance reasons.
1024
1025        // Wait for pending js.
1026        $this->wait_for_pending_js();
1027
1028        // Look for exceptions.
1029        $this->look_for_exceptions();
1030    }
1031
1032    /**
1033     * Get the actual user in the behat session (note $USER does not correspond to the behat session's user).
1034     * @return mixed
1035     * @throws coding_exception
1036     */
1037    protected function get_session_user() {
1038        global $DB;
1039
1040        $sid = $this->getSession()->getCookie('MoodleSession');
1041        if (empty($sid)) {
1042            throw new coding_exception('failed to get moodle session');
1043        }
1044        $userid = $DB->get_field('sessions', 'userid', ['sid' => $sid]);
1045        if (empty($userid)) {
1046            throw new coding_exception('failed to get user from seession id '.$sid);
1047        }
1048        return $DB->get_record('user', ['id' => $userid]);
1049    }
1050
1051    /**
1052     * Set current $USER, reset access cache.
1053     *
1054     * In some cases, behat will execute the code as admin but in many cases we need to set an specific user as some
1055     * API's might rely on the logged user to take some action.
1056     *
1057     * @param null|int|stdClass $user user record, null or 0 means non-logged-in, positive integer means userid
1058     */
1059    public static function set_user($user = null) {
1060        global $DB;
1061
1062        if (is_object($user)) {
1063            $user = clone($user);
1064        } else if (!$user) {
1065            // Assign valid data to admin user (some generator-related code needs a valid user).
1066            $user = $DB->get_record('user', array('username' => 'admin'));
1067        } else {
1068            $user = $DB->get_record('user', array('id' => $user));
1069        }
1070        unset($user->description);
1071        unset($user->access);
1072        unset($user->preference);
1073
1074        // Ensure session is empty, as it may contain caches and user specific info.
1075        \core\session\manager::init_empty_session();
1076
1077        \core\session\manager::set_user($user);
1078    }
1079
1080    /**
1081     * Gets the internal moodle context id from the context reference.
1082     *
1083     * The context reference changes depending on the context
1084     * level, it can be the system, a user, a category, a course or
1085     * a module.
1086     *
1087     * @throws Exception
1088     * @param string $levelname The context level string introduced by the test writer
1089     * @param string $contextref The context reference introduced by the test writer
1090     * @return context
1091     */
1092    public static function get_context(string $levelname, string $contextref): context {
1093        global $DB;
1094
1095        // Getting context levels and names (we will be using the English ones as it is the test site language).
1096        $contextlevels = context_helper::get_all_levels();
1097        $contextnames = array();
1098        foreach ($contextlevels as $level => $classname) {
1099            $contextnames[context_helper::get_level_name($level)] = $level;
1100        }
1101
1102        if (empty($contextnames[$levelname])) {
1103            throw new Exception('The specified "' . $levelname . '" context level does not exist');
1104        }
1105        $contextlevel = $contextnames[$levelname];
1106
1107        // Return it, we don't need to look for other internal ids.
1108        if ($contextlevel == CONTEXT_SYSTEM) {
1109            return context_system::instance();
1110        }
1111
1112        switch ($contextlevel) {
1113
1114            case CONTEXT_USER:
1115                $instanceid = $DB->get_field('user', 'id', array('username' => $contextref));
1116                break;
1117
1118            case CONTEXT_COURSECAT:
1119                $instanceid = $DB->get_field('course_categories', 'id', array('idnumber' => $contextref));
1120                break;
1121
1122            case CONTEXT_COURSE:
1123                $instanceid = $DB->get_field('course', 'id', array('shortname' => $contextref));
1124                break;
1125
1126            case CONTEXT_MODULE:
1127                $instanceid = $DB->get_field('course_modules', 'id', array('idnumber' => $contextref));
1128                break;
1129
1130            default:
1131                break;
1132        }
1133
1134        $contextclass = $contextlevels[$contextlevel];
1135        if (!$context = $contextclass::instance($instanceid, IGNORE_MISSING)) {
1136            throw new Exception('The specified "' . $contextref . '" context reference does not exist');
1137        }
1138
1139        return $context;
1140    }
1141
1142    /**
1143     * Trigger click on node via javascript instead of actually clicking on it via pointer.
1144     *
1145     * This function resolves the issue of nested elements with click listeners or links - in these cases clicking via
1146     * the pointer may accidentally cause a click on the wrong element.
1147     * Example of issue: clicking to expand navigation nodes when the config value linkadmincategories is enabled.
1148     * @param NodeElement $node
1149     */
1150    protected function js_trigger_click($node) {
1151        if (!$this->running_javascript()) {
1152            $node->click();
1153        }
1154        $driver = $this->getSession()->getDriver();
1155        if ($driver instanceof \Moodle\BehatExtension\Driver\WebDriver) {
1156            $this->execute_js_on_node($node, '{{ELEMENT}}.click();');
1157        } else {
1158            $this->ensure_node_is_visible($node); // Ensures hidden elements can't be clicked.
1159            $driver->click($node->getXpath());
1160        }
1161    }
1162
1163    /**
1164     * Execute JS on the specified NodeElement.
1165     *
1166     * @param NodeElement $node
1167     * @param string $script
1168     * @param bool $async
1169     */
1170    protected function execute_js_on_node(NodeElement $node, string $script, bool $async = false): void {
1171        $driver = $this->getSession()->getDriver();
1172        if (!($driver instanceof \Moodle\BehatExtension\Driver\WebDriver)) {
1173            throw new \coding_exception('Unknown driver');
1174        }
1175
1176        if (preg_match('/^function[\s\(]/', $script)) {
1177            $script = preg_replace('/;$/', '', $script);
1178            $script = '(' . $script . ')';
1179        }
1180
1181        $script = str_replace('{{ELEMENT}}', 'arguments[0]', $script);
1182
1183        $webdriver = $driver->getWebDriver();
1184
1185        $element = $this->get_webdriver_element_from_node_element($node);
1186        if ($async) {
1187            try {
1188                $webdriver->executeAsyncScript($script, [$element]);
1189            } catch (ScriptTimeoutException $e) {
1190                throw new DriverException($e->getMessage(), $e->getCode(), $e);
1191            }
1192        } else {
1193            $webdriver->executeScript($script, [$element]);
1194        }
1195    }
1196
1197    /**
1198     * Translate a Mink NodeElement into a WebDriver Element.
1199     *
1200     * @param NodeElement $node
1201     * @return WebDriverElement
1202     */
1203    protected function get_webdriver_element_from_node_element(NodeElement $node): WebDriverElement {
1204        return $this->getSession()
1205            ->getDriver()
1206            ->getWebDriver()
1207            ->findElement(WebDriverBy::xpath($node->getXpath()));
1208    }
1209
1210    /**
1211     * Convert page names to URLs for steps like 'When I am on the "[page name]" page'.
1212     *
1213     * You should override this as appropriate for your plugin. The method
1214     * {@link behat_navigation::resolve_core_page_url()} is a good example.
1215     *
1216     * Your overridden method should document the recognised page types with
1217     * a table like this:
1218     *
1219     * Recognised page names are:
1220     * | Page            | Description                                                    |
1221     *
1222     * @param string $page name of the page, with the component name removed e.g. 'Admin notification'.
1223     * @return moodle_url the corresponding URL.
1224     * @throws Exception with a meaningful error message if the specified page cannot be found.
1225     */
1226    protected function resolve_page_url(string $page): moodle_url {
1227        throw new Exception('Component "' . get_class($this) .
1228                '" does not support the generic \'When I am on the "' . $page .
1229                '" page\' navigation step.');
1230    }
1231
1232    /**
1233     * Convert page names to URLs for steps like 'When I am on the "[identifier]" "[page type]" page'.
1234     *
1235     * A typical example might be:
1236     *     When I am on the "Test quiz" "mod_quiz > Responses report" page
1237     * which would cause this method in behat_mod_quiz to be called with
1238     * arguments 'Responses report', 'Test quiz'.
1239     *
1240     * You should override this as appropriate for your plugin. The method
1241     * {@link behat_navigation::resolve_core_page_instance_url()} is a good example.
1242     *
1243     * Your overridden method should document the recognised page types with
1244     * a table like this:
1245     *
1246     * Recognised page names are:
1247     * | Type      | identifier meaning | Description                                     |
1248     *
1249     * @param string $type identifies which type of page this is, e.g. 'Attempt review'.
1250     * @param string $identifier identifies the particular page, e.g. 'Test quiz > student > Attempt 1'.
1251     * @return moodle_url the corresponding URL.
1252     * @throws Exception with a meaningful error message if the specified page cannot be found.
1253     */
1254    protected function resolve_page_instance_url(string $type, string $identifier): moodle_url {
1255        throw new Exception('Component "' . get_class($this) .
1256                '" does not support the generic \'When I am on the "' . $identifier .
1257                '" "' . $type . '" page\' navigation step.');
1258    }
1259
1260    /**
1261     * Gets the required timeout in seconds.
1262     *
1263     * @param int $timeout One of the TIMEOUT constants
1264     * @return int Actual timeout (in seconds)
1265     */
1266    protected static function get_real_timeout(int $timeout) : int {
1267        global $CFG;
1268        if (!empty($CFG->behat_increasetimeout)) {
1269            return $timeout * $CFG->behat_increasetimeout;
1270        } else {
1271            return $timeout;
1272        }
1273    }
1274
1275    /**
1276     * Gets the default timeout.
1277     *
1278     * The timeout for each Behat step (load page, wait for an element to load...).
1279     *
1280     * @return int Timeout in seconds
1281     */
1282    public static function get_timeout() : int {
1283        return self::get_real_timeout(6);
1284    }
1285
1286    /**
1287     * Gets the reduced timeout.
1288     *
1289     * A reduced timeout for cases where self::get_timeout() is too much
1290     * and a simple $this->getSession()->getPage()->find() could not
1291     * be enough.
1292     *
1293     * @return int Timeout in seconds
1294     */
1295    public static function get_reduced_timeout() : int {
1296        return self::get_real_timeout(2);
1297    }
1298
1299    /**
1300     * Gets the extended timeout.
1301     *
1302     * A longer timeout for cases where the normal timeout is not enough.
1303     *
1304     * @return int Timeout in seconds
1305     */
1306    public static function get_extended_timeout() : int {
1307        return self::get_real_timeout(10);
1308    }
1309
1310    /**
1311     * Return a list of the exact named selectors for the component.
1312     *
1313     * Named selectors are what make Behat steps like
1314     *   Then I should see "Useful text" in the "General" "fieldset"
1315     * work. Here, "fieldset" is the named selector, and "General" is the locator.
1316     *
1317     * If you override this method in your plugin (e.g. mod_mymod), to define
1318     * new selectors specific to your plugin. For example, if you returned
1319     *   new behat_component_named_selector('Thingy',
1320     *           [".//some/xpath//img[contains(@alt, %locator%)]/.."])
1321     * then
1322     *   Then I should see "Useful text" in the "Whatever" "mod_mymod > Thingy"
1323     * would work.
1324     *
1325     * This method should return a list of {@link behat_component_named_selector} and
1326     * the docs on that class explain how it works.
1327     *
1328     * @return behat_component_named_selector[]
1329     */
1330    public static function get_exact_named_selectors(): array {
1331        return [];
1332    }
1333
1334    /**
1335     * Return a list of the partial named selectors for the component.
1336     *
1337     * Like the exact named selectors above, but the locator only
1338     * needs to match part of the text. For example, the standard
1339     * "button" is a partial selector, so:
1340     *   When I click "Save" "button"
1341     * will activate "Save changes".
1342     *
1343     * @return behat_component_named_selector[]
1344     */
1345    public static function get_partial_named_selectors(): array {
1346        return [];
1347    }
1348
1349    /**
1350     * Return a list of the Mink named replacements for the component.
1351     *
1352     * Named replacements allow you to define parts of an xpath that can be reused multiple times, or in multiple
1353     * xpaths.
1354     *
1355     * This method should return a list of {@link behat_component_named_replacement} and the docs on that class explain
1356     * how it works.
1357     *
1358     * @return behat_component_named_replacement[]
1359     */
1360    public static function get_named_replacements(): array {
1361        return [];
1362    }
1363
1364    /**
1365     * Evaluate the supplied script in the current session, returning the result.
1366     *
1367     * @param string $script
1368     * @return mixed
1369     */
1370    public function evaluate_script(string $script) {
1371        return self::evaluate_script_in_session($this->getSession(), $script);
1372    }
1373
1374    /**
1375     * Evaluate the supplied script in the specified session, returning the result.
1376     *
1377     * @param Session $session
1378     * @param string $script
1379     * @return mixed
1380     */
1381    public static function evaluate_script_in_session(Session $session, string $script) {
1382        self::require_javascript_in_session($session);
1383
1384        return $session->evaluateScript($script);
1385    }
1386
1387    /**
1388     * Execute the supplied script in the current session.
1389     *
1390     * No result will be returned.
1391     *
1392     * @param string $script
1393     */
1394    public function execute_script(string $script): void {
1395        self::execute_script_in_session($this->getSession(), $script);
1396    }
1397
1398    /**
1399     * Excecute the supplied script in the specified session.
1400     *
1401     * No result will be returned.
1402     *
1403     * @param Session $session
1404     * @param string $script
1405     */
1406    public static function execute_script_in_session(Session $session, string $script): void {
1407        self::require_javascript_in_session($session);
1408
1409        $session->executeScript($script);
1410    }
1411
1412    /**
1413     * Get the session key for the current session via Javascript.
1414     *
1415     * @return string
1416     */
1417    public function get_sesskey(): string {
1418        $script = <<<EOF
1419return (function() {
1420if (M && M.cfg && M.cfg.sesskey) {
1421    return M.cfg.sesskey;
1422}
1423return '';
1424})()
1425EOF;
1426
1427        return $this->evaluate_script($script);
1428    }
1429
1430    /**
1431     * Set the timeout factor for the remaining lifetime of the session.
1432     *
1433     * @param   int $factor A multiplication factor to use when calculating the timeout
1434     */
1435    public function set_test_timeout_factor(int $factor = 1): void {
1436        $driver = $this->getSession()->getDriver();
1437
1438        if (!$driver instanceof \OAndreyev\Mink\Driver\WebDriver) {
1439            // This is a feature of the OAndreyev MinkWebDriver.
1440            return;
1441        }
1442
1443        // The standard curl timeout is 30 seconds.
1444        // Use get_real_timeout and multiply by the timeout factor to get the final timeout.
1445        $timeout = self::get_real_timeout(30) * 1000 * $factor;
1446        $driver->getWebDriver()->getCommandExecutor()->setRequestTimeout($timeout);
1447    }
1448
1449    /**
1450     * Get the course category id from an identifier.
1451     *
1452     * The category idnumber, and name are checked.
1453     *
1454     * @param string $identifier
1455     * @return int|null
1456     */
1457    protected function get_category_id(string $identifier): ?int {
1458        global $DB;
1459
1460        $sql = <<<EOF
1461    SELECT id
1462      FROM {course_categories}
1463     WHERE idnumber = :idnumber
1464        OR name = :name
1465EOF;
1466
1467        $result = $DB->get_field_sql($sql, [
1468            'idnumber' => $identifier,
1469            'name' => $identifier,
1470        ]);
1471
1472        return $result ?: null;
1473    }
1474
1475    /**
1476     * Get the course id from an identifier.
1477     *
1478     * The course idnumber, shortname, and fullname are checked.
1479     *
1480     * @param string $identifier
1481     * @return int|null
1482     */
1483    protected function get_course_id(string $identifier): ?int {
1484        global $DB;
1485
1486        $sql = <<<EOF
1487    SELECT id
1488      FROM {course}
1489     WHERE idnumber = :idnumber
1490        OR shortname = :shortname
1491        OR fullname = :fullname
1492EOF;
1493
1494        $result = $DB->get_field_sql($sql, [
1495            'idnumber' => $identifier,
1496            'shortname' => $identifier,
1497            'fullname' => $identifier,
1498        ]);
1499
1500        return $result ?: null;
1501    }
1502
1503    /**
1504     * Get the activity course module id from its idnumber.
1505     *
1506     * Note: Only idnumber is supported here, not name at this time.
1507     *
1508     * @param string $identifier
1509     * @return cm_info|null
1510     */
1511    protected function get_course_module_for_identifier(string $identifier): ?cm_info {
1512        global $DB;
1513
1514        $coursetable = new \core\dml\table('course', 'c', 'c');
1515        $courseselect = $coursetable->get_field_select();
1516        $coursefrom = $coursetable->get_from_sql();
1517
1518        $cmtable = new \core\dml\table('course_modules', 'cm', 'cm');
1519        $cmfrom = $cmtable->get_from_sql();
1520
1521        $sql = <<<EOF
1522    SELECT {$courseselect}, cm.id as cmid
1523      FROM {$cmfrom}
1524INNER JOIN {$coursefrom} ON c.id = cm.course
1525     WHERE cm.idnumber = :idnumber
1526EOF;
1527
1528        $result = $DB->get_record_sql($sql, [
1529            'idnumber' => $identifier,
1530        ]);
1531
1532        if ($result) {
1533            $course = $coursetable->extract_from_result($result);
1534            return get_fast_modinfo($course)->get_cm($result->cmid);
1535        }
1536
1537        return null;
1538    }
1539
1540    /**
1541     * Get a coursemodule from an activity name or idnumber.
1542     *
1543     * @param string $activity
1544     * @param string $identifier
1545     * @return cm_info
1546     */
1547    protected function get_cm_by_activity_name(string $activity, string $identifier): cm_info {
1548        global $DB;
1549
1550        $coursetable = new \core\dml\table('course', 'c', 'c');
1551        $courseselect = $coursetable->get_field_select();
1552        $coursefrom = $coursetable->get_from_sql();
1553
1554        $cmtable = new \core\dml\table('course_modules', 'cm', 'cm');
1555        $cmfrom = $cmtable->get_from_sql();
1556
1557        $acttable = new \core\dml\table($activity, 'a', 'a');
1558        $actselect = $acttable->get_field_select();
1559        $actfrom = $acttable->get_from_sql();
1560
1561        $sql = <<<EOF
1562    SELECT cm.id as cmid, {$courseselect}, {$actselect}
1563      FROM {$cmfrom}
1564INNER JOIN {$coursefrom} ON c.id = cm.course
1565INNER JOIN {modules} m ON m.id = cm.module AND m.name = :modname
1566INNER JOIN {$actfrom} ON cm.instance = a.id
1567     WHERE cm.idnumber = :idnumber OR a.name = :name
1568EOF;
1569
1570        $result = $DB->get_record_sql($sql, [
1571            'modname' => $activity,
1572            'idnumber' => $identifier,
1573            'name' => $identifier,
1574        ], MUST_EXIST);
1575
1576        $course = $coursetable->extract_from_result($result);
1577        $instancedata = $acttable->extract_from_result($result);
1578
1579        return get_fast_modinfo($course)->get_cm($result->cmid);
1580    }
1581}
1582