From 1959e16412cdfe4bf21cf3b7f262f1f1e4eb7e72 Mon Sep 17 00:00:00 2001 From: sam marshall Date: Mon, 12 Nov 2018 12:11:06 +0000 Subject: [PATCH] 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 --- admin/tool/behat/lang/en/tool_behat.php | 1 + config-dist.php | 7 + course/tests/behat/app_courselist.feature | 91 +++ lib/behat/classes/behat_command.php | 6 + lib/behat/classes/behat_config_util.php | 121 ++++ lib/tests/behat/app_behat_runtime.js | 635 ++++++++++++++++++ lib/tests/behat/behat_app.php | 524 +++++++++++++++ mod/forum/tests/behat/app_basic_usage.feature | 62 ++ 8 files changed, 1447 insertions(+) create mode 100644 course/tests/behat/app_courselist.feature create mode 100644 lib/tests/behat/app_behat_runtime.js create mode 100644 lib/tests/behat/behat_app.php create mode 100644 mod/forum/tests/behat/app_basic_usage.feature diff --git a/admin/tool/behat/lang/en/tool_behat.php b/admin/tool/behat/lang/en/tool_behat.php index f2cd279ee9b..776fb1dfc6d 100644 --- a/admin/tool/behat/lang/en/tool_behat.php +++ b/admin/tool/behat/lang/en/tool_behat.php @@ -24,6 +24,7 @@ $string['aim'] = 'This administration tool helps developers and test writers to create .feature files describing Moodle\'s functionalities and run them automatically. Step definitions available for use in .feature files are listed below.'; $string['allavailablesteps'] = 'All available step definitions'; +$string['errorapproot'] = '$CFG->behat_approot is not pointing to a valid Moodle Mobile developer install.'; $string['errorbehatcommand'] = 'Error running behat CLI command. Try running "{$a} --help" manually from CLI to find out more about the problem.'; $string['errorcomposer'] = 'Composer dependencies are not installed.'; $string['errordataroot'] = '$CFG->behat_dataroot is not set or is invalid.'; diff --git a/config-dist.php b/config-dist.php index 9cd8bba70f6..e29135ae384 100644 --- a/config-dist.php +++ b/config-dist.php @@ -868,6 +868,13 @@ $CFG->admin = 'admin'; // Example: // define('BEHAT_DISABLE_HISTOGRAM', true); // +// Mobile app Behat testing requires this option, pointing to a developer Moodle Mobile directory: +// $CFG->behat_approot = '/where/I/keep/my/git/checkouts/moodlemobile2'; +// +// The following option can be used to indicate a running Ionic server (otherwise Behat will start +// one automatically for each test run, which is convenient but takes ages): +// $CFG->behat_ionicaddress = 'http://localhost:8100'; +// //========================================================================= // 12. DEVELOPER DATA GENERATOR //========================================================================= diff --git a/course/tests/behat/app_courselist.feature b/course/tests/behat/app_courselist.feature new file mode 100644 index 00000000000..51f464d9073 --- /dev/null +++ b/course/tests/behat/app_courselist.feature @@ -0,0 +1,91 @@ +@core @core_course @app @javascript +Feature: Test course list shown on app start tab + In order to select a course + As a student + I need to see the correct list of courses + + Background: + Given the following "courses" exist: + | fullname | shortname | + | Course 1 | C1 | + | Course 2 | C2 | + And the following "users" exist: + | username | + | student1 | + | student2 | + And the following "course enrolments" exist: + | user | course | role | + | student1 | C1 | student | + | student2 | C1 | student | + | student2 | C2 | student | + + Scenario: Student is registered on one course + When I enter the app + And I log in as "student1" in the app + Then I should see "Course 1" + And I should not see "Course 2" + + Scenario: Student is registered on two courses (shortnames not displayed) + When I enter the app + And I log in as "student2" in the app + Then I should see "Course 1" + And I should see "Course 2" + And I should not see "C1" + And I should not see "C2" + + Scenario: Student is registered on two courses (shortnames displayed) + Given the following config values are set as admin: + | courselistshortnames | 1 | + When I enter the app + And I log in as "student2" in the app + Then I should see "Course 1" + And I should see "Course 2" + And I should see "C1" + And I should see "C2" + + Scenario: Student uses course list to enter course, then leaves it again + When I enter the app + And I log in as "student2" in the app + And I press "Course 2" in the app + Then the header should be "Course 2" in the app + And I press the back button in the app + Then the header should be "Acceptance test site" in the app + + Scenario: Student uses filter feature to reduce course list + Given the following config values are set as admin: + | courselistshortnames | 1 | + And the following "courses" exist: + | fullname | shortname | + | Frog 3 | C3 | + | Frog 4 | C4 | + | Course 5 | C5 | + | Toad 6 | C6 | + And the following "course enrolments" exist: + | user | course | role | + | student2 | C3 | student | + | student2 | C4 | student | + | student2 | C5 | student | + | student2 | C6 | student | + When I enter the app + And I log in as "student2" 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 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 "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" diff --git a/lib/behat/classes/behat_command.php b/lib/behat/classes/behat_command.php index 0070634326a..f6116323a4e 100644 --- a/lib/behat/classes/behat_command.php +++ b/lib/behat/classes/behat_command.php @@ -219,6 +219,12 @@ class behat_command { return BEHAT_EXITCODE_CONFIG; } + // If app config is supplied, check the value is correct. + if (!empty($CFG->behat_approot) && !file_exists($CFG->behat_approot . '/ionic.config.json')) { + self::output_msg(get_string('errorapproot', 'tool_behat')); + return BEHAT_EXITCODE_CONFIG; + } + return 0; } diff --git a/lib/behat/classes/behat_config_util.php b/lib/behat/classes/behat_config_util.php index e393580c9e6..d964e42bf6e 100644 --- a/lib/behat/classes/behat_config_util.php +++ b/lib/behat/classes/behat_config_util.php @@ -620,6 +620,42 @@ class behat_config_util { // Check suite values. $behatprofilesuites = array(); + + // Automatically set tags information to skip app testing if necessary. We skip app testing + // if the browser is not Chrome. (Note: We also skip if it's not configured, but that is + // done on the theme/suite level.) + if (empty($values['browser']) || $values['browser'] !== 'chrome') { + if (!empty($values['tags'])) { + $values['tags'] .= ' && ~@app'; + } else { + $values['tags'] = '~@app'; + } + } + + // Automatically add Chrome command line option to skip the prompt about allowing file + // storage - needed for mobile app testing (won't hurt for everything else either). + if (!empty($values['browser']) && $values['browser'] === 'chrome') { + if (!isset($values['capabilities'])) { + $values['capabilities'] = []; + } + if (!isset($values['capabilities']['chrome'])) { + $values['capabilities']['chrome'] = []; + } + if (!isset($values['capabilities']['chrome']['switches'])) { + $values['capabilities']['chrome']['switches'] = []; + } + $values['capabilities']['chrome']['switches'][] = '--unlimited-storage'; + + // If the mobile app is enabled, check its version and add appropriate tags. + if ($mobiletags = $this->get_mobile_version_tags()) { + if (!empty($values['tags'])) { + $values['tags'] .= ' && ' . $mobiletags; + } else { + $values['tags'] = $mobiletags; + } + } + } + // Fill tags information. if (isset($values['tags'])) { $behatprofilesuites = array( @@ -658,6 +694,84 @@ class behat_config_util { return array($profile => array_merge($behatprofilesuites, $behatprofileextension)); } + /** + * Gets version tags to use for the mobile app. + * + * This is based on the current mobile app version (from its package.json) and all known + * mobile app versions (based on the list appversions.json in the lib/behat directory). + * + * @return string List of tags or '' if not supporting mobile + */ + protected function get_mobile_version_tags() : string { + global $CFG; + + if (empty($CFG->behat_approot)) { + return ''; + } + + // Get app version. + $jsonpath = $CFG->behat_approot . '/package.json'; + $json = @file_get_contents($jsonpath); + if (!$json) { + throw new coding_exception('Unable to load app version from ' . $jsonpath); + } + $package = json_decode($json); + if ($package === null) { + throw new coding_exception('Invalid app package data in ' . $jsonpath); + } + $installedversion = $package->version; + + // Read all feature files to check which mobile tags are used. (Note: This could be cached + // but ideally, it is the sort of thing that really ought to be refreshed by doing a new + // Behat init. Also, at time of coding it only takes 0.3 seconds and only if app enabled.) + $usedtags = []; + foreach ($this->features as $filepath) { + $feature = file_get_contents($filepath); + // This may incorrectly detect versions used e.g. in a comment or something, but it + // doesn't do much harm if we have extra ones. + if (preg_match_all('~@app_(?:from|upto)(?:[0-9]+(?:\.[0-9]+)*)~', $feature, $matches)) { + foreach ($matches[0] as $tag) { + // Store as key in array so we don't get duplicates. + $usedtags[$tag] = true; + } + } + } + + // Set up relevant tags for each version. + $tags = []; + foreach ($usedtags as $usedtag => $ignored) { + if (!preg_match('~^@app_(from|upto)([0-9]+(?:\.[0-9]+)*)$~', $usedtag, $matches)) { + throw new coding_exception('Unexpected tag format'); + } + $direction = $matches[1]; + $version = $matches[2]; + + switch (version_compare($installedversion, $version)) { + case -1: + // Installed version OLDER than the one being considered, so do not + // include any scenarios that only run from the considered version up. + if ($direction === 'from') { + $tags[] = '~app_from' . $version; + } + break; + + case 0: + // Installed version EQUAL to the one being considered - no tags need + // excluding. + break; + + case 1: + // Installed version NEWER than the one being considered, so do not + // include any scenarios that only run up to that version. + if ($direction === 'upto') { + $tags[] = '~app_upto' . $version; + } + break; + } + } + return join(' && ', $tags); + } + /** * Attempt to split feature list into fairish buckets using timing information, if available. * Simply add each one to lightest buckets until all files allocated. @@ -1237,12 +1351,19 @@ class behat_config_util { * @return array ($blacklistfeatures, $blacklisttags, $features) */ protected function get_behat_features_for_theme($theme) { + global $CFG; // Get list of features defined by theme. $themefeatures = $this->get_tests_for_theme($theme, 'features'); $themeblacklistfeatures = $this->get_blacklisted_tests_for_theme($theme, 'features'); $themeblacklisttags = $this->get_blacklisted_tests_for_theme($theme, 'tags'); + // Mobile app tests are not theme-specific, so run only for the default theme (and if + // configured). + if (empty($CFG->behat_approot) || $theme !== $this->get_default_theme()) { + $themeblacklisttags[] = '@app'; + } + // Clean feature key and path. $features = array(); $blacklistfeatures = array(); diff --git a/lib/tests/behat/app_behat_runtime.js b/lib/tests/behat/app_behat_runtime.js new file mode 100644 index 00000000000..ca396a22e67 --- /dev/null +++ b/lib/tests/behat/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/lib/tests/behat/behat_app.php b/lib/tests/behat/behat_app.php new file mode 100644 index 00000000000..f5d723dd2c7 --- /dev/null +++ b/lib/tests/behat/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]); + } +} diff --git a/mod/forum/tests/behat/app_basic_usage.feature b/mod/forum/tests/behat/app_basic_usage.feature new file mode 100644 index 00000000000..a6a0ed6b326 --- /dev/null +++ b/mod/forum/tests/behat/app_basic_usage.feature @@ -0,0 +1,62 @@ +@mod @mod_forum @app @javascript +Feature: Test basic usage in app + In order to participate in the forum while using the mobile app + As a student + I need basic forum functionality to work + + Background: + Given the following "courses" exist: + | fullname | shortname | + | Course 1 | C1 | + And the following "users" exist: + | username | + | student1 | + And the following "course enrolments" exist: + | user | course | role | + | student1 | C1 | student | + And the following "activities" exist: + | activity | name | intro | course | idnumber | groupmode | + | forum | Test forum name | Test forum | C1 | forum | 0 | + + Scenario: Student starts a discussion + When I enter the app + And I log in as "student1" in the app + And I press "Course 1" in the app + And I press "Test forum name" in the app + And I press "Add a new discussion topic" in the app + And I set the field "Subject" to "My happy subject" in the app + And I set the field "Message" to "An awesome message" in the app + And I press "Post to forum" in the app + Then I should see "My happy subject" + And I should see "An awesome message" + + Scenario: Student posts a reply + When I enter the app + And I log in as "student1" in the app + And I press "Course 1" in the app + And I press "Test forum name" in the app + And I press "Add a new discussion topic" in the app + And I set the field "Subject" to "DiscussionSubject" in the app + And I set the field "Message" to "DiscussionMessage" in the app + And I press "Post to forum" in the app + And I press "DiscussionSubject" in the app + And I press "Reply" in the app + And I set the field "Message" to "ReplyMessage" in the app + And I press "Post to forum" in the app + Then I should see "DiscussionMessage" + And I should see "ReplyMessage" + + Scenario: Test that 'open in browser' works for forum + When I enter the app + And I change viewport size to "360x640" + And I log in as "student1" in the app + And I press "Course 1" in the app + And I press "Test forum name" in the app + And I press the page menu button in the app + And I press "Open in browser" in the app + And I switch to the browser tab opened by the app + And I log in as "student1" + Then I should see "Test forum name" + And I should see "Add a new discussion topic" + And I close the browser tab opened by the app + And I press the back button in the app