From 2735b7ff8d3dbf862dc37767bcaf3695b5037ce8 Mon Sep 17 00:00:00 2001 From: sam marshall Date: Mon, 12 Nov 2018 12:11:06 +0000 Subject: [PATCH 01/27] MDL-63977 Behat: Allow Behat testing of the Moodle mobile app This change allows you to write and run Behat tests that cover the mobile app. These should have the @app tag. They will be run in the Chrome browser using an Ionic server on the local machine. See config-dist.php for configuration settings, or full docs here: https://docs.moodle.org/dev/Acceptance_testing_for_the_mobile_app --- app_behat_runtime.js | 635 +++++++++++++++++++++++++++++++++++++++++++ behat_app.php | 524 +++++++++++++++++++++++++++++++++++ 2 files changed, 1159 insertions(+) create mode 100644 app_behat_runtime.js create mode 100644 behat_app.php diff --git a/app_behat_runtime.js b/app_behat_runtime.js new file mode 100644 index 000000000..ca396a22e --- /dev/null +++ b/app_behat_runtime.js @@ -0,0 +1,635 @@ +(function() { + // Set up the M object - only pending_js is implemented. + window.M = window.M ? window.M : {}; + var M = window.M; + M.util = M.util ? M.util : {}; + M.util.pending_js = M.util.pending_js ? M.util.pending_js : []; // eslint-disable-line camelcase + + /** + * Logs information from this Behat runtime JavaScript, including the time and the 'BEHAT' + * keyword so we can easily filter for it if needed. + * + * @param {string} text Information to log + */ + var log = function(text) { + var now = new Date(); + var nowFormatted = String(now.getHours()).padStart(2, '0') + ':' + + String(now.getMinutes()).padStart(2, '0') + ':' + + String(now.getSeconds()).padStart(2, '0') + '.' + + String(now.getMilliseconds()).padStart(2, '0'); + console.log('BEHAT: ' + nowFormatted + ' ' + text); // eslint-disable-line no-console + }; + + /** + * Run after several setTimeouts to ensure queued events are finished. + * + * @param {function} target function to run + * @param {number} count Number of times to do setTimeout (leave blank for 10) + */ + var runAfterEverything = function(target, count) { + if (count === undefined) { + count = 10; + } + setTimeout(function() { + count--; + if (count == 0) { + target(); + } else { + runAfterEverything(target, count); + } + }, 0); + }; + + /** + * Adds a pending key to the array. + * + * @param {string} key Key to add + */ + var addPending = function(key) { + // Add a special DELAY entry whenever another entry is added. + if (window.M.util.pending_js.length == 0) { + window.M.util.pending_js.push('DELAY'); + } + window.M.util.pending_js.push(key); + + log('PENDING+: ' + window.M.util.pending_js); + }; + + /** + * Removes a pending key from the array. If this would clear the array, the actual clear only + * takes effect after the queued events are finished. + * + * @param {string} key Key to remove + */ + var removePending = function(key) { + // Remove the key immediately. + window.M.util.pending_js = window.M.util.pending_js.filter(function(x) { // eslint-disable-line camelcase + return x !== key; + }); + log('PENDING-: ' + window.M.util.pending_js); + + // If the only thing left is DELAY, then remove that as well, later... + if (window.M.util.pending_js.length === 1) { + runAfterEverything(function() { + // Check there isn't a spinner... + updateSpinner(); + + // Only remove it if the pending array is STILL empty after all that. + if (window.M.util.pending_js.length === 1) { + window.M.util.pending_js = []; // eslint-disable-line camelcase + log('PENDING-: ' + window.M.util.pending_js); + } + }); + } + }; + + /** + * Adds a pending key to the array, but removes it after some setTimeouts finish. + */ + var addPendingDelay = function() { + addPending('...'); + removePending('...'); + }; + + // Override XMLHttpRequest to mark things pending while there is a request waiting. + var realOpen = XMLHttpRequest.prototype.open; + var requestIndex = 0; + XMLHttpRequest.prototype.open = function() { + var index = requestIndex++; + var key = 'httprequest-' + index; + + // Add to the list of pending requests. + addPending(key); + + // Detect when it finishes and remove it from the list. + this.addEventListener('loadend', function() { + removePending(key); + }); + + return realOpen.apply(this, arguments); + }; + + var waitingSpinner = false; + + /** + * Checks if a loading spinner is present and visible; if so, adds it to the pending array + * (and if not, removes it). + */ + var updateSpinner = function() { + var spinner = document.querySelector('span.core-loading-spinner'); + if (spinner && spinner.offsetParent) { + if (!waitingSpinner) { + addPending('spinner'); + waitingSpinner = true; + } + } else { + if (waitingSpinner) { + removePending('spinner'); + waitingSpinner = false; + } + } + }; + + // It would be really beautiful if you could detect CSS transitions and animations, that would + // cover almost everything, but sadly there is no way to do this because the transitionstart + // and animationcancel events are not implemented in Chrome, so we cannot detect either of + // these reliably. Instead, we have to look for any DOM changes and do horrible polling. Most + // of the animations are set to 500ms so we allow it to continue from 500ms after any DOM + // change. + + var recentMutation = false; + var lastMutation; + + /** + * Called from the mutation callback to remove the pending tag after 500ms if nothing else + * gets mutated. + * + * This will be called after 500ms, then every 100ms until there have been no mutation events + * for 500ms. + */ + var pollRecentMutation = function() { + if (Date.now() - lastMutation > 500) { + recentMutation = false; + removePending('dom-mutation'); + } else { + setTimeout(pollRecentMutation, 100); + } + }; + + /** + * Mutation callback, called whenever the DOM is mutated. + */ + var mutationCallback = function() { + lastMutation = Date.now(); + if (!recentMutation) { + recentMutation = true; + addPending('dom-mutation'); + setTimeout(pollRecentMutation, 500); + } + // Also update the spinner presence if needed. + updateSpinner(); + }; + + // Set listener using the mutation callback. + var observer = new MutationObserver(mutationCallback); + observer.observe(document, {attributes: true, childList: true, subtree: true}); + + /** + * Generic shared function to find possible xpath matches within the document, that are visible, + * and then process them using a callback function. + * + * @param {string} xpath Xpath to use + * @param {function} process Callback function that handles each matched node + */ + var findPossibleMatches = function(xpath, process) { + var matches = document.evaluate(xpath, document); + while (true) { + var match = matches.iterateNext(); + if (!match) { + break; + } + // Skip invisible text nodes. + if (!match.offsetParent) { + continue; + } + + process(match); + } + }; + + /** + * Function to find an element based on its text or Aria label. + * + * @param {string} text Text (full or partial) + * @param {string} [near] Optional 'near' text - if specified, must have a single match on page + * @return {HTMLElement} Found element + * @throws {string} Error message beginning 'ERROR:' if something went wrong + */ + var findElementBasedOnText = function(text, near) { + // Find all the elements that contain this text (and don't have a child element that + // contains it - i.e. the most specific elements). + var escapedText = text.replace('"', '""'); + var exactMatches = []; + var anyMatches = []; + findPossibleMatches('//*[contains(normalize-space(.), "' + escapedText + + '") and not(child::*[contains(normalize-space(.), "' + escapedText + '")])]', + function(match) { + // Get the text. Note that innerText returns capitalised values for Android buttons + // for some reason, so we'll have to do a case-insensitive match. + var matchText = match.innerText.trim().toLowerCase(); + + // Let's just check - is this actually a label for something else? If so we will click + // that other thing instead. + var labelId = document.evaluate('string(ancestor-or-self::ion-label[@id][1]/@id)', match).stringValue; + if (labelId) { + var target = document.querySelector('*[aria-labelledby=' + labelId + ']'); + if (target) { + match = target; + } + } + + // Add to array depending on if it's an exact or partial match. + if (matchText === text.toLowerCase()) { + exactMatches.push(match); + } else { + anyMatches.push(match); + } + }); + + // Find all the Aria labels that contain this text. + var exactLabelMatches = []; + var anyLabelMatches = []; + findPossibleMatches('//*[@aria-label and contains(@aria-label, "' + escapedText + + '")]', function(match) { + // Add to array depending on if it's an exact or partial match. + if (match.getAttribute('aria-label').trim() === text) { + exactLabelMatches.push(match); + } else { + anyLabelMatches.push(match); + } + }); + + // If the 'near' text is set, use it to filter results. + var nearAncestors = []; + if (near !== undefined) { + escapedText = near.replace('"', '""'); + var exactNearMatches = []; + var anyNearMatches = []; + findPossibleMatches('//*[contains(normalize-space(.), "' + escapedText + + '") and not(child::*[contains(normalize-space(.), "' + escapedText + + '")])]', function(match) { + // Get the text. + var matchText = match.innerText.trim(); + + // Add to array depending on if it's an exact or partial match. + if (matchText === text) { + exactNearMatches.push(match); + } else { + anyNearMatches.push(match); + } + }); + + var nearFound = null; + + // If there is an exact text match, use that (regardless of other matches). + if (exactNearMatches.length > 1) { + throw new Error('Too many exact matches for near text'); + } else if (exactNearMatches.length) { + nearFound = exactNearMatches[0]; + } + + if (nearFound === null) { + // If there is one partial text match, use that. + if (anyNearMatches.length > 1) { + throw new Error('Too many partial matches for near text'); + } else if (anyNearMatches.length) { + nearFound = anyNearMatches[0]; + } + } + + if (!nearFound) { + throw new Error('No matches for near text'); + } + + while (nearFound) { + nearAncestors.push(nearFound); + nearFound = nearFound.parentNode; + } + + /** + * Checks the number of steps up the tree from a specified node before getting to an + * ancestor of the 'near' item + * + * @param {HTMLElement} node HTML node + * @returns {number} Number of steps up, or Number.MAX_SAFE_INTEGER if it never matched + */ + var calculateNearDepth = function(node) { + var depth = 0; + while (node) { + if (nearAncestors.indexOf(node) !== -1) { + return depth; + } + node = node.parentNode; + depth++; + } + return Number.MAX_SAFE_INTEGER; + }; + + /** + * Reduces an array to include only the nearest in each category. + * + * @param {Array} arr Array to + * @return {Array} Array including only the items with minimum 'near' depth + */ + var filterNonNearest = function(arr) { + var nearDepth = arr.map(function(node) { + return calculateNearDepth(node); + }); + var minDepth = Math.min.apply(null, nearDepth); + return arr.filter(function(element, index) { + return nearDepth[index] == minDepth; + }); + }; + + // Filter all the category arrays. + exactMatches = filterNonNearest(exactMatches); + exactLabelMatches = filterNonNearest(exactLabelMatches); + anyMatches = filterNonNearest(anyMatches); + anyLabelMatches = filterNonNearest(anyLabelMatches); + } + + // Select the resulting match. Note this 'do' loop is not really a loop, it is just so we + // can easily break out of it as soon as we find a match. + var found = null; + do { + // If there is an exact text match, use that (regardless of other matches). + if (exactMatches.length > 1) { + throw new Error('Too many exact matches for text'); + } else if (exactMatches.length) { + found = exactMatches[0]; + break; + } + + // If there is an exact label match, use that. + if (exactLabelMatches.length > 1) { + throw new Error('Too many exact label matches for text'); + } else if (exactLabelMatches.length) { + found = exactLabelMatches[0]; + break; + } + + // If there is one partial text match, use that. + if (anyMatches.length > 1) { + throw new Error('Too many partial matches for text'); + } else if (anyMatches.length) { + found = anyMatches[0]; + break; + } + + // Finally if there is one partial label match, use that. + if (anyLabelMatches.length > 1) { + throw new Error('Too many partial label matches for text'); + } else if (anyLabelMatches.length) { + found = anyLabelMatches[0]; + break; + } + } while (false); + + if (!found) { + throw new Error('No matches for text'); + } + + return found; + }; + + /** + * Function to find and click an app standard button. + * + * @param {string} button Type of button to press + * @return {string} OK if successful, or ERROR: followed by message + */ + window.behatPressStandard = function(button) { + log('Action - Click standard button: ' + button); + var selector; + switch (button) { + case 'back' : + selector = 'ion-navbar > button.back-button-md'; + break; + case 'main menu' : + selector = 'page-core-mainmenu .tab-button > ion-icon[aria-label=more]'; + break; + case 'page menu' : + selector = 'core-context-menu > button[aria-label=Info]'; + break; + default: + return 'ERROR: Unsupported standard button type'; + } + var buttons = Array.from(document.querySelectorAll(selector)); + var foundButton = null; + var tooMany = false; + buttons.forEach(function(button) { + if (button.offsetParent) { + if (foundButton === null) { + foundButton = button; + } else { + tooMany = true; + } + } + }); + if (!foundButton) { + return 'ERROR: Could not find button'; + } + if (tooMany) { + return 'ERROR: Found too many buttons'; + } + foundButton.click(); + + // Mark busy until the button click finishes processing. + addPendingDelay(); + + return 'OK'; + }; + + /** + * When there is a popup, clicks on the backdrop. + * + * @return {string} OK if successful, or ERROR: followed by message + */ + window.behatClosePopup = function() { + log('Action - Close popup'); + + var backdrops = Array.from(document.querySelectorAll('ion-backdrop')); + var found = null; + var tooMany = false; + backdrops.forEach(function(backdrop) { + if (backdrop.offsetParent) { + if (found === null) { + found = backdrop; + } else { + tooMany = true; + } + } + }); + if (!found) { + return 'ERROR: Could not find backdrop'; + } + if (tooMany) { + return 'ERROR: Found too many backdrops'; + } + found.click(); + + // Mark busy until the click finishes processing. + addPendingDelay(); + + return 'OK'; + }; + + /** + * Function to press arbitrary item based on its text or Aria label. + * + * @param {string} text Text (full or partial) + * @param {string} near Optional 'near' text - if specified, must have a single match on page + * @return {string} OK if successful, or ERROR: followed by message + */ + window.behatPress = function(text, near) { + log('Action - Press ' + text + (near === undefined ? '' : ' - near ' + near)); + + var found; + try { + found = findElementBasedOnText(text, near); + } catch (error) { + return 'ERROR: ' + error.message; + } + + // Simulate a mouse click on the button. + found.scrollIntoView(); + var rect = found.getBoundingClientRect(); + var eventOptions = {clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2, + bubbles: true, view: window, cancelable: true}; + setTimeout(function() { + found.dispatchEvent(new MouseEvent('mousedown', eventOptions)); + }, 0); + setTimeout(function() { + found.dispatchEvent(new MouseEvent('mouseup', eventOptions)); + }, 0); + setTimeout(function() { + found.dispatchEvent(new MouseEvent('click', eventOptions)); + }, 0); + + // Mark busy until the button click finishes processing. + addPendingDelay(); + + return 'OK'; + }; + + /** + * Gets the currently displayed page header. + * + * @return {string} OK: followed by header text if successful, or ERROR: followed by message. + */ + window.behatGetHeader = function() { + log('Action - Get header'); + + var result = null; + var resultCount = 0; + var titles = Array.from(document.querySelectorAll('ion-header ion-title')); + titles.forEach(function(title) { + if (title.offsetParent) { + result = title.innerText.trim(); + resultCount++; + } + }); + + if (resultCount > 1) { + return 'ERROR: Too many possible titles'; + } else if (!resultCount) { + return 'ERROR: No title found'; + } else { + return 'OK:' + result; + } + }; + + /** + * Sets the text of a field to the specified value. + * + * This currently matches fields only based on the placeholder attribute. + * + * @param {string} field Field name + * @param {string} value New value + * @return {string} OK or ERROR: followed by message + */ + window.behatSetField = function(field, value) { + log('Action - Set field ' + field + ' to: ' + value); + + // Find input(s) with given placeholder. + var escapedText = field.replace('"', '""'); + var exactMatches = []; + var anyMatches = []; + findPossibleMatches( + '//input[contains(@placeholder, "' + escapedText + '")] |' + + '//textarea[contains(@placeholder, "' + escapedText + '")] |' + + '//core-rich-text-editor/descendant::div[contains(@data-placeholder-text, "' + + escapedText + '")]', function(match) { + // Add to array depending on if it's an exact or partial match. + var placeholder; + if (match.nodeName === 'DIV') { + placeholder = match.getAttribute('data-placeholder-text'); + } else { + placeholder = match.getAttribute('placeholder'); + } + if (placeholder.trim() === field) { + exactMatches.push(match); + } else { + anyMatches.push(match); + } + }); + + // Select the resulting match. + var found = null; + do { + // If there is an exact text match, use that (regardless of other matches). + if (exactMatches.length > 1) { + return 'ERROR: Too many exact placeholder matches for text'; + } else if (exactMatches.length) { + found = exactMatches[0]; + break; + } + + // If there is one partial text match, use that. + if (anyMatches.length > 1) { + return 'ERROR: Too many partial placeholder matches for text'; + } else if (anyMatches.length) { + found = anyMatches[0]; + break; + } + } while (false); + + if (!found) { + return 'ERROR: No matches for text'; + } + + // Functions to get/set value depending on field type. + var setValue; + var getValue; + switch (found.nodeName) { + case 'INPUT': + case 'TEXTAREA': + setValue = function(text) { + found.value = text; + }; + getValue = function() { + return found.value; + }; + break; + case 'DIV': + setValue = function(text) { + found.innerHTML = text; + }; + getValue = function() { + return found.innerHTML; + }; + break; + } + + // Pretend we have cut and pasted the new text. + var event; + if (getValue() !== '') { + event = new InputEvent('input', {bubbles: true, view: window, cancelable: true, + inputType: 'devareByCut'}); + setTimeout(function() { + setValue(''); + found.dispatchEvent(event); + }, 0); + } + if (value !== '') { + event = new InputEvent('input', {bubbles: true, view: window, cancelable: true, + inputType: 'insertFromPaste', data: value}); + setTimeout(function() { + setValue(value); + found.dispatchEvent(event); + }, 0); + } + + return 'OK'; + }; +})(); diff --git a/behat_app.php b/behat_app.php new file mode 100644 index 000000000..f5d723dd2 --- /dev/null +++ b/behat_app.php @@ -0,0 +1,524 @@ +. + +/** + * Mobile/desktop app steps definitions. + * + * @package core + * @category test + * @copyright 2018 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +// NOTE: no MOODLE_INTERNAL test here, this file may be required by behat before including /config.php. + +require_once(__DIR__ . '/../../behat/behat_base.php'); + +use Behat\Mink\Exception\DriverException; +use Behat\Mink\Exception\ExpectationException; +use Behat\Behat\Hook\Scope\BeforeScenarioScope; + +/** + * Mobile/desktop app steps definitions. + * + * @package core + * @category test + * @copyright 2018 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class behat_app extends behat_base { + /** @var bool True if the current scenario has the app tag */ + protected $apptag = false; + + /** @var stdClass Object with data about launched Ionic instance (if any) */ + protected static $ionicrunning = null; + + /** + * Checks if the current OS is Windows, from the point of view of task-executing-and-killing. + * + * @return bool True if Windows + */ + protected static function is_windows() : bool { + return strtoupper(substr(PHP_OS, 0, 3)) === 'WIN'; + } + + /** + * Checks tags before each scenario. + * + * @BeforeScenario + * @param BeforeScenarioScope $scope Scope information + */ + public function check_tags(BeforeScenarioScope $scope) { + $this->apptag = in_array('app', $scope->getScenario()->getTags()) || + in_array('app', $scope->getFeature()->getTags()); + } + + /** + * Opens the Moodle app in the browser. + * + * Requires JavaScript. + * + * @Given /^I enter the app$/ + * @throws DriverException Issue with configuration or feature file + * @throws dml_exception Problem with Moodle setup + * @throws ExpectationException Problem with resizing window + */ + public function i_enter_the_app() { + // Restart the browser and set its size. + $this->getSession()->restart(); + $this->resize_window('360x720', true); + + // Prepare setup. + $this->check_behat_setup(); + $this->fix_moodle_setup(); + + // Start Ionic server (or use existing one). + $url = $this->start_or_reuse_ionic(); + + // Go to page and prepare browser for app. + $this->prepare_browser($url); + } + + /** + * Checks the Behat setup - tags and configuration. + * + * @throws DriverException + */ + protected function check_behat_setup() { + global $CFG; + + // Check the app tag was set. + if (!$this->apptag) { + throw new DriverException('Requires @app tag on scenario or feature.'); + } + + // Check JavaScript is enabled. + if (!$this->running_javascript()) { + throw new DriverException('The app requires JavaScript.'); + } + + // Check the config settings are defined. + if (empty($CFG->behat_ionicaddress) && empty($CFG->behat_approot)) { + throw new DriverException('$CFG->behat_ionicaddress or $CFG->behat_approot must be defined.'); + } + } + + /** + * Fixes the Moodle admin settings to allow mobile app use (if not already correct). + * + * @throws dml_exception If there is any problem changing Moodle settings + */ + protected function fix_moodle_setup() { + global $CFG, $DB; + + // Configure Moodle settings to enable app web services. + if (!$CFG->enablewebservices) { + set_config('enablewebservices', 1); + } + if (!$CFG->enablemobilewebservice) { + set_config('enablemobilewebservice', 1); + } + + // Add 'Create token' and 'Use REST webservice' permissions to authenticated user role. + $userroleid = $DB->get_field('role', 'id', ['shortname' => 'user']); + $systemcontext = \context_system::instance(); + role_change_permission($userroleid, $systemcontext, 'moodle/webservice:createtoken', CAP_ALLOW); + role_change_permission($userroleid, $systemcontext, 'webservice/rest:use', CAP_ALLOW); + + // Check the value of the 'webserviceprotocols' config option. Due to weird behaviour + // in Behat with regard to config variables that aren't defined in a settings.php, the + // value in $CFG here may reflect a previous run, so get it direct from the database + // instead. + $field = $DB->get_field('config', 'value', ['name' => 'webserviceprotocols'], IGNORE_MISSING); + if (empty($field)) { + $protocols = []; + } else { + $protocols = explode(',', $field); + } + if (!in_array('rest', $protocols)) { + $protocols[] = 'rest'; + set_config('webserviceprotocols', implode(',', $protocols)); + } + + // Enable mobile service. + require_once($CFG->dirroot . '/webservice/lib.php'); + $webservicemanager = new webservice(); + $service = $webservicemanager->get_external_service_by_shortname( + MOODLE_OFFICIAL_MOBILE_SERVICE, MUST_EXIST); + if (!$service->enabled) { + $service->enabled = 1; + $webservicemanager->update_external_service($service); + } + + // If installed, also configure local_mobile plugin to enable additional features service. + $localplugins = core_component::get_plugin_list('local'); + if (array_key_exists('mobile', $localplugins)) { + $service = $webservicemanager->get_external_service_by_shortname( + 'local_mobile', MUST_EXIST); + if (!$service->enabled) { + $service->enabled = 1; + $webservicemanager->update_external_service($service); + } + } + } + + /** + * Starts an Ionic server if necessary, or uses an existing one. + * + * @return string URL to Ionic server + * @throws DriverException If there's a system error starting Ionic + */ + protected function start_or_reuse_ionic() { + global $CFG; + + if (!empty($CFG->behat_ionicaddress)) { + // Use supplied Ionic server which should already be running. + $url = $CFG->behat_ionicaddress; + } else if (self::$ionicrunning) { + // Use existing Ionic instance launched previously. + $url = self::$ionicrunning->url; + } else { + // Open Ionic process in relevant path. + $path = realpath($CFG->behat_approot); + $stderrfile = $CFG->dataroot . '/behat/ionic-stderr.log'; + $prefix = ''; + // Except on Windows, use 'exec' so that we get the pid of the actual Node process + // and not the shell it uses to execute. You can't do exec on Windows; there is a + // bypass_shell option but it is not the same thing and isn't usable here. + if (!self::is_windows()) { + $prefix = 'exec '; + } + $process = proc_open($prefix . 'ionic serve --no-interactive --no-open', + [['pipe', 'r'], ['pipe', 'w'], ['file', $stderrfile, 'w']], $pipes, $path); + if ($process === false) { + throw new DriverException('Error starting Ionic process'); + } + fclose($pipes[0]); + + // Get pid - we will need this to kill the process. + $status = proc_get_status($process); + $pid = $status['pid']; + + // Read data from stdout until the server comes online. + // Note: On Windows it is impossible to read simultaneously from stderr and stdout + // because stream_select and non-blocking I/O don't work on process pipes, so that is + // why stderr was redirected to a file instead. Also, this code is simpler. + $url = null; + $stdoutlog = ''; + while (true) { + $line = fgets($pipes[1], 4096); + if ($line === false) { + break; + } + + $stdoutlog .= $line; + + if (preg_match('~^\s*Local: (http\S*)~', $line, $matches)) { + $url = $matches[1]; + break; + } + } + + // If it failed, close the pipes and the process. + if (!$url) { + fclose($pipes[1]); + proc_close($process); + $logpath = $CFG->dataroot . '/behat/ionic-start.log'; + $stderrlog = file_get_contents($stderrfile); + @unlink($stderrfile); + file_put_contents($logpath, + "Ionic startup log from " . date('c') . + "\n\n----STDOUT----\n$stdoutlog\n\n----STDERR----\n$stderrlog"); + throw new DriverException('Unable to start Ionic. See ' . $logpath); + } + + // Remember the URL, so we can reuse it next time, and other details so we can kill + // the process. + self::$ionicrunning = (object)['url' => $url, 'process' => $process, 'pipes' => $pipes, + 'pid' => $pid]; + } + return $url; + } + + /** + * Closes Ionic (if it was started) at end of test suite. + * + * @AfterSuite + */ + public static function close_ionic() { + if (self::$ionicrunning) { + fclose(self::$ionicrunning->pipes[1]); + + if (self::is_windows()) { + // Using proc_terminate here does not work. It terminates the process but not any + // other processes it might have launched. Instead, we need to use an OS-specific + // mechanism to kill the process and children based on its pid. + exec('taskkill /F /T /PID ' . self::$ionicrunning->pid); + } else { + // On Unix this actually works, although only due to the 'exec' command inserted + // above. + proc_terminate(self::$ionicrunning->process); + } + self::$ionicrunning = null; + } + } + + /** + * Goes to the app page and then sets up some initial JavaScript so we can use it. + * + * @param string $url App URL + * @throws DriverException If the app fails to load properly + */ + protected function prepare_browser(string $url) { + global $CFG; + + // Visit the Ionic URL and wait for it to load. + $this->getSession()->visit($url); + $this->spin( + function($context, $args) { + $title = $context->getSession()->getPage()->find('xpath', '//title'); + if ($title) { + $text = $title->getHtml(); + if ($text === 'Moodle Desktop') { + return true; + } + } + throw new DriverException('Moodle app not found in browser'); + }, false, 30); + + // Run the scripts to install Moodle 'pending' checks. + $this->getSession()->executeScript( + file_get_contents(__DIR__ . '/app_behat_runtime.js')); + + // Wait until the site login field appears OR the main page. + $situation = $this->spin( + function($context, $args) { + $input = $context->getSession()->getPage()->find('xpath', '//input[@name="url"]'); + if ($input) { + return 'login'; + } + $mainmenu = $context->getSession()->getPage()->find('xpath', '//page-core-mainmenu'); + if ($mainmenu) { + return 'mainpage'; + } + throw new DriverException('Moodle app login URL prompt not found'); + }, false, 30); + + // If it's the login page, we automatically fill in the URL and leave it on the user/pass + // page. If it's the main page, we just leave it there. + if ($situation === 'login') { + $this->i_set_the_field_in_the_app('Site address', $CFG->wwwroot); + $this->i_press_in_the_app('Connect!'); + } + + // Continue only after JS finishes. + $this->wait_for_pending_js(); + } + + /** + * Logs in as the given user in the app's login screen. + * + * Must be run from the app login screen (i.e. immediately after first 'I enter the app'). + * + * @Given /^I log in as "(?P(?:[^"]|\\")*)" in the app$/ + * @param string $username Username (and password) + * @throws DriverException If the main page doesn't load + */ + public function i_log_in_as_username_in_the_app(string $username) { + $this->i_set_the_field_in_the_app('Username', $username); + $this->i_set_the_field_in_the_app('Password', $username); + + // Note there are two 'Log in' texts visible (the title and the button) so we have to use + // the 'near' syntax here. + $this->i_press_near_in_the_app('Log in', 'Forgotten'); + + // Wait until the main page appears. + $this->spin( + function($context, $args) { + $mainmenu = $context->getSession()->getPage()->find('xpath', '//page-core-mainmenu'); + if ($mainmenu) { + return 'mainpage'; + } + throw new DriverException('Moodle app main page not loaded after login'); + }, false, 30); + + // Wait for JS to finish as well. + $this->wait_for_pending_js(); + } + + /** + * Presses standard buttons in the app. + * + * @Given /^I press the (?Pback|main menu|page menu) button in the app$/ + * @param string $button Button type + * @throws DriverException If the button push doesn't work + */ + public function i_press_the_standard_button_in_the_app(string $button) { + $this->spin(function($context, $args) use ($button) { + $result = $this->getSession()->evaluateScript('return window.behatPressStandard("' . + $button . '");'); + if ($result !== 'OK') { + throw new DriverException('Error pressing standard button - ' . $result); + } + return true; + }); + $this->wait_for_pending_js(); + } + + /** + * Closes a popup by clicking on the 'backdrop' behind it. + * + * @Given /^I close the popup in the app$/ + * @throws DriverException If there isn't a popup to close + */ + public function i_close_the_popup_in_the_app() { + $this->spin(function($context, $args) { + $result = $this->getSession()->evaluateScript('return window.behatClosePopup();'); + if ($result !== 'OK') { + throw new DriverException('Error closing popup - ' . $result); + } + return true; + }); + $this->wait_for_pending_js(); + } + + /** + * Clicks on / touches something that is visible in the app. + * + * Note it is difficult to use the standard 'click on' or 'press' steps because those do not + * distinguish visible items and the app always has many non-visible items in the DOM. + * + * @Given /^I press "(?P(?:[^"]|\\")*)" in the app$/ + * @param string $text Text identifying click target + * @throws DriverException If the press doesn't work + */ + public function i_press_in_the_app(string $text) { + $this->spin(function($context, $args) use ($text) { + $result = $this->getSession()->evaluateScript('return window.behatPress("' . + addslashes_js($text) . '");'); + if ($result !== 'OK') { + throw new DriverException('Error pressing item - ' . $result); + } + return true; + }); + $this->wait_for_pending_js(); + } + + /** + * Clicks on / touches something that is visible in the app, near some other text. + * + * This is the same as the other step, but when there are multiple matches, it picks the one + * nearest (in DOM terms) the second text. The second text should be an exact match, or a partial + * match that only has one result. + * + * @Given /^I press "(?P(?:[^"]|\\")*)" near "(?P(?:[^"]|\\")*)" in the app$/ + * @param string $text Text identifying click target + * @param string $near Text identifying a nearby unique piece of text + * @throws DriverException If the press doesn't work + */ + public function i_press_near_in_the_app(string $text, string $near) { + $this->spin(function($context, $args) use ($text, $near) { + $result = $this->getSession()->evaluateScript('return window.behatPress("' . + addslashes_js($text) . '", "' . addslashes_js($near) . '");'); + if ($result !== 'OK') { + throw new DriverException('Error pressing item - ' . $result); + } + return true; + }); + $this->wait_for_pending_js(); + } + + /** + * Sets a field to the given text value in the app. + * + * Currently this only works for input fields which must be identified using a partial or + * exact match on the placeholder text. + * + * @Given /^I set the field "(?P(?:[^"]|\\")*)" to "(?P(?:[^"]|\\")*)" in the app$/ + * @param string $field Text identifying field + * @param string $value Value for field + * @throws DriverException If the field set doesn't work + */ + public function i_set_the_field_in_the_app(string $field, string $value) { + $this->spin(function($context, $args) use ($field, $value) { + $result = $this->getSession()->evaluateScript('return window.behatSetField("' . + addslashes_js($field) . '", "' . addslashes_js($value) . '");'); + if ($result !== 'OK') { + throw new DriverException('Error setting field - ' . $result); + } + return true; + }); + $this->wait_for_pending_js(); + } + + /** + * Checks that the current header stripe in the app contains the expected text. + * + * This can be used to see if the app went to the expected page. + * + * @Then /^the header should be "(?P(?:[^"]|\\")*)" in the app$/ + * @param string $text Expected header text + * @throws DriverException If the header can't be retrieved + * @throws ExpectationException If the header text is different to the expected value + */ + public function the_header_should_be_in_the_app(string $text) { + $result = $this->spin(function($context, $args) { + $result = $this->getSession()->evaluateScript('return window.behatGetHeader();'); + if (substr($result, 0, 3) !== 'OK:') { + throw new DriverException('Error getting header - ' . $result); + } + return $result; + }); + $header = substr($result, 3); + if (trim($header) !== trim($text)) { + throw new ExpectationException('The header text was not as expected: \'' . $header . '\'', + $this->getSession()->getDriver()); + } + } + + /** + * Switches to a newly-opened browser tab. + * + * This assumes the app opened a new tab. + * + * @Given /^I switch to the browser tab opened by the app$/ + * @throws DriverException If there aren't exactly 2 tabs open + */ + public function i_switch_to_the_browser_tab_opened_by_the_app() { + $names = $this->getSession()->getWindowNames(); + if (count($names) !== 2) { + throw new DriverException('Expected to see 2 tabs open, not ' . count($names)); + } + $this->getSession()->switchToWindow($names[1]); + } + + /** + * Closes the current browser tab. + * + * This assumes it was opened by the app and you will now get back to the app. + * + * @Given /^I close the browser tab opened by the app$/ + * @throws DriverException If there aren't exactly 2 tabs open + */ + public function i_close_the_browser_tab_opened_by_the_app() { + $names = $this->getSession()->getWindowNames(); + if (count($names) !== 2) { + throw new DriverException('Expected to see 2 tabs open, not ' . count($names)); + } + $this->getSession()->getDriver()->executeScript('window.close()'); + $this->getSession()->switchToWindow($names[0]); + } +} From 5958b9dc1b496c61b5ca744c0e4e94a7e4139b22 Mon Sep 17 00:00:00 2001 From: sam marshall Date: Tue, 15 Jan 2019 14:31:25 +0000 Subject: [PATCH 02/27] MDL-63977 Behat: Upgrade mobile tests to work with 3.6 app version --- app_behat_runtime.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app_behat_runtime.js b/app_behat_runtime.js index ca396a22e..354786e75 100644 --- a/app_behat_runtime.js +++ b/app_behat_runtime.js @@ -399,7 +399,9 @@ selector = 'page-core-mainmenu .tab-button > ion-icon[aria-label=more]'; break; case 'page menu' : - selector = 'core-context-menu > button[aria-label=Info]'; + // This lang string was changed in app version 3.6. + selector = 'core-context-menu > button[aria-label=Info], ' + + 'core-context-menu > button[aria-label=Information]'; break; default: return 'ERROR: Unsupported standard button type'; From ae110772cf43b6c2ca38cbee694392f761080465 Mon Sep 17 00:00:00 2001 From: sam marshall Date: Tue, 15 Jan 2019 14:31:44 +0000 Subject: [PATCH 03/27] MDL-63977 Behat: Fix bug in 'near' calculation --- app_behat_runtime.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app_behat_runtime.js b/app_behat_runtime.js index 354786e75..b07b53e63 100644 --- a/app_behat_runtime.js +++ b/app_behat_runtime.js @@ -306,8 +306,9 @@ var calculateNearDepth = function(node) { var depth = 0; while (node) { - if (nearAncestors.indexOf(node) !== -1) { - return depth; + var ancestorDepth = nearAncestors.indexOf(node); + if (ancestorDepth !== -1) { + return depth + ancestorDepth; } node = node.parentNode; depth++; From e6299d9a5fa034761e880a041d98fa26fd365830 Mon Sep 17 00:00:00 2001 From: sam marshall Date: Tue, 15 Jan 2019 14:54:47 +0000 Subject: [PATCH 04/27] MDL-63977 Behat: Add generic way to get tag list --- behat_app.php | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/behat_app.php b/behat_app.php index f5d723dd2..a211dcef9 100644 --- a/behat_app.php +++ b/behat_app.php @@ -29,7 +29,6 @@ require_once(__DIR__ . '/../../behat/behat_base.php'); use Behat\Mink\Exception\DriverException; use Behat\Mink\Exception\ExpectationException; -use Behat\Behat\Hook\Scope\BeforeScenarioScope; /** * Mobile/desktop app steps definitions. @@ -40,9 +39,6 @@ use Behat\Behat\Hook\Scope\BeforeScenarioScope; * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class behat_app extends behat_base { - /** @var bool True if the current scenario has the app tag */ - protected $apptag = false; - /** @var stdClass Object with data about launched Ionic instance (if any) */ protected static $ionicrunning = null; @@ -55,17 +51,6 @@ class behat_app extends behat_base { return strtoupper(substr(PHP_OS, 0, 3)) === 'WIN'; } - /** - * Checks tags before each scenario. - * - * @BeforeScenario - * @param BeforeScenarioScope $scope Scope information - */ - public function check_tags(BeforeScenarioScope $scope) { - $this->apptag = in_array('app', $scope->getScenario()->getTags()) || - in_array('app', $scope->getFeature()->getTags()); - } - /** * Opens the Moodle app in the browser. * @@ -101,7 +86,7 @@ class behat_app extends behat_base { global $CFG; // Check the app tag was set. - if (!$this->apptag) { + if (!$this->has_tag('app')) { throw new DriverException('Requires @app tag on scenario or feature.'); } From 29bcee1db5f4da92431b07eb82a488e3ea755179 Mon Sep 17 00:00:00 2001 From: sam marshall Date: Tue, 15 Jan 2019 15:12:06 +0000 Subject: [PATCH 05/27] MDL-63977 Behat: Move app startup to before-scenario hook --- behat_app.php | 33 ++++++++++++++++++++------------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/behat_app.php b/behat_app.php index a211dcef9..d307f2027 100644 --- a/behat_app.php +++ b/behat_app.php @@ -42,6 +42,9 @@ class behat_app extends behat_base { /** @var stdClass Object with data about launched Ionic instance (if any) */ protected static $ionicrunning = null; + /** @var string URL for running Ionic server */ + protected $ionicurl = ''; + /** * Checks if the current OS is Windows, from the point of view of task-executing-and-killing. * @@ -51,6 +54,17 @@ class behat_app extends behat_base { return strtoupper(substr(PHP_OS, 0, 3)) === 'WIN'; } + /** + * Called from behat_hooks when a new scenario starts, if it has the app tag. + * + * This updates Moodle configuration and starts Ionic running, if it isn't already. + */ + public function start_scenario() { + $this->check_behat_setup(); + $this->fix_moodle_setup(); + $this->ionicurl = $this->start_or_reuse_ionic(); +} + /** * Opens the Moodle app in the browser. * @@ -62,19 +76,17 @@ class behat_app extends behat_base { * @throws ExpectationException Problem with resizing window */ public function i_enter_the_app() { + // Check the app tag was set. + if (!$this->has_tag('app')) { + throw new DriverException('Requires @app tag on scenario or feature.'); + } + // Restart the browser and set its size. $this->getSession()->restart(); $this->resize_window('360x720', true); - // Prepare setup. - $this->check_behat_setup(); - $this->fix_moodle_setup(); - - // Start Ionic server (or use existing one). - $url = $this->start_or_reuse_ionic(); - // Go to page and prepare browser for app. - $this->prepare_browser($url); + $this->prepare_browser($this->ionicurl); } /** @@ -85,11 +97,6 @@ class behat_app extends behat_base { protected function check_behat_setup() { global $CFG; - // Check the app tag was set. - if (!$this->has_tag('app')) { - throw new DriverException('Requires @app tag on scenario or feature.'); - } - // Check JavaScript is enabled. if (!$this->running_javascript()) { throw new DriverException('The app requires JavaScript.'); From ebe805c8432dfa3bb4f54154bf1f834fa73eb2fa Mon Sep 17 00:00:00 2001 From: sam marshall Date: Tue, 15 Jan 2019 16:51:38 +0000 Subject: [PATCH 06/27] MDL-63977 Behat: Make standard login step work for app as well --- behat_app.php | 47 +++++++++++++++++++++++++++++------------------ 1 file changed, 29 insertions(+), 18 deletions(-) diff --git a/behat_app.php b/behat_app.php index d307f2027..04c63646d 100644 --- a/behat_app.php +++ b/behat_app.php @@ -321,20 +321,18 @@ class behat_app extends behat_base { } /** - * Logs in as the given user in the app's login screen. + * Carries out the login steps for the app, assuming the user is on the app login page. Called + * from behat_auth.php. * - * Must be run from the app login screen (i.e. immediately after first 'I enter the app'). - * - * @Given /^I log in as "(?P(?:[^"]|\\")*)" in the app$/ * @param string $username Username (and password) - * @throws DriverException If the main page doesn't load + * @throws Exception Any error */ - public function i_log_in_as_username_in_the_app(string $username) { + public function login(string $username) { $this->i_set_the_field_in_the_app('Username', $username); $this->i_set_the_field_in_the_app('Password', $username); // Note there are two 'Log in' texts visible (the title and the button) so we have to use - // the 'near' syntax here. + // a 'near' value here. $this->i_press_near_in_the_app('Log in', 'Forgotten'); // Wait until the main page appears. @@ -398,15 +396,7 @@ class behat_app extends behat_base { * @throws DriverException If the press doesn't work */ public function i_press_in_the_app(string $text) { - $this->spin(function($context, $args) use ($text) { - $result = $this->getSession()->evaluateScript('return window.behatPress("' . - addslashes_js($text) . '");'); - if ($result !== 'OK') { - throw new DriverException('Error pressing item - ' . $result); - } - return true; - }); - $this->wait_for_pending_js(); + $this->press($text); } /** @@ -422,9 +412,30 @@ class behat_app extends behat_base { * @throws DriverException If the press doesn't work */ public function i_press_near_in_the_app(string $text, string $near) { + $this->press($text, $near); + } + + /** + * Clicks on / touches something that is visible in the app, near some other text. + * + * If the $near is specified then when there are multiple matches, it picks the one + * nearest (in DOM terms) $near. $near should be an exact match, or a partial match that only + * has one result. + * + * @param behat_base $base Behat context + * @param string $text Text identifying click target + * @param string $near Text identifying a nearby unique piece of text + * @throws DriverException If the press doesn't work + */ + protected function press(string $text, string $near = '') { $this->spin(function($context, $args) use ($text, $near) { - $result = $this->getSession()->evaluateScript('return window.behatPress("' . - addslashes_js($text) . '", "' . addslashes_js($near) . '");'); + if ($near !== '') { + $nearbit = ', "' . addslashes_js($near) . '"'; + } else { + $nearbit = ''; + } + $result = $context->getSession()->evaluateScript('return window.behatPress("' . + addslashes_js($text) . '"' . $nearbit .');'); if ($result !== 'OK') { throw new DriverException('Error pressing item - ' . $result); } From 165c117bcacc47dbe9c3c1b1be02109794f2bc84 Mon Sep 17 00:00:00 2001 From: sam marshall Date: Mon, 11 Feb 2019 16:40:56 +0000 Subject: [PATCH 07/27] MDL-63977 Behat: Rename Ionic config variables for consistency --- behat_app.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/behat_app.php b/behat_app.php index 04c63646d..322d5cf92 100644 --- a/behat_app.php +++ b/behat_app.php @@ -103,8 +103,8 @@ class behat_app extends behat_base { } // Check the config settings are defined. - if (empty($CFG->behat_ionicaddress) && empty($CFG->behat_approot)) { - throw new DriverException('$CFG->behat_ionicaddress or $CFG->behat_approot must be defined.'); + if (empty($CFG->behat_ionic_wwwroot) && empty($CFG->behat_ionic_dirroot)) { + throw new DriverException('$CFG->behat_ionic_wwwroot or $CFG->behat_ionic_dirroot must be defined.'); } } @@ -176,15 +176,15 @@ class behat_app extends behat_base { protected function start_or_reuse_ionic() { global $CFG; - if (!empty($CFG->behat_ionicaddress)) { + if (!empty($CFG->behat_ionic_wwwroot)) { // Use supplied Ionic server which should already be running. - $url = $CFG->behat_ionicaddress; + $url = $CFG->behat_ionic_wwwroot; } else if (self::$ionicrunning) { // Use existing Ionic instance launched previously. $url = self::$ionicrunning->url; } else { // Open Ionic process in relevant path. - $path = realpath($CFG->behat_approot); + $path = realpath($CFG->behat_ionic_dirroot); $stderrfile = $CFG->dataroot . '/behat/ionic-stderr.log'; $prefix = ''; // Except on Windows, use 'exec' so that we get the pid of the actual Node process From c5ce5618abfa310bbbce0698085bbdd10ccfbf95 Mon Sep 17 00:00:00 2001 From: sam marshall Date: Tue, 12 Feb 2019 10:13:51 +0000 Subject: [PATCH 08/27] MDL-63977 Behat: Organise app functions in window.behat object --- app_behat_runtime.js | 19 ++++++++++++++----- behat_app.php | 10 +++++----- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/app_behat_runtime.js b/app_behat_runtime.js index b07b53e63..d88fa92b7 100644 --- a/app_behat_runtime.js +++ b/app_behat_runtime.js @@ -389,7 +389,7 @@ * @param {string} button Type of button to press * @return {string} OK if successful, or ERROR: followed by message */ - window.behatPressStandard = function(button) { + var behatPressStandard = function(button) { log('Action - Click standard button: ' + button); var selector; switch (button) { @@ -438,7 +438,7 @@ * * @return {string} OK if successful, or ERROR: followed by message */ - window.behatClosePopup = function() { + var behatClosePopup = function() { log('Action - Close popup'); var backdrops = Array.from(document.querySelectorAll('ion-backdrop')); @@ -474,7 +474,7 @@ * @param {string} near Optional 'near' text - if specified, must have a single match on page * @return {string} OK if successful, or ERROR: followed by message */ - window.behatPress = function(text, near) { + var behatPress = function(text, near) { log('Action - Press ' + text + (near === undefined ? '' : ' - near ' + near)); var found; @@ -510,7 +510,7 @@ * * @return {string} OK: followed by header text if successful, or ERROR: followed by message. */ - window.behatGetHeader = function() { + var behatGetHeader = function() { log('Action - Get header'); var result = null; @@ -541,7 +541,7 @@ * @param {string} value New value * @return {string} OK or ERROR: followed by message */ - window.behatSetField = function(field, value) { + var behatSetField = function(field, value) { log('Action - Set field ' + field + ' to: ' + value); // Find input(s) with given placeholder. @@ -635,4 +635,13 @@ return 'OK'; }; + + // Make some functions publicly available for Behat to call. + window.behat = { + pressStandard : behatPressStandard, + closePopup : behatClosePopup, + press : behatPress, + setField : behatSetField, + getHeader : behatGetHeader, + }; })(); diff --git a/behat_app.php b/behat_app.php index 322d5cf92..f84b356ee 100644 --- a/behat_app.php +++ b/behat_app.php @@ -358,7 +358,7 @@ class behat_app extends behat_base { */ public function i_press_the_standard_button_in_the_app(string $button) { $this->spin(function($context, $args) use ($button) { - $result = $this->getSession()->evaluateScript('return window.behatPressStandard("' . + $result = $this->getSession()->evaluateScript('return window.behat.pressStandard("' . $button . '");'); if ($result !== 'OK') { throw new DriverException('Error pressing standard button - ' . $result); @@ -376,7 +376,7 @@ class behat_app extends behat_base { */ public function i_close_the_popup_in_the_app() { $this->spin(function($context, $args) { - $result = $this->getSession()->evaluateScript('return window.behatClosePopup();'); + $result = $this->getSession()->evaluateScript('return window.behat.closePopup();'); if ($result !== 'OK') { throw new DriverException('Error closing popup - ' . $result); } @@ -434,7 +434,7 @@ class behat_app extends behat_base { } else { $nearbit = ''; } - $result = $context->getSession()->evaluateScript('return window.behatPress("' . + $result = $context->getSession()->evaluateScript('return window.behat.press("' . addslashes_js($text) . '"' . $nearbit .');'); if ($result !== 'OK') { throw new DriverException('Error pressing item - ' . $result); @@ -457,7 +457,7 @@ class behat_app extends behat_base { */ public function i_set_the_field_in_the_app(string $field, string $value) { $this->spin(function($context, $args) use ($field, $value) { - $result = $this->getSession()->evaluateScript('return window.behatSetField("' . + $result = $this->getSession()->evaluateScript('return window.behat.setField("' . addslashes_js($field) . '", "' . addslashes_js($value) . '");'); if ($result !== 'OK') { throw new DriverException('Error setting field - ' . $result); @@ -479,7 +479,7 @@ class behat_app extends behat_base { */ public function the_header_should_be_in_the_app(string $text) { $result = $this->spin(function($context, $args) { - $result = $this->getSession()->evaluateScript('return window.behatGetHeader();'); + $result = $this->getSession()->evaluateScript('return window.behat.getHeader();'); if (substr($result, 0, 3) !== 'OK:') { throw new DriverException('Error getting header - ' . $result); } From b7804052a836c851d6c5593cbb7a12ffa6033532 Mon Sep 17 00:00:00 2001 From: Andrew Nicols Date: Wed, 13 Feb 2019 13:55:34 +0800 Subject: [PATCH 09/27] MDL-63977 Behat: Prefer ionic approot over wwwroot --- behat_app.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/behat_app.php b/behat_app.php index f84b356ee..b2251c34b 100644 --- a/behat_app.php +++ b/behat_app.php @@ -176,7 +176,7 @@ class behat_app extends behat_base { protected function start_or_reuse_ionic() { global $CFG; - if (!empty($CFG->behat_ionic_wwwroot)) { + if (empty($CFG->behat_ionic_dirroot) && !empty($CFG->behat_ionic_wwwroot)) { // Use supplied Ionic server which should already be running. $url = $CFG->behat_ionic_wwwroot; } else if (self::$ionicrunning) { From a24f9dc9dfb24063dbc2c5963685747a203819e7 Mon Sep 17 00:00:00 2001 From: Andrew Nicols Date: Wed, 13 Feb 2019 14:14:32 +0800 Subject: [PATCH 10/27] MDL-63977 Behat: Wait longer for app login --- behat_app.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/behat_app.php b/behat_app.php index b2251c34b..8a5b23813 100644 --- a/behat_app.php +++ b/behat_app.php @@ -307,7 +307,7 @@ class behat_app extends behat_base { return 'mainpage'; } throw new DriverException('Moodle app login URL prompt not found'); - }, false, 30); + }, self::EXTENDED_TIMEOUT, 30); // If it's the login page, we automatically fill in the URL and leave it on the user/pass // page. If it's the main page, we just leave it there. From e9ea931a48096f9e54d67020dc2d1d9214f4cbf2 Mon Sep 17 00:00:00 2001 From: sam marshall Date: Thu, 28 Feb 2019 17:36:30 +0000 Subject: [PATCH 11/27] MDL-64979 Behat: Add option to increase timeouts --- behat_app.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/behat_app.php b/behat_app.php index 8a5b23813..fee3f56d9 100644 --- a/behat_app.php +++ b/behat_app.php @@ -307,7 +307,7 @@ class behat_app extends behat_base { return 'mainpage'; } throw new DriverException('Moodle app login URL prompt not found'); - }, self::EXTENDED_TIMEOUT, 30); + }, behat_base::get_extended_timeout(), 30); // If it's the login page, we automatically fill in the URL and leave it on the user/pass // page. If it's the main page, we just leave it there. From d6be9e72330af321ec48fa4e72e14cbbdb53071a Mon Sep 17 00:00:00 2001 From: "Kiet.Chan" Date: Tue, 26 Mar 2019 10:53:31 +0700 Subject: [PATCH 12/27] MDL-65137 behat: Step "I press...the app" should be able to select image --- app_behat_runtime.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app_behat_runtime.js b/app_behat_runtime.js index d88fa92b7..08ccc8c79 100644 --- a/app_behat_runtime.js +++ b/app_behat_runtime.js @@ -239,10 +239,11 @@ // Find all the Aria labels that contain this text. var exactLabelMatches = []; var anyLabelMatches = []; - findPossibleMatches('//*[@aria-label and contains(@aria-label, "' + escapedText + - '")]', function(match) { + findPossibleMatches('//*[@aria-label and contains(@aria-label, "' + escapedText + '")]' + + '| //img[@alt and contains(@alt, "' + escapedText + '")]', function(match) { // Add to array depending on if it's an exact or partial match. - if (match.getAttribute('aria-label').trim() === text) { + var attributeData = match.getAttribute('aria-label') || match.getAttribute('alt'); + if (attributeData.trim() === text) { exactLabelMatches.push(match); } else { anyLabelMatches.push(match); From 21803fc7f2b91b0653dbdbf6b8dbdd47bbb6dd59 Mon Sep 17 00:00:00 2001 From: Amaia Anabitarte Date: Fri, 31 May 2019 13:51:17 +0200 Subject: [PATCH 13/27] MDL-65831 behat: New steps to switch offline mode for moodle app behats --- app_behat_runtime.js | 5 ++++- behat_app.php | 11 +++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/app_behat_runtime.js b/app_behat_runtime.js index 08ccc8c79..3c9f3a496 100644 --- a/app_behat_runtime.js +++ b/app_behat_runtime.js @@ -240,9 +240,12 @@ var exactLabelMatches = []; var anyLabelMatches = []; findPossibleMatches('//*[@aria-label and contains(@aria-label, "' + escapedText + '")]' + + '| //a[@title and contains(@title, "' + escapedText + '")]' + '| //img[@alt and contains(@alt, "' + escapedText + '")]', function(match) { // Add to array depending on if it's an exact or partial match. - var attributeData = match.getAttribute('aria-label') || match.getAttribute('alt'); + var attributeData = match.getAttribute('aria-label') || + match.getAttribute('title') || + match.getAttribute('alt'); if (attributeData.trim() === text) { exactLabelMatches.push(match); } else { diff --git a/behat_app.php b/behat_app.php index fee3f56d9..aebbed109 100644 --- a/behat_app.php +++ b/behat_app.php @@ -524,4 +524,15 @@ class behat_app extends behat_base { $this->getSession()->getDriver()->executeScript('window.close()'); $this->getSession()->switchToWindow($names[0]); } + + /** + * Switch navigator online mode. + * + * @Given /^I switch offline mode to "(?P(?:[^"]|\\")*)"$/ + * @param string $offline New value for navigator online mode + * @throws DriverException If the navigator.online mode is not available + */ + public function i_switch_offline_mode(string $offline) { + $this->getSession()->evaluateScript('appProvider.setForceOffline(' . $offline . ');'); + } } From a14f5dea2128d2812be088744b3f8aa6160b6758 Mon Sep 17 00:00:00 2001 From: John Beedell Date: Tue, 15 Oct 2019 17:14:40 +0100 Subject: [PATCH 14/27] MDL-66918 behat: Behat app step failing --- app_behat_runtime.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app_behat_runtime.js b/app_behat_runtime.js index 3c9f3a496..888a1571b 100644 --- a/app_behat_runtime.js +++ b/app_behat_runtime.js @@ -401,7 +401,9 @@ selector = 'ion-navbar > button.back-button-md'; break; case 'main menu' : - selector = 'page-core-mainmenu .tab-button > ion-icon[aria-label=more]'; + // Change in app version 3.8. + selector = 'page-core-mainmenu .tab-button > ion-icon[aria-label=more], ' + + 'page-core-mainmenu .tab-button > ion-icon[aria-label=menu]'; break; case 'page menu' : // This lang string was changed in app version 3.6. From f56fd4a943d638dd1f00a14742b2ffee460ab896 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Fri, 17 Jan 2020 15:30:23 +0100 Subject: [PATCH 15/27] MDL-67727 behat: Reset ionic app properly --- behat_app.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/behat_app.php b/behat_app.php index aebbed109..4996b2797 100644 --- a/behat_app.php +++ b/behat_app.php @@ -85,6 +85,10 @@ class behat_app extends behat_base { $this->getSession()->restart(); $this->resize_window('360x720', true); + if (empty($this->ionicurl)) { + $this->ionicurl = $this->start_or_reuse_ionic(); + } + // Go to page and prepare browser for app. $this->prepare_browser($this->ionicurl); } @@ -241,6 +245,7 @@ class behat_app extends behat_base { // the process. self::$ionicrunning = (object)['url' => $url, 'process' => $process, 'pipes' => $pipes, 'pid' => $pid]; + $url = self::$ionicrunning->url; } return $url; } From 1f04f85599f12fbf24793f6f2ba0a6e1dda88b03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Wed, 22 Jan 2020 09:56:01 +0100 Subject: [PATCH 16/27] MDL-67728 behat: Improve behat app selectors --- app_behat_runtime.js | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/app_behat_runtime.js b/app_behat_runtime.js index 888a1571b..011215751 100644 --- a/app_behat_runtime.js +++ b/app_behat_runtime.js @@ -72,7 +72,7 @@ if (window.M.util.pending_js.length === 1) { runAfterEverything(function() { // Check there isn't a spinner... - updateSpinner(); + checkUIBlocked(); // Only remove it if the pending array is STILL empty after all that. if (window.M.util.pending_js.length === 1) { @@ -109,23 +109,23 @@ return realOpen.apply(this, arguments); }; - var waitingSpinner = false; + var waitingBlocked = false; /** * Checks if a loading spinner is present and visible; if so, adds it to the pending array * (and if not, removes it). */ - var updateSpinner = function() { - var spinner = document.querySelector('span.core-loading-spinner'); - if (spinner && spinner.offsetParent) { - if (!waitingSpinner) { - addPending('spinner'); - waitingSpinner = true; + var checkUIBlocked = function() { + var blocked = document.querySelector('span.core-loading-spinner, ion-loading, .click-block-active'); + if (blocked && blocked.offsetParent) { + if (!waitingBlocked) { + addPending('blocked'); + waitingBlocked = true; } } else { - if (waitingSpinner) { - removePending('spinner'); - waitingSpinner = false; + if (waitingBlocked) { + removePending('blocked'); + waitingBlocked = false; } } }; @@ -167,7 +167,7 @@ setTimeout(pollRecentMutation, 500); } // Also update the spinner presence if needed. - updateSpinner(); + checkUIBlocked(); }; // Set listener using the mutation callback. @@ -182,7 +182,9 @@ * @param {function} process Callback function that handles each matched node */ var findPossibleMatches = function(xpath, process) { - var matches = document.evaluate(xpath, document); + var select = 'ion-alert, ion-popover, ion-action-sheet, core-ion-tab.show-tab ion-page.show-page, ion-page.show-page, html'; + var parent = document.querySelector(select); + var matches = document.evaluate(xpath, parent || document); while (true) { var match = matches.iterateNext(); if (!match) { From 990472e02f80e3fe7b1878e29a296252f785e40f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Tue, 21 Jan 2020 12:43:51 +0100 Subject: [PATCH 17/27] MDL-67728 behat: Increase app start timeout --- behat_app.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/behat_app.php b/behat_app.php index 4996b2797..f82ce5d70 100644 --- a/behat_app.php +++ b/behat_app.php @@ -294,7 +294,7 @@ class behat_app extends behat_base { } } throw new DriverException('Moodle app not found in browser'); - }, false, 30); + }, false, 60); // Run the scripts to install Moodle 'pending' checks. $this->getSession()->executeScript( @@ -312,7 +312,7 @@ class behat_app extends behat_base { return 'mainpage'; } throw new DriverException('Moodle app login URL prompt not found'); - }, behat_base::get_extended_timeout(), 30); + }, behat_base::get_extended_timeout(), 60); // If it's the login page, we automatically fill in the URL and leave it on the user/pass // page. If it's the main page, we just leave it there. From 25bb9dd90664f8136b3478c2f6970aa04292badc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Wed, 20 May 2020 13:29:26 +0200 Subject: [PATCH 18/27] MDL-68789 behat: Adapt login step to new app version --- behat_app.php | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/behat_app.php b/behat_app.php index f82ce5d70..af82844ec 100644 --- a/behat_app.php +++ b/behat_app.php @@ -303,12 +303,22 @@ class behat_app extends behat_base { // Wait until the site login field appears OR the main page. $situation = $this->spin( function($context, $args) { - $input = $context->getSession()->getPage()->find('xpath', '//input[@name="url"]'); - if ($input) { + $page = $context->getSession()->getPage(); + + $element = $page->find('xpath', '//page-core-login-site//input[@name="url"]'); + if ($element) { + // Wait for the onboarding modal to open, if any. + $this->wait_for_pending_js(); + $element = $page->find('xpath', '//page-core-login-site-onboarding'); + if ($element) { + $this->i_press_in_the_app('Skip'); + } + return 'login'; } - $mainmenu = $context->getSession()->getPage()->find('xpath', '//page-core-mainmenu'); - if ($mainmenu) { + + $element = $page->find('xpath', '//page-core-mainmenu'); + if ($element) { return 'mainpage'; } throw new DriverException('Moodle app login URL prompt not found'); @@ -317,7 +327,7 @@ class behat_app extends behat_base { // If it's the login page, we automatically fill in the URL and leave it on the user/pass // page. If it's the main page, we just leave it there. if ($situation === 'login') { - $this->i_set_the_field_in_the_app('Site address', $CFG->wwwroot); + $this->i_set_the_field_in_the_app('campus.example.edu', $CFG->wwwroot); $this->i_press_in_the_app('Connect!'); } From c73a00f6c9ee465d17099d3570aa1b51296d3c97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Mon, 25 May 2020 14:53:18 +0200 Subject: [PATCH 19/27] MDL-68789 behat: Change app page menu text selector --- app_behat_runtime.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app_behat_runtime.js b/app_behat_runtime.js index 011215751..56fdbc3b3 100644 --- a/app_behat_runtime.js +++ b/app_behat_runtime.js @@ -410,7 +410,8 @@ case 'page menu' : // This lang string was changed in app version 3.6. selector = 'core-context-menu > button[aria-label=Info], ' + - 'core-context-menu > button[aria-label=Information]'; + 'core-context-menu > button[aria-label=Information], ' + + 'core-context-menu > button[aria-label="Display options"]'; break; default: return 'ERROR: Unsupported standard button type'; From 1a838bb44182761ba3ea10fd59f1e6a095cc6320 Mon Sep 17 00:00:00 2001 From: Andrew Nicols Date: Thu, 16 Jan 2020 17:13:28 +0800 Subject: [PATCH 20/27] MDL-67657 behat: Convert existing uses of executeScript --- behat_app.php | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/behat_app.php b/behat_app.php index af82844ec..e474ffc67 100644 --- a/behat_app.php +++ b/behat_app.php @@ -297,8 +297,7 @@ class behat_app extends behat_base { }, false, 60); // Run the scripts to install Moodle 'pending' checks. - $this->getSession()->executeScript( - file_get_contents(__DIR__ . '/app_behat_runtime.js')); + $this->execute_script(file_get_contents(__DIR__ . '/app_behat_runtime.js')); // Wait until the site login field appears OR the main page. $situation = $this->spin( @@ -373,8 +372,7 @@ class behat_app extends behat_base { */ public function i_press_the_standard_button_in_the_app(string $button) { $this->spin(function($context, $args) use ($button) { - $result = $this->getSession()->evaluateScript('return window.behat.pressStandard("' . - $button . '");'); + $result = $this->evaluate_script("return window.behat.pressStandard('{$button}');"); if ($result !== 'OK') { throw new DriverException('Error pressing standard button - ' . $result); } @@ -391,7 +389,7 @@ class behat_app extends behat_base { */ public function i_close_the_popup_in_the_app() { $this->spin(function($context, $args) { - $result = $this->getSession()->evaluateScript('return window.behat.closePopup();'); + $result = $this->evaluate_script("return window.behat.closePopup();"); if ($result !== 'OK') { throw new DriverException('Error closing popup - ' . $result); } @@ -449,7 +447,7 @@ class behat_app extends behat_base { } else { $nearbit = ''; } - $result = $context->getSession()->evaluateScript('return window.behat.press("' . + $result = $this->evaluate_script('return window.behat.press("' . addslashes_js($text) . '"' . $nearbit .');'); if ($result !== 'OK') { throw new DriverException('Error pressing item - ' . $result); @@ -472,7 +470,7 @@ class behat_app extends behat_base { */ public function i_set_the_field_in_the_app(string $field, string $value) { $this->spin(function($context, $args) use ($field, $value) { - $result = $this->getSession()->evaluateScript('return window.behat.setField("' . + $result = $this->evaluate_script('return window.behat.setField("' . addslashes_js($field) . '", "' . addslashes_js($value) . '");'); if ($result !== 'OK') { throw new DriverException('Error setting field - ' . $result); @@ -494,7 +492,7 @@ class behat_app extends behat_base { */ public function the_header_should_be_in_the_app(string $text) { $result = $this->spin(function($context, $args) { - $result = $this->getSession()->evaluateScript('return window.behat.getHeader();'); + $result = $this->evaluate_script('return window.behat.getHeader();'); if (substr($result, 0, 3) !== 'OK:') { throw new DriverException('Error getting header - ' . $result); } @@ -536,7 +534,7 @@ class behat_app extends behat_base { if (count($names) !== 2) { throw new DriverException('Expected to see 2 tabs open, not ' . count($names)); } - $this->getSession()->getDriver()->executeScript('window.close()'); + $this->execute_script('window.close()'); $this->getSession()->switchToWindow($names[0]); } @@ -548,6 +546,6 @@ class behat_app extends behat_base { * @throws DriverException If the navigator.online mode is not available */ public function i_switch_offline_mode(string $offline) { - $this->getSession()->evaluateScript('appProvider.setForceOffline(' . $offline . ');'); + $this->execute_script('appProvider.setForceOffline(' . $offline . ');'); } } From fd42bc3ef9f24f2a39e41e92af2755202072e46f Mon Sep 17 00:00:00 2001 From: Andrew Nicols Date: Thu, 25 Jun 2020 14:39:38 +0800 Subject: [PATCH 21/27] MDL-69138 behat: Update session->visit() to use visit step --- behat_app.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/behat_app.php b/behat_app.php index e474ffc67..ce9f392c0 100644 --- a/behat_app.php +++ b/behat_app.php @@ -283,7 +283,7 @@ class behat_app extends behat_base { global $CFG; // Visit the Ionic URL and wait for it to load. - $this->getSession()->visit($url); + $this->execute('behat_general::i_visit', [$url]); $this->spin( function($context, $args) { $title = $context->getSession()->getPage()->find('xpath', '//title'); From c06cd808795977b530993830773fdf6fa16ff08e Mon Sep 17 00:00:00 2001 From: Noel De Martin Date: Thu, 6 Aug 2020 12:08:43 +0200 Subject: [PATCH 22/27] MDL-69421 behat: Revert app behat navigation to call session visit --- behat_app.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/behat_app.php b/behat_app.php index ce9f392c0..e474ffc67 100644 --- a/behat_app.php +++ b/behat_app.php @@ -283,7 +283,7 @@ class behat_app extends behat_base { global $CFG; // Visit the Ionic URL and wait for it to load. - $this->execute('behat_general::i_visit', [$url]); + $this->getSession()->visit($url); $this->spin( function($context, $args) { $title = $context->getSession()->getPage()->find('xpath', '//title'); From 7ce1e2b2f9cbbcae5db6d03eca095ca87fbcbb03 Mon Sep 17 00:00:00 2001 From: Thinh Pham Date: Tue, 2 Mar 2021 13:34:53 +0700 Subject: [PATCH 23/27] MDL-70727 Behat: function scrollIntoView not working in the app --- app_behat_runtime.js | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/app_behat_runtime.js b/app_behat_runtime.js index 56fdbc3b3..182e5c185 100644 --- a/app_behat_runtime.js +++ b/app_behat_runtime.js @@ -476,6 +476,23 @@ return 'OK'; }; + /** + * Get main navigation controller. + * + * @return {Object} main navigation controller. + */ + var getNavCtrl = function() { + var mainNav = window.appProvider.appCtrl.getRootNavs()[0].getActiveChildNav(); + if (mainNav && mainNav.tabsIds.length && mainNav.firstSelectedTab) { + var tabPos = mainNav.tabsIds.indexOf(mainNav.firstSelectedTab); + if (tabPos !== -1 && mainNav._tabs && mainNav._tabs.length > tabPos) { + return mainNav._tabs[tabPos]; + } + } + // Fallback to return main nav - this will work but will overlay current tab. + return window.appProvider.appCtrl.getRootNavs()[0]; + }; + /** * Function to press arbitrary item based on its text or Aria label. * @@ -493,9 +510,13 @@ return 'ERROR: ' + error.message; } - // Simulate a mouse click on the button. - found.scrollIntoView(); + var mainContent = getNavCtrl().getActive()._cntDir; var rect = found.getBoundingClientRect(); + + // Scroll the item into view. + mainContent.scrollTo(rect.x, rect.y); + + // Simulate a mouse click on the button. var eventOptions = {clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2, bubbles: true, view: window, cancelable: true}; setTimeout(function() { From 7d1fd94c19b73db722c2eebdb4f4ef668b4de884 Mon Sep 17 00:00:00 2001 From: Noel De Martin Date: Thu, 25 Mar 2021 13:50:27 +0100 Subject: [PATCH 24/27] MDL-71194 behat: Fix app tests --- app_behat_runtime.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app_behat_runtime.js b/app_behat_runtime.js index 182e5c185..8bd63730d 100644 --- a/app_behat_runtime.js +++ b/app_behat_runtime.js @@ -510,7 +510,7 @@ return 'ERROR: ' + error.message; } - var mainContent = getNavCtrl().getActive()._cntDir; + var mainContent = getNavCtrl().getActive().contentRef().nativeElement; var rect = found.getBoundingClientRect(); // Scroll the item into view. From 15e79257627c897f55cc3cf8893a3e8d8c8ae81a Mon Sep 17 00:00:00 2001 From: Noel De Martin Date: Thu, 22 Apr 2021 09:32:32 +0200 Subject: [PATCH 25/27] MDL-68969 behat: Move app behat files to plugin folder --- app_behat_runtime.js => tests/behat/app_behat_runtime.js | 0 behat_app.php => tests/behat/behat_app.php | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename app_behat_runtime.js => tests/behat/app_behat_runtime.js (100%) rename behat_app.php => tests/behat/behat_app.php (100%) diff --git a/app_behat_runtime.js b/tests/behat/app_behat_runtime.js similarity index 100% rename from app_behat_runtime.js rename to tests/behat/app_behat_runtime.js diff --git a/behat_app.php b/tests/behat/behat_app.php similarity index 100% rename from behat_app.php rename to tests/behat/behat_app.php From 17006dcc4e742e98bfaf6bb907cc70f93696ee98 Mon Sep 17 00:00:00 2001 From: Noel De Martin Date: Tue, 27 Apr 2021 17:43:47 +0200 Subject: [PATCH 26/27] MOBILE-3738 behat: Prepare 3.9.5 tests --- .../tests/behat/app_basic_usage.feature | 2 +- mod/chat/tests/behat/app_basic_usage.feature | 2 +- .../tests/behat/app_basic_usage.feature | 2 +- .../tests/behat/app_basic_usage.feature | 2 +- .../tests/behat/app_basic_usage.feature | 2 +- .../tests/behat/app_course_completion.feature | 2 +- mod/course/tests/behat/app_courselist.feature | 80 ++-- .../tests/behat/app_basic_usage.feature | 2 +- mod/data/tests/behat/app_data_entries.feature | 2 +- mod/data/tests/behat/app_data_sync.feature | 2 +- mod/forum/tests/behat/app_basic_usage.feature | 2 +- .../tests/behat/app_basic_usage.feature | 2 +- mod/login/tests/behat/app_basic_usage.feature | 2 +- .../tests/behat/app_basic_usage.feature | 2 +- mod/quiz/tests/behat/app_basic_usage.feature | 2 +- .../tests/behat/app_quiz_navigation.feature | 2 +- .../tests/behat/app_basic_usage.feature | 2 +- tests/behat/app_behat_runtime.js | 349 ++++++++---------- tests/behat/behat_app.php | 74 +++- 19 files changed, 285 insertions(+), 250 deletions(-) diff --git a/mod/assignment/tests/behat/app_basic_usage.feature b/mod/assignment/tests/behat/app_basic_usage.feature index 1bfb70b57..778cfe1ec 100755 --- a/mod/assignment/tests/behat/app_basic_usage.feature +++ b/mod/assignment/tests/behat/app_basic_usage.feature @@ -1,4 +1,4 @@ -@mod @mod_assign @app @javascript +@mod @mod_assign @app @app_upto3.9.4 @javascript Feature: Test basic usage of assignment activity in app In order to participate in the assignment while using the mobile app I need basic assignment functionality to work diff --git a/mod/chat/tests/behat/app_basic_usage.feature b/mod/chat/tests/behat/app_basic_usage.feature index 424c80051..5838fa4ae 100755 --- a/mod/chat/tests/behat/app_basic_usage.feature +++ b/mod/chat/tests/behat/app_basic_usage.feature @@ -1,4 +1,4 @@ -@mod @mod_chat @app @javascript +@mod @mod_chat @app @app_upto3.9.4 @javascript Feature: Test basic usage of chat in app As a student I need basic chat functionality to work diff --git a/mod/choice/tests/behat/app_basic_usage.feature b/mod/choice/tests/behat/app_basic_usage.feature index 86055ca25..22ea6aa69 100755 --- a/mod/choice/tests/behat/app_basic_usage.feature +++ b/mod/choice/tests/behat/app_basic_usage.feature @@ -1,4 +1,4 @@ -@mod @mod_choice @app @javascript +@mod @mod_choice @app @app_upto3.9.4 @javascript Feature: Test basic usage of choice activity in app In order to participate in the choice while using the mobile app As a student diff --git a/mod/comments/tests/behat/app_basic_usage.feature b/mod/comments/tests/behat/app_basic_usage.feature index e4066d82d..2bc874f98 100755 --- a/mod/comments/tests/behat/app_basic_usage.feature +++ b/mod/comments/tests/behat/app_basic_usage.feature @@ -1,4 +1,4 @@ -@mod @mod_comments @app @javascript +@mod @mod_comments @app @app_upto3.9.4 @javascript Feature: Test basic usage of comments in app In order to participate in the comments while using the mobile app As a student diff --git a/mod/course/tests/behat/app_basic_usage.feature b/mod/course/tests/behat/app_basic_usage.feature index 0e66729d6..a74a97831 100755 --- a/mod/course/tests/behat/app_basic_usage.feature +++ b/mod/course/tests/behat/app_basic_usage.feature @@ -1,4 +1,4 @@ -@mod @mod_course @app @javascript +@mod @mod_course @app @app_upto3.9.4 @javascript Feature: Test basic usage of one course in app In order to participate in one course while using the mobile app As a student diff --git a/mod/course/tests/behat/app_course_completion.feature b/mod/course/tests/behat/app_course_completion.feature index a3c2475a5..34d482722 100644 --- a/mod/course/tests/behat/app_course_completion.feature +++ b/mod/course/tests/behat/app_course_completion.feature @@ -1,4 +1,4 @@ -@core @core_course @app @javascript +@core @core_course @app @app_upto3.9.4 @javascript Feature: Check course completion feature. In order to track the progress of the course on mobile device As a student diff --git a/mod/course/tests/behat/app_courselist.feature b/mod/course/tests/behat/app_courselist.feature index 1e1bc1e1f..7a508f9f3 100644 --- a/mod/course/tests/behat/app_courselist.feature +++ b/mod/course/tests/behat/app_courselist.feature @@ -22,17 +22,17 @@ Feature: Test course list shown on app start tab Scenario: View courses (shortnames not displayed) When I enter the app And I log in as "student1" - Then I should see "Course 1" - But I should not see "Course 2" - But I should not see "C1" - But I should not see "C2" + Then I should find "Course 1" in the app + But I should not find "Course 2" in the app + But I should not find "C1" in the app + But I should not find "C2" in the app When I enter the app And I log in as "student2" - Then I should see "Course 1" - And I should see "Course 2" - But I should not see "C1" - But I should not see "C2" + Then I should find "Course 1" in the app + And I should find "Course 2" in the app + But I should not find "C1" in the app + But I should not find "C2" in the app Scenario: Filter courses Given the following config values are set as admin: @@ -78,26 +78,46 @@ Feature: Test course list shown on app start tab | student2 | Z10 | student | When I enter the app And I log in as "student2" - Then I press "Display options" near "Course overview" in the app - Then I should see "C1" - And I should see "C2" - And I should see "C3" - And I should see "C4" - And I should see "C5" - And I should see "C6" - Then I press "Filter my courses" in the app - And I set the field "Filter my courses" to "fr" in the app - Then I should not see "C1" - And I should not see "C2" - And I should see "C3" - And I should see "C4" - And I should not see "C5" - And I should not see "C6" - And I press "Display options" near "Course overview" in the app + Then I should find "C1" in the app + And I should find "C2" in the app + And I should find "C3" in the app + And I should find "C4" in the app + And I should find "C5" in the app + And I should find "C6" in the app + And I should find "Course 1" in the app + And I should find "Course 2" in the app + And I should find "Frog 3" in the app + And I should find "Frog 4" in the app + And I should find "Course 5" in the app + And I should find "Toad 6" in the app + + When I press "Display options" near "Course overview" in the app And I press "Filter my courses" in the app - Then I should see "C1" - And I should see "C2" - And I should see "C3" - And I should see "C4" - And I should see "C5" - And I should see "C6" + And I set the field "Filter my courses" to "fr" in the app + Then I should find "C3" in the app + And I should find "C4" in the app + And I should find "Frog 3" in the app + And I should find "Frog 4" in the app + But I should not find "C1" in the app + And I should not find "C2" in the app + And I should not find "C5" in the app + And I should not find "C6" in the app + And I should not find "Course 1" in the app + And I should not find "Course 2" in the app + And I should not find "Course 5" in the app + And I should not find "Toad 6" in the app + + When I press "Display options" near "Course overview" in the app + And I press "Filter my courses" in the app + Then I should find "C1" in the app + And I should find "C2" in the app + And I should find "C3" in the app + And I should find "C4" in the app + And I should find "C5" in the app + And I should find "C6" in the app + And I should find "Course 1" in the app + And I should find "Course 2" in the app + And I should find "Frog 3" in the app + And I should find "Frog 4" in the app + And I should find "Course 5" in the app + And I should find "Toad 6" in the app diff --git a/mod/courses/tests/behat/app_basic_usage.feature b/mod/courses/tests/behat/app_basic_usage.feature index fea9ce84a..e81882cde 100755 --- a/mod/courses/tests/behat/app_basic_usage.feature +++ b/mod/courses/tests/behat/app_basic_usage.feature @@ -1,4 +1,4 @@ -@mod @mod_courses @app @javascript +@mod @mod_courses @app @app_upto3.9.4 @javascript Feature: Test basic usage of courses in app In order to participate in the courses while using the mobile app As a student diff --git a/mod/data/tests/behat/app_data_entries.feature b/mod/data/tests/behat/app_data_entries.feature index 41e8ad6c5..5fb346918 100644 --- a/mod/data/tests/behat/app_data_entries.feature +++ b/mod/data/tests/behat/app_data_entries.feature @@ -1,4 +1,4 @@ -@mod @mod_data @app @javascript +@mod @mod_data @app @app_upto3.9.4 @javascript Feature: Users can manage entries in database activities In order to populate databases As a user diff --git a/mod/data/tests/behat/app_data_sync.feature b/mod/data/tests/behat/app_data_sync.feature index f0b147b95..065ffe35f 100644 --- a/mod/data/tests/behat/app_data_sync.feature +++ b/mod/data/tests/behat/app_data_sync.feature @@ -1,4 +1,4 @@ -@mod @mod_data @app @javascript +@mod @mod_data @app @app_upto3.9.4 @javascript Feature: Users can store entries in database activities when offline and sync when online In order to populate databases while offline As a user diff --git a/mod/forum/tests/behat/app_basic_usage.feature b/mod/forum/tests/behat/app_basic_usage.feature index 175c4bb52..473cbfc21 100755 --- a/mod/forum/tests/behat/app_basic_usage.feature +++ b/mod/forum/tests/behat/app_basic_usage.feature @@ -1,4 +1,4 @@ -@mod @mod_forum @app @javascript +@mod @mod_forum @app @app_upto3.9.4 @javascript Feature: Test basic usage of forum activity in app In order to participate in the forum while using the mobile app As a student diff --git a/mod/glossary/tests/behat/app_basic_usage.feature b/mod/glossary/tests/behat/app_basic_usage.feature index 2f8de6a52..db323a308 100755 --- a/mod/glossary/tests/behat/app_basic_usage.feature +++ b/mod/glossary/tests/behat/app_basic_usage.feature @@ -1,4 +1,4 @@ -@mod @mod_glossary @app @javascript +@mod @mod_glossary @app @app_upto3.9.4 @javascript Feature: Test basic usage of glossary in app In order to participate in the glossaries while using the mobile app As a student diff --git a/mod/login/tests/behat/app_basic_usage.feature b/mod/login/tests/behat/app_basic_usage.feature index 0aa02605f..fc0f78236 100755 --- a/mod/login/tests/behat/app_basic_usage.feature +++ b/mod/login/tests/behat/app_basic_usage.feature @@ -1,4 +1,4 @@ -@mod @mod_login @app @javascript +@mod @mod_login @app @app_upto3.9.4 @javascript Feature: Test basic usage of login in app I need basic login functionality to work diff --git a/mod/messages/tests/behat/app_basic_usage.feature b/mod/messages/tests/behat/app_basic_usage.feature index f33f06c13..a2aa8fa3f 100755 --- a/mod/messages/tests/behat/app_basic_usage.feature +++ b/mod/messages/tests/behat/app_basic_usage.feature @@ -1,4 +1,4 @@ -@mod @mod_messages @app @javascript +@mod @mod_messages @app @app_upto3.9.4 @javascript Feature: Test basic usage of messages in app In order to participate with messages while using the mobile app As a student diff --git a/mod/quiz/tests/behat/app_basic_usage.feature b/mod/quiz/tests/behat/app_basic_usage.feature index ca652fdcb..7b2f92086 100755 --- a/mod/quiz/tests/behat/app_basic_usage.feature +++ b/mod/quiz/tests/behat/app_basic_usage.feature @@ -1,4 +1,4 @@ -@mod @mod_quiz @app @javascript +@mod @mod_quiz @app @app_upto3.9.4 @javascript Feature: Attempt a quiz in app As a student In order to demonstrate what I know diff --git a/mod/quiz/tests/behat/app_quiz_navigation.feature b/mod/quiz/tests/behat/app_quiz_navigation.feature index c28bc1c86..75b8b7a9d 100644 --- a/mod/quiz/tests/behat/app_quiz_navigation.feature +++ b/mod/quiz/tests/behat/app_quiz_navigation.feature @@ -1,4 +1,4 @@ -@mod @mod_quiz @app @javascript +@mod @mod_quiz @app @app_upto3.9.4 @javascript Feature: Attempt a quiz in app As a student In order to demonstrate what I know diff --git a/mod/survey/tests/behat/app_basic_usage.feature b/mod/survey/tests/behat/app_basic_usage.feature index a8a324415..fcba1840e 100755 --- a/mod/survey/tests/behat/app_basic_usage.feature +++ b/mod/survey/tests/behat/app_basic_usage.feature @@ -1,4 +1,4 @@ -@mod @mod_survey @app @javascript +@mod @mod_survey @app @app_upto3.9.4 @javascript Feature: Test basic usage of survey activity in app In order to participate in surveys while using the mobile app As a student diff --git a/tests/behat/app_behat_runtime.js b/tests/behat/app_behat_runtime.js index 8bd63730d..c725827f5 100644 --- a/tests/behat/app_behat_runtime.js +++ b/tests/behat/app_behat_runtime.js @@ -174,6 +174,26 @@ var observer = new MutationObserver(mutationCallback); observer.observe(document, {attributes: true, childList: true, subtree: true}); + /** + * Check if an element is visible. + * + * @param {HTMLElement} element Element + * @param {HTMLElement} container Container + * @returns {boolean} Whether the element is visible or not + */ + var isElementVisible = (element, container) => { + if (element.getAttribute('aria-hidden') === 'true' || getComputedStyle(element).display === 'none') + return false; + + if (element.parentElement === container) + return true; + + if (!element.parentElement) + return false; + + return isElementVisible(element.parentElement, container); + }; + /** * Generic shared function to find possible xpath matches within the document, that are visible, * and then process them using a callback function. @@ -199,194 +219,107 @@ } }; + /** + * Finds an element within a given container. + * + * @param {HTMLElement} container Parent element to search the element within + * @param {string} text Text to look for + * @return {HTMLElement} Found element + */ + var findElementBasedOnTextWithin = (container, text) => { + const attributesSelector = `[aria-label*="${text}"], a[title*="${text}"], img[alt*="${text}"]`; + + for (const foundByAttributes of container.querySelectorAll(attributesSelector)) { + if (isElementVisible(foundByAttributes, container)) + return foundByAttributes; + } + + const treeWalker = document.createTreeWalker( + container, + NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_DOCUMENT_FRAGMENT | NodeFilter.SHOW_TEXT, + { + acceptNode: node => { + if ( + node instanceof HTMLStyleElement || + node instanceof HTMLLinkElement || + node instanceof HTMLScriptElement + ) + return NodeFilter.FILTER_REJECT; + + if ( + node instanceof HTMLElement && ( + node.getAttribute('aria-hidden') === 'true' || getComputedStyle(node).display === 'none' + ) + ) + return NodeFilter.FILTER_REJECT; + + return NodeFilter.FILTER_ACCEPT; + } + }, + ); + + let currentNode; + while (currentNode = treeWalker.nextNode()) { + if (currentNode instanceof Text) { + if (currentNode.textContent.includes(text)) { + return currentNode.parentElement; + } + + continue; + } + + const labelledBy = currentNode.getAttribute('aria-labelledby'); + if (labelledBy && container.querySelector(`#${labelledBy}`)?.innerText?.includes(text)) + return currentNode; + + if (currentNode.shadowRoot) { + for (const childNode of currentNode.shadowRoot.childNodes) { + if (!childNode) { + continue; + } + + if (childNode.matches(attributesSelector)) { + return childNode; + } + + const foundByText = findElementBasedOnTextWithin(childNode, text); + + if (foundByText) { + return foundByText; + } + } + } + } + }; + /** * Function to find an element based on its text or Aria label. * * @param {string} text Text (full or partial) * @param {string} [near] Optional 'near' text - if specified, must have a single match on page * @return {HTMLElement} Found element - * @throws {string} Error message beginning 'ERROR:' if something went wrong */ var findElementBasedOnText = function(text, near) { - // Find all the elements that contain this text (and don't have a child element that - // contains it - i.e. the most specific elements). - var escapedText = text.replace('"', '""'); - var exactMatches = []; - var anyMatches = []; - findPossibleMatches('//*[contains(normalize-space(.), "' + escapedText + - '") and not(child::*[contains(normalize-space(.), "' + escapedText + '")])]', - function(match) { - // Get the text. Note that innerText returns capitalised values for Android buttons - // for some reason, so we'll have to do a case-insensitive match. - var matchText = match.innerText.trim().toLowerCase(); + const topContainer = document.querySelector('ion-alert, ion-popover, ion-action-sheet, core-ion-tab.show-tab ion-page.show-page, ion-page.show-page, html'); + let container = topContainer; - // Let's just check - is this actually a label for something else? If so we will click - // that other thing instead. - var labelId = document.evaluate('string(ancestor-or-self::ion-label[@id][1]/@id)', match).stringValue; - if (labelId) { - var target = document.querySelector('*[aria-labelledby=' + labelId + ']'); - if (target) { - match = target; - } - } + if (topContainer && near) { + const nearElement = findElementBasedOnText(near); - // Add to array depending on if it's an exact or partial match. - if (matchText === text.toLowerCase()) { - exactMatches.push(match); - } else { - anyMatches.push(match); - } - }); - - // Find all the Aria labels that contain this text. - var exactLabelMatches = []; - var anyLabelMatches = []; - findPossibleMatches('//*[@aria-label and contains(@aria-label, "' + escapedText + '")]' + - '| //a[@title and contains(@title, "' + escapedText + '")]' + - '| //img[@alt and contains(@alt, "' + escapedText + '")]', function(match) { - // Add to array depending on if it's an exact or partial match. - var attributeData = match.getAttribute('aria-label') || - match.getAttribute('title') || - match.getAttribute('alt'); - if (attributeData.trim() === text) { - exactLabelMatches.push(match); - } else { - anyLabelMatches.push(match); - } - }); - - // If the 'near' text is set, use it to filter results. - var nearAncestors = []; - if (near !== undefined) { - escapedText = near.replace('"', '""'); - var exactNearMatches = []; - var anyNearMatches = []; - findPossibleMatches('//*[contains(normalize-space(.), "' + escapedText + - '") and not(child::*[contains(normalize-space(.), "' + escapedText + - '")])]', function(match) { - // Get the text. - var matchText = match.innerText.trim(); - - // Add to array depending on if it's an exact or partial match. - if (matchText === text) { - exactNearMatches.push(match); - } else { - anyNearMatches.push(match); - } - }); - - var nearFound = null; - - // If there is an exact text match, use that (regardless of other matches). - if (exactNearMatches.length > 1) { - throw new Error('Too many exact matches for near text'); - } else if (exactNearMatches.length) { - nearFound = exactNearMatches[0]; + if (!nearElement) { + return; } - if (nearFound === null) { - // If there is one partial text match, use that. - if (anyNearMatches.length > 1) { - throw new Error('Too many partial matches for near text'); - } else if (anyNearMatches.length) { - nearFound = anyNearMatches[0]; - } - } - - if (!nearFound) { - throw new Error('No matches for near text'); - } - - while (nearFound) { - nearAncestors.push(nearFound); - nearFound = nearFound.parentNode; - } - - /** - * Checks the number of steps up the tree from a specified node before getting to an - * ancestor of the 'near' item - * - * @param {HTMLElement} node HTML node - * @returns {number} Number of steps up, or Number.MAX_SAFE_INTEGER if it never matched - */ - var calculateNearDepth = function(node) { - var depth = 0; - while (node) { - var ancestorDepth = nearAncestors.indexOf(node); - if (ancestorDepth !== -1) { - return depth + ancestorDepth; - } - node = node.parentNode; - depth++; - } - return Number.MAX_SAFE_INTEGER; - }; - - /** - * Reduces an array to include only the nearest in each category. - * - * @param {Array} arr Array to - * @return {Array} Array including only the items with minimum 'near' depth - */ - var filterNonNearest = function(arr) { - var nearDepth = arr.map(function(node) { - return calculateNearDepth(node); - }); - var minDepth = Math.min.apply(null, nearDepth); - return arr.filter(function(element, index) { - return nearDepth[index] == minDepth; - }); - }; - - // Filter all the category arrays. - exactMatches = filterNonNearest(exactMatches); - exactLabelMatches = filterNonNearest(exactLabelMatches); - anyMatches = filterNonNearest(anyMatches); - anyLabelMatches = filterNonNearest(anyLabelMatches); + container = nearElement.parentElement; } - // Select the resulting match. Note this 'do' loop is not really a loop, it is just so we - // can easily break out of it as soon as we find a match. - var found = null; do { - // If there is an exact text match, use that (regardless of other matches). - if (exactMatches.length > 1) { - throw new Error('Too many exact matches for text'); - } else if (exactMatches.length) { - found = exactMatches[0]; - break; + const node = findElementBasedOnTextWithin(container, text); + + if (node) { + return node; } - - // If there is an exact label match, use that. - if (exactLabelMatches.length > 1) { - throw new Error('Too many exact label matches for text'); - } else if (exactLabelMatches.length) { - found = exactLabelMatches[0]; - break; - } - - // If there is one partial text match, use that. - if (anyMatches.length > 1) { - throw new Error('Too many partial matches for text'); - } else if (anyMatches.length) { - found = anyMatches[0]; - break; - } - - // Finally if there is one partial label match, use that. - if (anyLabelMatches.length > 1) { - throw new Error('Too many partial label matches for text'); - } else if (anyLabelMatches.length) { - found = anyLabelMatches[0]; - break; - } - } while (false); - - if (!found) { - throw new Error('No matches for text'); - } - - return found; + } while ((container = container.parentElement) && container !== topContainer); }; /** @@ -476,6 +409,29 @@ return 'OK'; }; + /** + * Function to find an arbitrary item based on its text or aria label. + * + * @param {string} text Text (full or partial) + * @param {string} [near] Optional 'near' text + * @return {string} OK if successful, or ERROR: followed by message + */ + var behatFind = function(text, near) { + log(`Action - Find ${text}`); + + try { + const element = findElementBasedOnText(text, near); + + if (!element) { + return 'ERROR: No matches for text'; + } + + return 'OK'; + } catch (error) { + return 'ERROR: ' + error.message; + } + }; + /** * Get main navigation controller. * @@ -497,7 +453,7 @@ * Function to press arbitrary item based on its text or Aria label. * * @param {string} text Text (full or partial) - * @param {string} near Optional 'near' text - if specified, must have a single match on page + * @param {string} near Optional 'near' text * @return {string} OK if successful, or ERROR: followed by message */ var behatPress = function(text, near) { @@ -506,28 +462,37 @@ var found; try { found = findElementBasedOnText(text, near); + + if (!found) { + return 'ERROR: No matches for text'; + } } catch (error) { return 'ERROR: ' + error.message; } - var mainContent = getNavCtrl().getActive().contentRef().nativeElement; - var rect = found.getBoundingClientRect(); + if (window.BehatMoodleAppLegacy) { + var mainContent = getNavCtrl().getActive().contentRef().nativeElement; + var rect = found.getBoundingClientRect(); - // Scroll the item into view. - mainContent.scrollTo(rect.x, rect.y); + // Scroll the item into view. + mainContent.scrollTo(rect.x, rect.y); - // Simulate a mouse click on the button. - var eventOptions = {clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2, - bubbles: true, view: window, cancelable: true}; - setTimeout(function() { - found.dispatchEvent(new MouseEvent('mousedown', eventOptions)); - }, 0); - setTimeout(function() { - found.dispatchEvent(new MouseEvent('mouseup', eventOptions)); - }, 0); - setTimeout(function() { - found.dispatchEvent(new MouseEvent('click', eventOptions)); - }, 0); + // Simulate a mouse click on the button. + var eventOptions = {clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2, + bubbles: true, view: window, cancelable: true}; + setTimeout(function() { + found.dispatchEvent(new MouseEvent('mousedown', eventOptions)); + }, 0); + setTimeout(function() { + found.dispatchEvent(new MouseEvent('mouseup', eventOptions)); + }, 0); + setTimeout(function() { + found.dispatchEvent(new MouseEvent('click', eventOptions)); + }, 0); + } else { + found.scrollIntoView(); + setTimeout(() => found.click(), 300); + } // Mark busy until the button click finishes processing. addPendingDelay(); @@ -547,7 +512,10 @@ var resultCount = 0; var titles = Array.from(document.querySelectorAll('ion-header ion-title')); titles.forEach(function(title) { - if (title.offsetParent) { + if ( + (window.BehatMoodleAppLegacy && title.offsetParent) || + (!window.BehatMoodleAppLegacy && isElementVisible(title, document.body)) + ) { result = title.innerText.trim(); resultCount++; } @@ -670,6 +638,7 @@ window.behat = { pressStandard : behatPressStandard, closePopup : behatClosePopup, + find : behatFind, press : behatPress, setField : behatSetField, getHeader : behatGetHeader, diff --git a/tests/behat/behat_app.php b/tests/behat/behat_app.php index e474ffc67..c1c14c80b 100644 --- a/tests/behat/behat_app.php +++ b/tests/behat/behat_app.php @@ -25,7 +25,7 @@ // NOTE: no MOODLE_INTERNAL test here, this file may be required by behat before including /config.php. -require_once(__DIR__ . '/../../behat/behat_base.php'); +require_once(__DIR__ . '/../../../../lib/behat/behat_base.php'); use Behat\Mink\Exception\DriverException; use Behat\Mink\Exception\ExpectationException; @@ -63,7 +63,7 @@ class behat_app extends behat_base { $this->check_behat_setup(); $this->fix_moodle_setup(); $this->ionicurl = $this->start_or_reuse_ionic(); -} + } /** * Opens the Moodle app in the browser. @@ -93,6 +93,33 @@ class behat_app extends behat_base { $this->prepare_browser($this->ionicurl); } + /** + * Finds elements in the app. + * + * @Then /^I should(?P not)? find "(?P(?:[^"]|\\")*)"(?: near "(?P(?:[^"]|\\")*)")? in the app$/ + * @param string $text + */ + public function i_find_in_the_app($not, $text='', $near='') { + $not = !empty($not); + $text = addslashes_js($text); + $near = addslashes_js($near); + + $this->spin(function() use ($not, $text, $near) { + $result = $this->evaluate_script("return window.behat.find(\"$text\", \"$near\");"); + + if ($not && $result === 'OK') { + throw new DriverException('Error, found an item that should not be found'); + } + + if (!$not && $result !== 'OK') { + throw new DriverException('Error finding item - ' . $result); + } + + return true; + }); + $this->wait_for_pending_js(); + } + /** * Checks the Behat setup - tags and configuration. * @@ -282,14 +309,23 @@ class behat_app extends behat_base { protected function prepare_browser(string $url) { global $CFG; + // Check whether the app is running a legacy version. + $json = @file_get_contents("$url/assets/env.json") ?: @file_get_contents("$url/config.json"); + $data = json_decode($json); + $appversion = $data->build->version ?? str_replace('-dev', '', $data->versionname); + $islegacy = version_compare($appversion, '3.9.5', '<'); + // Visit the Ionic URL and wait for it to load. $this->getSession()->visit($url); $this->spin( - function($context, $args) { + function($context) use ($islegacy) { $title = $context->getSession()->getPage()->find('xpath', '//title'); if ($title) { $text = $title->getHtml(); - if ($text === 'Moodle Desktop') { + if ( + ($islegacy && $text === 'Moodle Desktop') || + (!$islegacy && $text === 'Moodle App') + ) { return true; } } @@ -297,20 +333,25 @@ class behat_app extends behat_base { }, false, 60); // Run the scripts to install Moodle 'pending' checks. + $islegacyboolean = $islegacy ? 'true' : 'false'; + $this->execute_script("window.BehatMoodleAppLegacy = $islegacyboolean;"); $this->execute_script(file_get_contents(__DIR__ . '/app_behat_runtime.js')); // Wait until the site login field appears OR the main page. $situation = $this->spin( - function($context, $args) { + function($context) use ($islegacy) { $page = $context->getSession()->getPage(); $element = $page->find('xpath', '//page-core-login-site//input[@name="url"]'); if ($element) { // Wait for the onboarding modal to open, if any. $this->wait_for_pending_js(); - $element = $page->find('xpath', '//page-core-login-site-onboarding'); + $element = $islegacy + ? $page->find('xpath', '//page-core-login-site-onboarding') + : $page->find('xpath', '//core-login-site-onboarding'); if ($element) { $this->i_press_in_the_app('Skip'); + $this->wait_for_pending_js(); } return 'login'; @@ -327,7 +368,7 @@ class behat_app extends behat_base { // page. If it's the main page, we just leave it there. if ($situation === 'login') { $this->i_set_the_field_in_the_app('campus.example.edu', $CFG->wwwroot); - $this->i_press_in_the_app('Connect!'); + $this->i_press_in_the_app($islegacy ? 'Connect!' : 'Connect to your site'); } // Continue only after JS finishes. @@ -491,18 +532,23 @@ class behat_app extends behat_base { * @throws ExpectationException If the header text is different to the expected value */ public function the_header_should_be_in_the_app(string $text) { - $result = $this->spin(function($context, $args) { + $this->spin(function() use ($text) { $result = $this->evaluate_script('return window.behat.getHeader();'); + if (substr($result, 0, 3) !== 'OK:') { throw new DriverException('Error getting header - ' . $result); } - return $result; + + $header = substr($result, 3); + if (trim($header) !== trim($text)) { + throw new ExpectationException( + "The header text was not as expected: '$header'", + $this->getSession()->getDriver() + ); + } + + return true; }); - $header = substr($result, 3); - if (trim($header) !== trim($text)) { - throw new ExpectationException('The header text was not as expected: \'' . $header . '\'', - $this->getSession()->getDriver()); - } } /** From 778298a45582d3bce64aa8f359f0c627d768555c Mon Sep 17 00:00:00 2001 From: Noel De Martin Date: Thu, 29 Apr 2021 13:40:42 +0200 Subject: [PATCH 27/27] MOBILE-3738 behat: Implement navigation tests --- tests/behat/app_behat_runtime.js | 124 ++++++++++++++++++++-------- tests/behat/behat_app.php | 34 ++++++++ tests/behat/navigation.feature | 133 +++++++++++++++++++++++++++++++ 3 files changed, 257 insertions(+), 34 deletions(-) create mode 100644 tests/behat/navigation.feature diff --git a/tests/behat/app_behat_runtime.js b/tests/behat/app_behat_runtime.js index c725827f5..9a0e4bff2 100644 --- a/tests/behat/app_behat_runtime.js +++ b/tests/behat/app_behat_runtime.js @@ -194,6 +194,24 @@ return isElementVisible(element.parentElement, container); }; + /** + * Check if an element is selected. + * + * @param {HTMLElement} element Element + * @param {HTMLElement} container Container + * @returns {boolean} Whether the element is selected or not + */ + var isElementSelected = (element, container) => { + const ariaCurrent = element.getAttribute('aria-current'); + if (ariaCurrent && ariaCurrent !== 'false') + return true; + + if (!element.parentElement || element.parentElement === container) + return false; + + return isElementSelected(element.parentElement, container); + }; + /** * Generic shared function to find possible xpath matches within the document, that are visible, * and then process them using a callback function. @@ -330,43 +348,61 @@ */ var behatPressStandard = function(button) { log('Action - Click standard button: ' + button); - var selector; - switch (button) { - case 'back' : - selector = 'ion-navbar > button.back-button-md'; - break; - case 'main menu' : - // Change in app version 3.8. - selector = 'page-core-mainmenu .tab-button > ion-icon[aria-label=more], ' + - 'page-core-mainmenu .tab-button > ion-icon[aria-label=menu]'; - break; - case 'page menu' : - // This lang string was changed in app version 3.6. - selector = 'core-context-menu > button[aria-label=Info], ' + - 'core-context-menu > button[aria-label=Information], ' + - 'core-context-menu > button[aria-label="Display options"]'; - break; - default: - return 'ERROR: Unsupported standard button type'; - } - var buttons = Array.from(document.querySelectorAll(selector)); + + // Find button var foundButton = null; - var tooMany = false; - buttons.forEach(function(button) { - if (button.offsetParent) { - if (foundButton === null) { - foundButton = button; - } else { - tooMany = true; - } + + if (window.BehatMoodleAppLegacy) { + var selector; + switch (button) { + case 'back' : + selector = 'ion-navbar > button.back-button-md'; + break; + case 'main menu' : + // Change in app version 3.8. + selector = 'page-core-mainmenu .tab-button > ion-icon[aria-label=more], ' + + 'page-core-mainmenu .tab-button > ion-icon[aria-label=menu]'; + break; + case 'page menu' : + // This lang string was changed in app version 3.6. + selector = 'core-context-menu > button[aria-label=Info], ' + + 'core-context-menu > button[aria-label=Information], ' + + 'core-context-menu > button[aria-label="Display options"]'; + break; + default: + return 'ERROR: Unsupported standard button type'; + } + var buttons = Array.from(document.querySelectorAll(selector)); + var tooMany = false; + buttons.forEach(function(button) { + if (button.offsetParent) { + if (foundButton === null) { + foundButton = button; + } else { + tooMany = true; + } + } + }); + if (!foundButton) { + return 'ERROR: Could not find button'; + } + if (tooMany) { + return 'ERROR: Found too many buttons'; + } + } else { + switch (button) { + case 'back': + foundButton = findElementBasedOnText('Back'); + break; + case 'main menu': + foundButton = findElementBasedOnText('more', 'Notifications'); + break; + default: + return 'ERROR: Unsupported standard button type'; } - }); - if (!foundButton) { - return 'ERROR: Could not find button'; - } - if (tooMany) { - return 'ERROR: Found too many buttons'; } + + // Click button foundButton.click(); // Mark busy until the button click finishes processing. @@ -449,6 +485,25 @@ return window.appProvider.appCtrl.getRootNavs()[0]; }; + /** + * Check whether an item is selected or not. + * + * @param {string} text Text (full or partial) + * @param {string} near Optional 'near' text + * @return {string} YES or NO if successful, or ERROR: followed by message + */ + var behatIsSelected = function(text, near) { + log(`Action - Is Selected: "${text}"${near ? ` near "${near}"`: ''}`); + + try { + const element = findElementBasedOnText(text, near); + + return isElementSelected(element, document.body) ? 'YES' : 'NO'; + } catch (error) { + return 'ERROR: ' + error.message; + } + } + /** * Function to press arbitrary item based on its text or Aria label. * @@ -639,6 +694,7 @@ pressStandard : behatPressStandard, closePopup : behatClosePopup, find : behatFind, + isSelected : behatIsSelected, press : behatPress, setField : behatSetField, getHeader : behatGetHeader, diff --git a/tests/behat/behat_app.php b/tests/behat/behat_app.php index c1c14c80b..8eec8fd7b 100644 --- a/tests/behat/behat_app.php +++ b/tests/behat/behat_app.php @@ -120,6 +120,40 @@ class behat_app extends behat_base { $this->wait_for_pending_js(); } + /** + * Check if elements are selected in the app. + * + * @Then /^"(?P(?:[^"]|\\")*)"(?: near "(?P(?:[^"]|\\")*)")? should(?P not)? be selected in the app$/ + * @param string $text + */ + public function be_selected_in_the_app($text, $near='', $not='') { + $not = !empty($not); + $text = addslashes_js($text); + $near = addslashes_js($near); + + $this->spin(function() use ($not, $text, $near) { + $result = $this->evaluate_script("return window.behat.isSelected(\"$text\", \"$near\");"); + + switch ($result) { + case 'YES': + if ($not) { + throw new ExpectationException("Item was selected and shouldn't have", $this->getSession()->getDriver()); + } + break; + case 'NO': + if (!$not) { + throw new ExpectationException("Item wasn't selected and should have", $this->getSession()->getDriver()); + } + break; + default: + throw new DriverException('Error finding item - ' . $result); + } + + return true; + }); + $this->wait_for_pending_js(); + } + /** * Checks the Behat setup - tags and configuration. * diff --git a/tests/behat/navigation.feature b/tests/behat/navigation.feature new file mode 100644 index 000000000..5a02138e5 --- /dev/null +++ b/tests/behat/navigation.feature @@ -0,0 +1,133 @@ +@app @javascript +Feature: It navigates properly between pages. + + Background: + Given the following "users" exist: + | username | + | student1 | + Given the following "courses" exist: + | fullname | shortname | + | Course 2 | C2 | + | Course 1 | C1 | + And the following "course enrolments" exist: + | user | course | role | + | student1 | C1 | student | + | student1 | C2 | student | + And the following "grade categories" exist: + | fullname | course | + | Grade category C1 | C1 | + | Grade category C2 | C2 | + And the following "grade items" exist: + | gradecategory | itemname | grademin | grademax | course | + | Grade category C1 | Grade item C1 | 20 | 40 | C1 | + | Grade category C2 | Grade item C2 | 60 | 80 | C2 | + + Scenario: Navigate between split-view items in mobiles + + # Open more tab + Given I enter the app + And I log in as "student1" + And I press the main menu button in the app + + # Open grades tab + When I press "Grades" in the app + Then the header should be "Grades" in the app + And I should find "Course 1" in the app + And I should find "Course 2" in the app + + # Open C1 course grades + When I press "Course 1" in the app + Then the header should be "Grades" in the app + And I should find "Grade category C1" in the app + + # Open C1 grade item + When I press "Grade item C1" in the app + Then the header should be "Grade" in the app + And I should find "20" near "Range" in the app + And I should find "40" near "Range" in the app + + # Go back to course grades + When I press the back button in the app + Then the header should be "Grades" in the app + And I should find "Grade category C1" in the app + + # Go back to grades tab + When I press the back button in the app + Then the header should be "Grades" in the app + And I should find "Course 1" in the app + And I should find "Course 2" in the app + + # Open C2 course grades + When I press "Course 2" in the app + Then the header should be "Grades" in the app + And I should find "Grade category C2" in the app + + # Open C2 grade item + When I press "Grade item C2" in the app + Then the header should be "Grade" in the app + And I should find "60" near "Range" in the app + And I should find "80" near "Range" in the app + + # Go back to course grades + When I press the back button in the app + Then the header should be "Grades" in the app + And I should find "Grade category C2" in the app + + # Go back to grades tab + When I press the back button in the app + Then the header should be "Grades" in the app + And I should find "Course 1" in the app + And I should find "Course 2" in the app + + # Go back to more tab + When I press the back button in the app + Then I should find "Grades" in the app + And I should find "App settings" in the app + But I should not find "Back" in the app + + Scenario: Navigate between split-view items in tablets + + # Open more tab + Given I enter the app + And I change viewport size to "1200x640" + And I log in as "student1" + + # Open grades tab + When I press "Grades" in the app + Then the header should be "Grades" in the app + And I should find "Course 1" in the app + And I should find "Course 2" in the app + And I should find "Grade category C1" in the app + + # Open C1 course grades + When I press "Grade item C1" in the app + Then the header should be "Grades" in the app + And I should find "Grade category C1" in the app + And I should find "20" near "Range" in the app + And I should find "40" near "Range" in the app + + # Go back to grades tab + When I press the back button in the app + Then the header should be "Grades" in the app + And I should find "Course 1" in the app + And I should find "Course 2" in the app + + # Select C2 course + When I press "Course 2" in the app + Then the header should be "Grades" in the app + And "Course 2" should be selected in the app + And I should find "Grade category C2" in the app + + # Open C2 course grades + When I press "Grade item C2" in the app + Then the header should be "Grades" in the app + And I should find "Grade category C2" in the app + And I should find "60" near "Range" in the app + And I should find "80" near "Range" in the app + + # Go back to grades tab + When I press the back button in the app + Then the header should be "Grades" in the app + And I should find "Course 1" in the app + And I should find "Course 2" in the app + But I should not find "Back" in the app