MDL-63977 Behat: Allow Behat testing of the Moodle mobile app
authorsam marshall <s.marshall@open.ac.uk>
Mon, 12 Nov 2018 12:11:06 +0000 (12:11 +0000)
committersam marshall <s.marshall@open.ac.uk>
Mon, 11 Feb 2019 16:20:42 +0000 (16:20 +0000)
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
config-dist.php
course/tests/behat/app_courselist.feature [new file with mode: 0644]
lib/behat/classes/behat_command.php
lib/behat/classes/behat_config_util.php
lib/tests/behat/app_behat_runtime.js [new file with mode: 0644]
lib/tests/behat/behat_app.php [new file with mode: 0644]
mod/forum/tests/behat/app_basic_usage.feature [new file with mode: 0644]

index f2cd279..776fb1d 100644 (file)
@@ -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.';
index 9cd8bba..e29135a 100644 (file)
@@ -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 (file)
index 0000000..51f464d
--- /dev/null
@@ -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"
index 0070634..f611632 100644 (file)
@@ -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;
     }
 
index e393580..d964e42 100644 (file)
@@ -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 (file)
index 0000000..ca396a2
--- /dev/null
@@ -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 (file)
index 0000000..f5d723d
--- /dev/null
@@ -0,0 +1,524 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * 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<username_string>(?:[^"]|\\")*)" 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 (?P<button_name>back|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<text_string>(?:[^"]|\\")*)" 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<text_string>(?:[^"]|\\")*)" near "(?P<nearby_string>(?:[^"]|\\")*)" 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<field_name>(?:[^"]|\\")*)" to "(?P<text_string>(?:[^"]|\\")*)" 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<text_string>(?:[^"]|\\")*)" 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 (file)
index 0000000..a6a0ed6
--- /dev/null
@@ -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