From 2735b7ff8d3dbf862dc37767bcaf3695b5037ce8 Mon Sep 17 00:00:00 2001 From: sam marshall Date: Mon, 12 Nov 2018 12:11:06 +0000 Subject: [PATCH 01/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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