MDL-60494 mod_lti: Invalid </img>, example context
[moodle.git] / lib / tests / behat / behat_navigation.php
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/>.
17 /**
18  * Navigation steps definitions.
19  *
20  * @package    core
21  * @category   test
22  * @copyright  2012 David MonllaĆ³
23  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
24  */
26 // NOTE: no MOODLE_INTERNAL test here, this file may be required by behat before including /config.php.
28 require_once(__DIR__ . '/../../behat/behat_base.php');
30 use Behat\Mink\Exception\ExpectationException as ExpectationException;
31 use Behat\Mink\Exception\DriverException as DriverException;
32 use Behat\Mink\Exception\ElementNotFoundException as ElementNotFoundException;
34 /**
35  * Steps definitions to navigate through the navigation tree nodes.
36  *
37  * @package    core
38  * @category   test
39  * @copyright  2012 David MonllaĆ³
40  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
41  */
42 class behat_navigation extends behat_base {
44     /**
45      * Helper function to get a navigation nodes text element given its text from within the navigation block.
46      *
47      * This function finds the node with the given text from within the navigation block.
48      * It checks to make sure the node is visible, and then returns it.
49      *
50      * @param string $text
51      * @param bool $branch Set this true if you're only interested in the node if its a branch.
52      * @param null|bool $collapsed Set this to true or false if you want the node to either be collapsed or not.
53      *    If its left as null then we don't worry about it.
54      * @param null|string|Exception|false $exception The exception to throw if the node is not found.
55      * @return \Behat\Mink\Element\NodeElement
56      */
57     protected function get_node_text_node($text, $branch = false, $collapsed = null, $exception = null) {
58         if ($exception === null) {
59             $exception = new ExpectationException('The "' . $text . '" node could not be found', $this->getSession());
60         } else if (is_string($exception)) {
61             $exception = new ExpectationException($exception, $this->getSession());
62         }
64         $nodetextliteral = behat_context_helper::escape($text);
65         $hasblocktree = "[contains(concat(' ', normalize-space(@class), ' '), ' block_tree ')]";
66         $hasbranch = "[contains(concat(' ', normalize-space(@class), ' '), ' branch ')]";
67         $hascollapsed = "p[@aria-expanded='false']";
68         $notcollapsed = "p[@aria-expanded='true']";
69         $match = "[normalize-space(.)={$nodetextliteral}]";
71         // Avoid problems with quotes.
72         $isbranch = ($branch) ? $hasbranch : '';
73         if ($collapsed === true) {
74             $iscollapsed = $hascollapsed;
75         } else if ($collapsed === false) {
76             $iscollapsed = $notcollapsed;
77         } else {
78             $iscollapsed = 'p';
79         }
81         // First check root nodes, it can be a span or link.
82         $xpath  = "//ul{$hasblocktree}/li/{$hascollapsed}{$isbranch}/span{$match}|";
83         $xpath  .= "//ul{$hasblocktree}/li/{$hascollapsed}{$isbranch}/a{$match}|";
85         // Next search for the node containing the text within a link.
86         $xpath .= "//ul{$hasblocktree}//ul/li/{$iscollapsed}{$isbranch}/a{$match}|";
88         // Finally search for the node containing the text within a span.
89         $xpath .= "//ul{$hasblocktree}//ul/li/{$iscollapsed}{$isbranch}/span{$match}";
91         $node = $this->find('xpath', $xpath, $exception);
92         $this->ensure_node_is_visible($node);
93         return $node;
94     }
96     /**
97      * Returns true if the navigation node with the given text is expandable.
98      *
99      * @Given /^navigation node "([^"]*)" should be expandable$/
100      *
101      * @throws ExpectationException
102      * @param string $nodetext
103      * @return bool
104      */
105     public function navigation_node_should_be_expandable($nodetext) {
106         if (!$this->running_javascript()) {
107             // Nodes are only expandable when JavaScript is enabled.
108             return false;
109         }
111         $node = $this->get_node_text_node($nodetext, true);
112         $node = $node->getParent();
113         if ($node->hasClass('emptybranch')) {
114             throw new ExpectationException('The "' . $nodetext . '" node is not expandable', $this->getSession());
115         }
117         return true;
118     }
120     /**
121      * Returns true if the navigation node with the given text is not expandable.
122      *
123      * @Given /^navigation node "([^"]*)" should not be expandable$/
124      *
125      * @throws ExpectationException
126      * @param string $nodetext
127      * @return bool
128      */
129     public function navigation_node_should_not_be_expandable($nodetext) {
130         if (!$this->running_javascript()) {
131             // Nodes are only expandable when JavaScript is enabled.
132             return false;
133         }
135         $node = $this->get_node_text_node($nodetext);
136         $node = $node->getParent();
138         if ($node->hasClass('emptybranch') || $node->hasClass('tree_item')) {
139             return true;
140         }
141         throw new ExpectationException('The "' . $nodetext . '" node is expandable', $this->getSession());
142     }
144     /**
145      * Click on an entry in the user menu.
146      * @Given /^I follow "(?P<nodetext_string>(?:[^"]|\\")*)" in the user menu$/
147      *
148      * @param string $nodetext
149      */
150     public function i_follow_in_the_user_menu($nodetext) {
152         if ($this->running_javascript()) {
153             // The user menu must be expanded when JS is enabled.
154             $xpath = "//div[contains(concat(' ', @class, ' '),  ' usermenu ')]//a[contains(concat(' ', @class, ' '), ' dropdown-toggle ')]";
155             $this->execute("behat_general::i_click_on", array($this->escape($xpath), "xpath_element"));
156         }
158         // Now select the link.
159         // The CSS path is always present, with or without JS.
160         $csspath = ".usermenu .dropdown-menu";
162         $this->execute('behat_general::i_click_on_in_the',
163                 array($nodetext, "link", $csspath, "css_element")
164         );
165     }
167     /**
168      * Expands the selected node of the navigation tree that matches the text.
169      * @Given /^I expand "(?P<nodetext_string>(?:[^"]|\\")*)" node$/
170      *
171      * @throws ExpectationException
172      * @param string $nodetext
173      * @return bool|void
174      */
175     public function i_expand_node($nodetext) {
177         // This step is useless with Javascript disabled as Moodle auto expands
178         // all of tree's nodes; adding this because of scenarios that shares the
179         // same steps with and without Javascript enabled.
180         if (!$this->running_javascript()) {
181             if ($nodetext === get_string('administrationsite')) {
182                 // Administration menu is not loaded by default any more. Click the link to expand.
183                 $this->execute('behat_general::i_click_on_in_the',
184                     array($nodetext, "link", get_string('administration'), "block")
185                 );
186                 return true;
187             }
188             return true;
189         }
191         $node = $this->get_node_text_node($nodetext, true, true, 'The "' . $nodetext . '" node can not be expanded');
192         // Check if the node is a link AND a branch.
193         if (strtolower($node->getTagName()) === 'a') {
194             // We just want to expand the node, we don't want to follow it.
195             $node = $node->getParent();
196         }
197         $node->click();
198     }
200     /**
201      * Collapses the selected node of the navigation tree that matches the text.
202      *
203      * @Given /^I collapse "(?P<nodetext_string>(?:[^"]|\\")*)" node$/
204      * @throws ExpectationException
205      * @param string $nodetext
206      * @return bool|void
207      */
208     public function i_collapse_node($nodetext) {
210         // No collapsible nodes with non-JS browsers.
211         if (!$this->running_javascript()) {
212             return true;
213         }
215         $node = $this->get_node_text_node($nodetext, true, false, 'The "' . $nodetext . '" node can not be collapsed');
216         // Check if the node is a link AND a branch.
217         if (strtolower($node->getTagName()) === 'a') {
218             // We just want to expand the node, we don't want to follow it.
219             $node = $node->getParent();
220         }
221         $node->click();
222     }
224     /**
225      * Finds a node in the Navigation or Administration tree
226      *
227      * @param string $nodetext
228      * @param array $parentnodes
229      * @param string $nodetype node type (link or text)
230      * @return NodeElement|null
231      * @throws ExpectationException when one of the parent nodes is not found
232      */
233     protected function find_node_in_navigation($nodetext, $parentnodes, $nodetype = 'link') {
234         // Site admin is different and needs special treatment.
235         $siteadminstr = get_string('administrationsite');
237         // Create array of all parentnodes.
238         $countparentnode = count($parentnodes);
240         // If JS is disabled and Site administration is not expanded we
241         // should follow it, so all the lower-level nodes are available.
242         if (!$this->running_javascript()) {
243             if ($parentnodes[0] === $siteadminstr) {
244                 // We don't know if there if Site admin is already expanded so
245                 // don't wait, it is non-JS and we already waited for the DOM.
246                 $siteadminlink = $this->getSession()->getPage()->find('named_exact', array('link', "'" . $siteadminstr . "'"));
247                 if ($siteadminlink) {
248                     $siteadminlink->click();
249                 }
250             }
251         }
253         // Get top level node.
254         $node = $this->get_top_navigation_node($parentnodes[0]);
256         // Expand all nodes.
257         for ($i = 0; $i < $countparentnode; $i++) {
258             if ($i > 0) {
259                 // Sub nodes within top level node.
260                 $node = $this->get_navigation_node($parentnodes[$i], $node);
261             }
263             // The p node contains the aria jazz.
264             $pnodexpath = "/p[contains(concat(' ', normalize-space(@class), ' '), ' tree_item ')]";
265             $pnode = $node->find('xpath', $pnodexpath);
267             // Keep expanding all sub-parents if js enabled.
268             if ($pnode && $this->running_javascript() && $pnode->hasAttribute('aria-expanded') &&
269                 ($pnode->getAttribute('aria-expanded') == "false")) {
271                 $this->js_trigger_click($pnode);
273                 // Wait for node to load, if not loaded before.
274                 if ($pnode->hasAttribute('data-loaded') && $pnode->getAttribute('data-loaded') == "false") {
275                     $jscondition = '(document.evaluate("' . $pnode->getXpath() . '", document, null, '.
276                         'XPathResult.ANY_TYPE, null).iterateNext().getAttribute(\'data-loaded\') == "true")';
278                     $this->getSession()->wait(behat_base::get_extended_timeout() * 1000, $jscondition);
279                 }
280             }
281         }
283         // Finally, click on requested node under navigation.
284         $nodetextliteral = behat_context_helper::escape($nodetext);
285         $tagname = ($nodetype === 'link') ? 'a' : 'span';
286         $xpath = "/ul/li/p[contains(concat(' ', normalize-space(@class), ' '), ' tree_item ')]" .
287             "/{$tagname}[normalize-space(.)=" . $nodetextliteral . "]";
288         return $node->find('xpath', $xpath);
289     }
291     /**
292      * Finds a node in the Navigation or Administration tree and clicks on it.
293      *
294      * @param string $nodetext
295      * @param array $parentnodes
296      * @throws ExpectationException
297      */
298     protected function select_node_in_navigation($nodetext, $parentnodes) {
299         $nodetoclick = $this->find_node_in_navigation($nodetext, $parentnodes);
300         // Throw exception if no node found.
301         if (!$nodetoclick) {
302             throw new ExpectationException('Navigation node "' . $nodetext . '" not found under "' .
303                 implode(' > ', $parentnodes) . '"', $this->getSession());
304         }
305         $nodetoclick->click();
306     }
308     /**
309      * Helper function to get top navigation node in tree.
310      *
311      * @throws ExpectationException if note not found.
312      * @param string $nodetext name of top navigation node in tree.
313      * @return NodeElement
314      */
315     protected function get_top_navigation_node($nodetext) {
317         // Avoid problems with quotes.
318         $nodetextliteral = behat_context_helper::escape($nodetext);
319         $exception = new ExpectationException('Top navigation node "' . $nodetext . ' not found in "', $this->getSession());
321         // First find in navigation block.
322         $xpath = "//div[contains(concat(' ', normalize-space(@class), ' '), ' card-text ')]" .
323                 "/ul[contains(concat(' ', normalize-space(@class), ' '), ' block_tree ')]" .
324                 "/li[contains(concat(' ', normalize-space(@class), ' '), ' contains_branch ')]" .
325                 "/ul/li[contains(concat(' ', normalize-space(@class), ' '), ' contains_branch ')]" .
326                 "[p[contains(concat(' ', normalize-space(@class), ' '), ' branch ')]" .
327                 "/*[contains(normalize-space(.), " . $nodetextliteral .")]]" .
328                 "|" .
329                 "//div[contains(concat(' ', normalize-space(@class), ' '), ' card-text ')]/div" .
330                 "/ul[contains(concat(' ', normalize-space(@class), ' '), ' block_tree ')]" .
331                 "/li[p[contains(concat(' ', normalize-space(@class), ' '), ' branch ')]" .
332                 "/*[contains(normalize-space(.), " . $nodetextliteral .")]]";
334         $node = $this->find('xpath', $xpath, $exception);
336         return $node;
337     }
339     /**
340      * Helper function to get sub-navigation node.
341      *
342      * @throws ExpectationException if note not found.
343      * @param string $nodetext node to find.
344      * @param NodeElement $parentnode parent navigation node.
345      * @return NodeElement.
346      */
347     protected function get_navigation_node($nodetext, $parentnode = null) {
349         // Avoid problems with quotes.
350         $nodetextliteral = behat_context_helper::escape($nodetext);
352         $xpath = "/ul/li[contains(concat(' ', normalize-space(@class), ' '), ' contains_branch ')]" .
353             "[child::p[contains(concat(' ', normalize-space(@class), ' '), ' branch ')]" .
354             "/child::span[normalize-space(.)=" . $nodetextliteral ."]]";
355         $node = $parentnode->find('xpath', $xpath);
356         if (!$node) {
357             $xpath = "/ul/li[contains(concat(' ', normalize-space(@class), ' '), ' contains_branch ')]" .
358                 "[child::p[contains(concat(' ', normalize-space(@class), ' '), ' branch ')]" .
359                 "/child::a[normalize-space(.)=" . $nodetextliteral ."]]";
360             $node = $parentnode->find('xpath', $xpath);
361         }
363         if (!$node) {
364             throw new ExpectationException('Sub-navigation node "' . $nodetext . '" not found under "' .
365                 $parentnode->getText() . '"', $this->getSession());
366         }
367         return $node;
368     }
370     /**
371      * Step to open the navigation bar if it is needed.
372      *
373      * The top log in and log out links are hidden when middle or small
374      * size windows (or devices) are used. This step returns a step definition
375      * clicking to expand the navbar if it is hidden.
376      *
377      * @Given /^I expand navigation bar$/
378      */
379     public function get_expand_navbar_step() {
381         // Checking if we need to click the navbar button to show the navigation menu, it
382         // is hidden by default when using clean theme and a medium or small screen size.
384         // The DOM and the JS should be all ready and loaded. Running without spinning
385         // as this is a widely used step and we can not spend time here trying to see
386         // a DOM node that is not always there (at the moment clean is not even the
387         // default theme...).
388         $navbuttonjs = "return (
389             Y.one('.btn-navbar') &&
390             Y.one('.btn-navbar').getComputedStyle('display') !== 'none'
391         )";
393         // Adding an extra click we need to show the 'Log in' link.
394         if (!$this->getSession()->getDriver()->evaluateScript($navbuttonjs)) {
395             return false;
396         }
398         $this->execute('behat_general::i_click_on', array(".btn-navbar", "css_element"));
399     }
401     /**
402      * Go to current page setting item
403      *
404      * This can be used on front page, course, category or modules pages.
405      *
406      * @Given /^I navigate to "(?P<nodetext_string>(?:[^"]|\\")*)" in current page administration$/
407      *
408      * @throws ExpectationException
409      * @param string $nodetext navigation node to click, may contain path, for example "Reports > Overview"
410      * @return void
411      */
412     public function i_navigate_to_in_current_page_administration($nodetext) {
413         $nodelist = array_map('trim', explode('>', $nodetext));
414         $this->select_from_administration_menu($nodelist);
415     }
417     /**
418      * Checks that current page administration contains text
419      *
420      * @Given /^"(?P<element_string>(?:[^"]|\\")*)" "(?P<selector_string>[^"]*)" should exist in current page administration$/
421      *
422      * @throws ExpectationException
423      * @param string $element The locator of the specified selector.
424      *     This may be a path, for example "Subscription mode > Forced subscription"
425      * @param string $selectortype The selector type (link or text)
426      * @return void
427      */
428     public function should_exist_in_current_page_administration($element, $selectortype) {
429         $nodes = array_map('trim', explode('>', $element));
430         $nodetext = end($nodes);
432         // Find administration menu.
433         $menuxpath = $this->find_header_administration_menu() ?: $this->find_page_administration_menu(true);
435         $this->toggle_page_administration_menu($menuxpath);
436         $this->execute('behat_general::should_exist_in_the', [$nodetext, $selectortype, $menuxpath, 'xpath_element']);
437         $this->toggle_page_administration_menu($menuxpath);
438     }
440     /**
441      * Checks that current page administration contains text
442      *
443      * @Given /^"(?P<element_string>(?:[^"]|\\")*)" "(?P<selector_string>[^"]*)" should not exist in current page administration$/
444      *
445      * @throws ExpectationException
446      * @param string $element The locator of the specified selector.
447      *     This may be a path, for example "Subscription mode > Forced subscription"
448      * @param string $selectortype The selector type (link or text)
449      * @return void
450      */
451     public function should_not_exist_in_current_page_administration($element, $selectortype) {
452         $nodes = array_map('trim', explode('>', $element));
453         $nodetext = end($nodes);
455         // Find administration menu.
456         $menuxpath = $this->find_header_administration_menu() ?: $this->find_page_administration_menu();
457         if (!$menuxpath) {
458             // Menu not found, exit.
459             return;
460         }
462         $this->toggle_page_administration_menu($menuxpath);
463         $this->execute('behat_general::should_not_exist_in_the', [$nodetext, $selectortype, $menuxpath, 'xpath_element']);
464         $this->toggle_page_administration_menu($menuxpath);
465     }
467     /**
468      * Go to site administration item
469      *
470      * @Given /^I navigate to "(?P<nodetext_string>(?:[^"]|\\")*)" in site administration$/
471      *
472      * @throws ExpectationException
473      * @param string $nodetext navigation node to click, may contain path, for example "Reports > Overview"
474      * @return void
475      */
476     public function i_navigate_to_in_site_administration($nodetext) {
477         $nodelist = array_map('trim', explode('>', $nodetext));
478         $this->i_select_from_flat_navigation_drawer(get_string('administrationsite'));
479         $this->select_on_administration_page($nodelist);
480     }
482     /**
483      * Opens the current users profile page in edit mode.
484      *
485      * @Given /^I open my profile in edit mode$/
486      * @throws coding_exception
487      * @return void
488      */
489     public function i_open_my_profile_in_edit_mode() {
490         global $USER;
492         $user = $this->get_session_user();
493         $globuser = $USER;
494         $USER = $user; // We need this set to the behat session user so we can call isloggedin.
496         $systemcontext = context_system::instance();
498         $bodynode = $this->find('xpath', 'body');
499         $bodyclass = $bodynode->getAttribute('class');
500         $matches = [];
501         if (preg_match('/(?<=^course-|\scourse-)\d+/', $bodyclass, $matches) && !empty($matches)) {
502             $courseid = intval($matches[0]);
503         } else {
504             $courseid = SITEID;
505         }
507         if (isloggedin() && !isguestuser($user) && !is_mnet_remote_user($user)) {
508             if (is_siteadmin($user) ||  has_capability('moodle/user:update', $systemcontext)) {
509                 $url = new moodle_url('/user/editadvanced.php', array('id' => $user->id, 'course' => SITEID,
510                     'returnto' => 'profile'));
511             } else if (has_capability('moodle/user:editownprofile', $systemcontext)) {
512                 $userauthplugin = false;
513                 if (!empty($user->auth)) {
514                     $userauthplugin = get_auth_plugin($user->auth);
515                 }
516                 if ($userauthplugin && $userauthplugin->can_edit_profile()) {
517                     $url = $userauthplugin->edit_profile_url();
518                     if (empty($url)) {
519                         if (empty($course)) {
520                             $url = new moodle_url('/user/edit.php', array('id' => $user->id, 'returnto' => 'profile'));
521                         } else {
522                             $url = new moodle_url('/user/edit.php', array('id' => $user->id, 'course' => $courseid,
523                                 'returnto' => 'profile'));
524                         }
525                     }
527                 }
528             }
529             $this->getSession()->visit($this->locate_path($url->out_as_local_url()));
530         }
532         // Restore global user variable.
533         $USER = $globuser;
534     }
536     /**
537      * Open a given page, belonging to a plugin or core component.
538      *
539      * The page-type are interpreted by each plugin to work out the
540      * corresponding URL. See the resolve_url method in each class like
541      * behat_mod_forum. That method should document which page types are
542      * recognised, and how the name identifies them.
543      *
544      * For pages belonging to core, the 'core > ' bit is omitted.
545      *
546      * @When I am on the :page page
547      * @param string $page the component and page name.
548      *      E.g. 'Admin notifications' or 'core_user > Preferences'.
549      * @throws Exception if the specified page cannot be determined.
550      */
551     public function i_am_on_page(string $page) {
552         $this->getSession()->visit($this->locate_path(
553                 $this->resolve_page_helper($page)->out_as_local_url()));
554     }
556     /**
557      * Open a given page logged in as a given user.
558      *
559      * This is like the combination
560      *   When I log in as "..."
561      *   And I am on the "..." page
562      * but with the advantage that you go straight to the desired page, without
563      * having to wait for the Dashboard to load.
564      *
565      * @When I am on the :page page logged in as :username
566      * @param string $page the type of page. E.g. 'Admin notifications' or 'core_user > Preferences'.
567      * @param string $username the name of the user to log in as. E.g. 'admin'.
568      * @throws Exception if the specified page cannot be determined.
569      */
570     public function i_am_on_page_logged_in_as(string $page, string $username) {
571         self::execute('behat_auth::i_log_in_as', [$username, $this->resolve_page_helper($page)]);
572     }
574     /**
575      * Helper used by i_am_on_page() and i_am_on_page_logged_in_as().
576      *
577      * @param string $page the type of page. E.g. 'Admin notifications' or 'core_user > Preferences'.
578      * @return moodle_url the corresponding URL.
579      */
580     protected function resolve_page_helper(string $page): moodle_url {
581         list($component, $name) = $this->parse_page_name($page);
582         if ($component === 'core') {
583             return $this->resolve_core_page_url($name);
584         } else {
585             $context = behat_context_helper::get('behat_' . $component);
586             return $context->resolve_page_url($name);
587         }
588     }
590     /**
591      * Parse a full page name like 'Admin notifications' or 'core_user > Preferences'.
592      *
593      * E.g. parsing 'mod_quiz > View' gives ['mod_quiz', 'View'].
594      *
595      * @param string $page the full page name
596      * @return array with two elements, component and page name.
597      */
598     protected function parse_page_name(string $page): array {
599         $dividercount = substr_count($page, ' > ');
600         if ($dividercount === 0) {
601             return ['core', $page];
602         } else if ($dividercount === 1) {
603             list($component, $name) = explode(' > ', $page);
604             if ($component === 'core') {
605                 throw new coding_exception('Do not specify the component "core > ..." for core pages.');
606             }
607             return [$component, $name];
608         } else {
609             throw new coding_exception('The page name most be in the form ' .
610                     '"{page-name}" for core pages, or "{component} > {page-name}" ' .
611                     'for pages belonging to other components. ' .
612                     'For example "Admin notifications" or "mod_quiz > View".');
613         }
614     }
616     /**
617      * Open a given instance of a page, belonging to a plugin or core component.
618      *
619      * The instance identifier and page-type are interpreted by each plugin to
620      * work out the corresponding URL. See the resolve_page_instance_url method
621      * in each class like behat_mod_forum. That method should document which page
622      * types are recognised, and how the name identifies them.
623      *
624      * For pages belonging to core, the 'core > ' bit is omitted.
625      *
626      * @When I am on the :identifier :type page
627      * @param string $identifier identifies the particular page. E.g. 'Test quiz'.
628      * @param string $type the component and page type. E.g. 'mod_quiz > View'.
629      * @throws Exception if the specified page cannot be determined.
630      */
631     public function i_am_on_page_instance(string $identifier, string $type) {
632         $this->getSession()->visit($this->locate_path(
633                 $this->resolve_page_instance_helper($identifier, $type)->out_as_local_url()));
634     }
636     /**
637      * Open a given page logged in as a given user.
638      *
639      * This is like the combination
640      *   When I log in as "..."
641      *   And I am on the "..." "..." page
642      * but with the advantage that you go straight to the desired page, without
643      * having to wait for the Dashboard to load.
644      *
645      * @When I am on the :identifier :type page logged in as :username
646      * @param string $identifier identifies the particular page. E.g. 'Test quiz'.
647      * @param string $type the component and page type. E.g. 'mod_quiz > View'.
648      * @param string $username the name of the user to log in as. E.g. 'student'.
649      * @throws Exception if the specified page cannot be determined.
650      */
651     public function i_am_on_page_instance_logged_in_as(string $identifier,
652             string $type, string $username) {
653         self::execute('behat_auth::i_log_in_as',
654                 [$username, $this->resolve_page_instance_helper($identifier, $type)]);
655     }
657     /**
658      * Helper used by i_am_on_page() and i_am_on_page_logged_in_as().
659      *
660      * @param string $identifier identifies the particular page. E.g. 'Test quiz'.
661      * @param string $pagetype the component and page type. E.g. 'mod_quiz > View'.
662      * @return moodle_url the corresponding URL.
663      */
664     protected function resolve_page_instance_helper(string $identifier, string $pagetype): moodle_url {
665         list($component, $type) = $this->parse_page_name($pagetype);
666         if ($component === 'core') {
667             return $this->resolve_core_page_instance_url($type, $identifier);
668         } else {
669             $context = behat_context_helper::get('behat_' . $component);
670             return $context->resolve_page_instance_url($type, $identifier);
671         }
672     }
674     /**
675      * Convert core page names to URLs for steps like 'When I am on the "[page name]" page'.
676      *
677      * Recognised page names are:
678      * | Homepage            | Homepage (normally dashboard).                                 |
679      * | Admin notifications | Admin notification screen.                                     |
680      *
681      * @param string $name identifies which identifies this page, e.g. 'Homepage', 'Admin notifications'.
682      * @return moodle_url the corresponding URL.
683      * @throws Exception with a meaningful error message if the specified page cannot be found.
684      */
685     protected function resolve_core_page_url(string $name): moodle_url {
686         switch ($name) {
687             case 'Homepage':
688                 return new moodle_url('/');
690             case 'Admin notifications':
691                 return new moodle_url('/admin/');
693             default:
694                 throw new Exception('Unrecognised core page type "' . $name . '."');
695         }
696     }
698     /**
699      * Convert page names to URLs for steps like 'When I am on the "[identifier]" "[page type]" page'.
700      *
701      * Recognised page names are:
702      * | Page type     | Identifier meaning | description                          |
703      * | Category page | category idnumber  | List of courses in that category.    |
704      * | Course        | course shortname   | Main course home pag                 |
705      * | Activity      | activity idnumber  | Start page for that activity         |
706      *
707      * @param string $type identifies which type of page this is, e.g. 'Category page'.
708      * @param string $identifier identifies the particular page, e.g. 'test-cat'.
709      * @return moodle_url the corresponding URL.
710      * @throws Exception with a meaningful error message if the specified page cannot be found.
711      */
712     protected function resolve_core_page_instance_url(string $type, string $identifier): moodle_url {
713         global $DB;
715         switch ($type) {
716             case 'Category page':
717                 $categoryid = $DB->get_field('course_categories', 'id', ['idnumber' => $identifier]);
718                 if (!$categoryid) {
719                     throw new Exception('The specified category with idnumber "' . $identifier . '" does not exist');
720                 }
721                 return new moodle_url('/course/category.php', ['id' => $categoryid]);
723             case 'Course':
724                 $courseid = $DB->get_field_select('course', 'id', 'shortname = ?', [$identifier], IGNORE_MISSING);
725                 if (!$courseid) {
726                     throw new Exception('The specified course with shortname, fullname, or idnumber "' .
727                             $identifier . '" does not exist');
728                 }
729                 return new moodle_url('/course/view.php', ['id' => $courseid]);
731             case 'Activity':
732                 $cm = $DB->get_record('course_modules', ['idnumber' => $identifier], 'id, course', IGNORE_MISSING);
733                 if (!$cm) {
734                     throw new Exception('The specified activity with idnumber "' . $identifier . '" does not exist');
735                 }
736                 $modinfo = get_fast_modinfo($cm->course);
737                 return $modinfo->cms[$cm->id]->url;
739             default:
740                 throw new Exception('Unrecognised core page type "' . $type . '."');
741         }
742     }
744     /**
745      * Opens the course homepage. (Consider using 'I am on the "shortname" "Course" page' step instead.)
746      *
747      * @Given /^I am on "(?P<coursefullname_string>(?:[^"]|\\")*)" course homepage$/
748      * @throws coding_exception
749      * @param string $coursefullname The full name of the course.
750      * @return void
751      */
752     public function i_am_on_course_homepage($coursefullname) {
753         global $DB;
754         $course = $DB->get_record("course", array("fullname" => $coursefullname), 'id', MUST_EXIST);
755         $url = new moodle_url('/course/view.php', ['id' => $course->id]);
756         $this->getSession()->visit($this->locate_path($url->out_as_local_url(false)));
757     }
759     /**
760      * Opens the course homepage with editing mode on.
761      *
762      * @Given /^I am on "(?P<coursefullname_string>(?:[^"]|\\")*)" course homepage with editing mode on$/
763      * @throws coding_exception
764      * @param string $coursefullname The course full name of the course.
765      * @return void
766      */
767     public function i_am_on_course_homepage_with_editing_mode_on($coursefullname) {
768         global $DB;
769         $course = $DB->get_record("course", array("fullname" => $coursefullname), 'id', MUST_EXIST);
770         $url = new moodle_url('/course/view.php', ['id' => $course->id]);
771         $this->getSession()->visit($this->locate_path($url->out_as_local_url(false)));
772         try {
773             $this->execute("behat_forms::press_button", get_string('turneditingon'));
774         } catch (Exception $e) {
775             $this->execute("behat_navigation::i_navigate_to_in_current_page_administration", [get_string('turneditingon')]);
776         }
777     }
779     /**
780      * Opens the flat navigation drawer if it is not already open
781      *
782      * @When /^I open flat navigation drawer$/
783      * @throws ElementNotFoundException Thrown by behat_base::find
784      */
785     public function i_open_flat_navigation_drawer() {
786         if (!$this->running_javascript()) {
787             // Navigation drawer is always open without JS.
788             return;
789         }
790         $xpath = "//button[contains(@data-action,'toggle-drawer')]";
791         $node = $this->find('xpath', $xpath);
792         $expanded = $node->getAttribute('aria-expanded');
793         if ($expanded === 'false') {
794             $node->click();
795             $this->ensure_node_attribute_is_set($node, 'aria-expanded', 'true');
796             $this->wait_for_pending_js();
797         }
798     }
800     /**
801      * Closes the flat navigation drawer if it is open (does nothing if JS disabled)
802      *
803      * @When /^I close flat navigation drawer$/
804      * @throws ElementNotFoundException Thrown by behat_base::find
805      */
806     public function i_close_flat_navigation_drawer() {
807         if (!$this->running_javascript()) {
808             // Navigation drawer can not be closed without JS.
809             return;
810         }
811         $xpath = "//button[contains(@data-action,'toggle-drawer')]";
812         $node = $this->find('xpath', $xpath);
813         $expanded = $node->getAttribute('aria-expanded');
814         if ($expanded === 'true') {
815             $node->click();
816             $this->wait_for_pending_js();
817         }
818     }
820     /**
821      * Clicks link with specified id|title|alt|text in the flat navigation drawer.
822      *
823      * @When /^I select "(?P<link_string>(?:[^"]|\\")*)" from flat navigation drawer$/
824      * @throws ElementNotFoundException Thrown by behat_base::find
825      * @param string $link
826      */
827     public function i_select_from_flat_navigation_drawer($link) {
828         $this->i_open_flat_navigation_drawer();
829         $this->execute('behat_general::i_click_on_in_the', [$link, 'link', '#nav-drawer', 'css_element']);
830     }
832     /**
833      * If we are not on the course main page, click on the course link in the navbar
834      */
835     protected function go_to_main_course_page() {
836         $url = $this->getSession()->getCurrentUrl();
837         if (!preg_match('|/course/view.php\?id=[\d]+$|', $url)) {
838             $this->find('xpath', '//header//div[@id=\'page-navbar\']//a[contains(@href,\'/course/view.php?id=\')]')->click();
839             $this->execute('behat_general::wait_until_the_page_is_ready');
840         }
841     }
843     /**
844      * Finds and clicks a link on the admin page (site administration or course administration)
845      *
846      * @param array $nodelist
847      */
848     protected function select_on_administration_page($nodelist) {
849         $parentnodes = $nodelist;
850         $lastnode = array_pop($parentnodes);
851         $xpath = '//section[@id=\'region-main\']';
853         // Check if there is a separate tab for this submenu of the page. If found go to it.
854         if ($parentnodes) {
855             $tabname = behat_context_helper::escape($parentnodes[0]);
856             $tabxpath = '//ul[@role=\'tablist\']/li/a[contains(normalize-space(.), ' . $tabname . ')]';
857             if ($node = $this->getSession()->getPage()->find('xpath', $tabxpath)) {
858                 if ($this->running_javascript()) {
859                     // Click on the tab and add 'active' tab to the xpath.
860                     $node->click();
861                     $xpath .= '//div[contains(@class,\'active\')]';
862                 } else {
863                     // Add the tab content selector to the xpath.
864                     $tabid = behat_context_helper::escape(ltrim($node->getAttribute('href'), '#'));
865                     $xpath .= '//div[@id = ' . $tabid . ']';
866                 }
867                 array_shift($parentnodes);
868             }
869         }
871         // Find a section with the parent name in it.
872         if ($parentnodes) {
873             // Find the section on the page (links may be repeating in different sections).
874             $section = behat_context_helper::escape($parentnodes[0]);
875             $xpath .= '//div[@class=\'row\' and contains(.,'.$section.')]';
876         }
878         // Find a link and click on it.
879         $linkname = behat_context_helper::escape($lastnode);
880         $xpath .= '//a[contains(normalize-space(.), ' . $linkname . ')]';
881         if (!$node = $this->getSession()->getPage()->find('xpath', $xpath)) {
882             throw new ElementNotFoundException($this->getSession(), 'Link "' . join(' > ', $nodelist) . '"');
883         }
884         $node->click();
885         $this->wait_for_pending_js();
886     }
888     /**
889      * Locates the administration menu in the <header> element and returns its xpath
890      *
891      * @param bool $mustexist if specified throws an exception if menu is not found
892      * @return null|string
893      */
894     protected function find_header_administration_menu($mustexist = false) {
895         $menuxpath = '//header[@id=\'page-header\']//div[contains(@class,\'moodle-actionmenu\')]';
896         if ($mustexist) {
897             $exception = new ElementNotFoundException($this->getSession(), 'Page header administration menu');
898             $this->find('xpath', $menuxpath, $exception);
899         } else if (!$this->getSession()->getPage()->find('xpath', $menuxpath)) {
900             return null;
901         }
902         return $menuxpath;
903     }
905     /**
906      * Locates the administration menu on the page (but not in the header) and returns its xpath
907      *
908      * @param bool $mustexist if specified throws an exception if menu is not found
909      * @return null|string
910      */
911     protected function find_page_administration_menu($mustexist = false) {
912         $menuxpath = '//div[@id=\'region-main-settings-menu\']';
913         if ($mustexist) {
914             $exception = new ElementNotFoundException($this->getSession(), 'Page administration menu');
915             $this->find('xpath', $menuxpath, $exception);
916         } else if (!$this->getSession()->getPage()->find('xpath', $menuxpath)) {
917             return null;
918         }
919         return $menuxpath;
920     }
922     /**
923      * Toggles administration menu
924      *
925      * @param string $menuxpath (optional) xpath to the page administration menu if already known
926      */
927     protected function toggle_page_administration_menu($menuxpath = null) {
928         if (!$menuxpath) {
929             $menuxpath = $this->find_header_administration_menu() ?: $this->find_page_administration_menu();
930         }
931         if ($menuxpath && $this->running_javascript()) {
932             $this->find('xpath', $menuxpath . '//a[@data-toggle=\'dropdown\']')->click();
933             $this->wait_for_pending_js();
934         }
935     }
937     /**
938      * Finds a page edit cog and select an item from it
939      *
940      * If the page edit cog is in the page header and the item is not found there, click "More..." link
941      * and find the item on the course/frontpage administration page
942      *
943      * @param array $nodelist
944      * @throws ElementNotFoundException
945      */
946     protected function select_from_administration_menu($nodelist) {
947         // Find administration menu.
948         if ($menuxpath = $this->find_header_administration_menu()) {
949             $isheader = true;
950         } else {
951             $menuxpath = $this->find_page_administration_menu(true);
952             $isheader = false;
953         }
955         $this->toggle_page_administration_menu($menuxpath);
957         if (!$isheader || count($nodelist) == 1) {
958             $lastnode = end($nodelist);
959             $linkname = behat_context_helper::escape($lastnode);
960             $link = $this->getSession()->getPage()->find('xpath', $menuxpath . '//a[contains(normalize-space(.), ' . $linkname . ')]');
961             if ($link) {
962                 $link->click();
963                 $this->wait_for_pending_js();
964                 return;
965             }
966         }
968         if ($isheader) {
969             // Course administration and Front page administration will have subnodes under "More...".
970             $linkname = behat_context_helper::escape(get_string('morenavigationlinks'));
971             $link = $this->getSession()->getPage()->find('xpath', $menuxpath . '//a[contains(normalize-space(.), ' . $linkname . ')]');
972             if ($link) {
973                 $link->click();
974                 $this->execute('behat_general::wait_until_the_page_is_ready');
975                 $this->select_on_administration_page($nodelist);
976                 return;
977             }
978         }
980         throw new ElementNotFoundException($this->getSession(),
981                 'Link "' . join(' > ', $nodelist) . '" in the current page edit menu"');
982     }
984     /**
985      * Visit a fixture page for testing stuff that is not available in core.
986      *
987      * Please always, to prevent unwanted requests, protect behat fixture files with:
988      *     defined('BEHAT_SITE_RUNNING') || die();
989      *
990      * @Given /^I am on fixture page "(?P<url_string>(?:[^"]|\\")*)"$/
991      * @param string $url local path to fixture page
992      */
993     public function i_am_on_fixture_page($url) {
994         $fixtureregex = '|^/[a-z0-9_\-/]*/tests/behat/fixtures/[a-z0-9_\-]*\.php$|';
995         if (!preg_match($fixtureregex, $url)) {
996             throw new coding_exception("URL {$url} is not a fixture URL");
997         }
998         $this->getSession()->visit($this->locate_path($url));
999     }