diff --git a/mod/assignment/tests/behat/app_basic_usage.feature b/mod/assignment/tests/behat/app_basic_usage.feature index 1bfb70b57..778cfe1ec 100755 --- a/mod/assignment/tests/behat/app_basic_usage.feature +++ b/mod/assignment/tests/behat/app_basic_usage.feature @@ -1,4 +1,4 @@ -@mod @mod_assign @app @javascript +@mod @mod_assign @app @app_upto3.9.4 @javascript Feature: Test basic usage of assignment activity in app In order to participate in the assignment while using the mobile app I need basic assignment functionality to work diff --git a/mod/chat/tests/behat/app_basic_usage.feature b/mod/chat/tests/behat/app_basic_usage.feature index 424c80051..5838fa4ae 100755 --- a/mod/chat/tests/behat/app_basic_usage.feature +++ b/mod/chat/tests/behat/app_basic_usage.feature @@ -1,4 +1,4 @@ -@mod @mod_chat @app @javascript +@mod @mod_chat @app @app_upto3.9.4 @javascript Feature: Test basic usage of chat in app As a student I need basic chat functionality to work diff --git a/mod/choice/tests/behat/app_basic_usage.feature b/mod/choice/tests/behat/app_basic_usage.feature index 86055ca25..22ea6aa69 100755 --- a/mod/choice/tests/behat/app_basic_usage.feature +++ b/mod/choice/tests/behat/app_basic_usage.feature @@ -1,4 +1,4 @@ -@mod @mod_choice @app @javascript +@mod @mod_choice @app @app_upto3.9.4 @javascript Feature: Test basic usage of choice activity in app In order to participate in the choice while using the mobile app As a student diff --git a/mod/comments/tests/behat/app_basic_usage.feature b/mod/comments/tests/behat/app_basic_usage.feature index e4066d82d..2bc874f98 100755 --- a/mod/comments/tests/behat/app_basic_usage.feature +++ b/mod/comments/tests/behat/app_basic_usage.feature @@ -1,4 +1,4 @@ -@mod @mod_comments @app @javascript +@mod @mod_comments @app @app_upto3.9.4 @javascript Feature: Test basic usage of comments in app In order to participate in the comments while using the mobile app As a student diff --git a/mod/course/tests/behat/app_basic_usage.feature b/mod/course/tests/behat/app_basic_usage.feature index 0e66729d6..a74a97831 100755 --- a/mod/course/tests/behat/app_basic_usage.feature +++ b/mod/course/tests/behat/app_basic_usage.feature @@ -1,4 +1,4 @@ -@mod @mod_course @app @javascript +@mod @mod_course @app @app_upto3.9.4 @javascript Feature: Test basic usage of one course in app In order to participate in one course while using the mobile app As a student diff --git a/mod/course/tests/behat/app_course_completion.feature b/mod/course/tests/behat/app_course_completion.feature index a3c2475a5..34d482722 100644 --- a/mod/course/tests/behat/app_course_completion.feature +++ b/mod/course/tests/behat/app_course_completion.feature @@ -1,4 +1,4 @@ -@core @core_course @app @javascript +@core @core_course @app @app_upto3.9.4 @javascript Feature: Check course completion feature. In order to track the progress of the course on mobile device As a student diff --git a/mod/course/tests/behat/app_courselist.feature b/mod/course/tests/behat/app_courselist.feature index 1e1bc1e1f..7a508f9f3 100644 --- a/mod/course/tests/behat/app_courselist.feature +++ b/mod/course/tests/behat/app_courselist.feature @@ -22,17 +22,17 @@ Feature: Test course list shown on app start tab Scenario: View courses (shortnames not displayed) When I enter the app And I log in as "student1" - Then I should see "Course 1" - But I should not see "Course 2" - But I should not see "C1" - But I should not see "C2" + Then I should find "Course 1" in the app + But I should not find "Course 2" in the app + But I should not find "C1" in the app + But I should not find "C2" in the app When I enter the app And I log in as "student2" - Then I should see "Course 1" - And I should see "Course 2" - But I should not see "C1" - But I should not see "C2" + Then I should find "Course 1" in the app + And I should find "Course 2" in the app + But I should not find "C1" in the app + But I should not find "C2" in the app Scenario: Filter courses Given the following config values are set as admin: @@ -78,26 +78,46 @@ Feature: Test course list shown on app start tab | student2 | Z10 | student | When I enter the app And I log in as "student2" - Then I press "Display options" near "Course overview" in the app - Then I should see "C1" - And I should see "C2" - And I should see "C3" - And I should see "C4" - And I should see "C5" - And I should see "C6" - Then I press "Filter my courses" in the app - And I set the field "Filter my courses" to "fr" in the app - Then I should not see "C1" - And I should not see "C2" - And I should see "C3" - And I should see "C4" - And I should not see "C5" - And I should not see "C6" - And I press "Display options" near "Course overview" in the app + Then I should find "C1" in the app + And I should find "C2" in the app + And I should find "C3" in the app + And I should find "C4" in the app + And I should find "C5" in the app + And I should find "C6" in the app + And I should find "Course 1" in the app + And I should find "Course 2" in the app + And I should find "Frog 3" in the app + And I should find "Frog 4" in the app + And I should find "Course 5" in the app + And I should find "Toad 6" in the app + + When I press "Display options" near "Course overview" in the app And I press "Filter my courses" in the app - Then I should see "C1" - And I should see "C2" - And I should see "C3" - And I should see "C4" - And I should see "C5" - And I should see "C6" + And I set the field "Filter my courses" to "fr" in the app + Then I should find "C3" in the app + And I should find "C4" in the app + And I should find "Frog 3" in the app + And I should find "Frog 4" in the app + But I should not find "C1" in the app + And I should not find "C2" in the app + And I should not find "C5" in the app + And I should not find "C6" in the app + And I should not find "Course 1" in the app + And I should not find "Course 2" in the app + And I should not find "Course 5" in the app + And I should not find "Toad 6" in the app + + When I press "Display options" near "Course overview" in the app + And I press "Filter my courses" in the app + Then I should find "C1" in the app + And I should find "C2" in the app + And I should find "C3" in the app + And I should find "C4" in the app + And I should find "C5" in the app + And I should find "C6" in the app + And I should find "Course 1" in the app + And I should find "Course 2" in the app + And I should find "Frog 3" in the app + And I should find "Frog 4" in the app + And I should find "Course 5" in the app + And I should find "Toad 6" in the app diff --git a/mod/courses/tests/behat/app_basic_usage.feature b/mod/courses/tests/behat/app_basic_usage.feature index fea9ce84a..e81882cde 100755 --- a/mod/courses/tests/behat/app_basic_usage.feature +++ b/mod/courses/tests/behat/app_basic_usage.feature @@ -1,4 +1,4 @@ -@mod @mod_courses @app @javascript +@mod @mod_courses @app @app_upto3.9.4 @javascript Feature: Test basic usage of courses in app In order to participate in the courses while using the mobile app As a student diff --git a/mod/data/tests/behat/app_data_entries.feature b/mod/data/tests/behat/app_data_entries.feature index 41e8ad6c5..5fb346918 100644 --- a/mod/data/tests/behat/app_data_entries.feature +++ b/mod/data/tests/behat/app_data_entries.feature @@ -1,4 +1,4 @@ -@mod @mod_data @app @javascript +@mod @mod_data @app @app_upto3.9.4 @javascript Feature: Users can manage entries in database activities In order to populate databases As a user diff --git a/mod/data/tests/behat/app_data_sync.feature b/mod/data/tests/behat/app_data_sync.feature index f0b147b95..065ffe35f 100644 --- a/mod/data/tests/behat/app_data_sync.feature +++ b/mod/data/tests/behat/app_data_sync.feature @@ -1,4 +1,4 @@ -@mod @mod_data @app @javascript +@mod @mod_data @app @app_upto3.9.4 @javascript Feature: Users can store entries in database activities when offline and sync when online In order to populate databases while offline As a user diff --git a/mod/forum/tests/behat/app_basic_usage.feature b/mod/forum/tests/behat/app_basic_usage.feature index 175c4bb52..473cbfc21 100755 --- a/mod/forum/tests/behat/app_basic_usage.feature +++ b/mod/forum/tests/behat/app_basic_usage.feature @@ -1,4 +1,4 @@ -@mod @mod_forum @app @javascript +@mod @mod_forum @app @app_upto3.9.4 @javascript Feature: Test basic usage of forum activity in app In order to participate in the forum while using the mobile app As a student diff --git a/mod/glossary/tests/behat/app_basic_usage.feature b/mod/glossary/tests/behat/app_basic_usage.feature index 2f8de6a52..db323a308 100755 --- a/mod/glossary/tests/behat/app_basic_usage.feature +++ b/mod/glossary/tests/behat/app_basic_usage.feature @@ -1,4 +1,4 @@ -@mod @mod_glossary @app @javascript +@mod @mod_glossary @app @app_upto3.9.4 @javascript Feature: Test basic usage of glossary in app In order to participate in the glossaries while using the mobile app As a student diff --git a/mod/login/tests/behat/app_basic_usage.feature b/mod/login/tests/behat/app_basic_usage.feature index 0aa02605f..fc0f78236 100755 --- a/mod/login/tests/behat/app_basic_usage.feature +++ b/mod/login/tests/behat/app_basic_usage.feature @@ -1,4 +1,4 @@ -@mod @mod_login @app @javascript +@mod @mod_login @app @app_upto3.9.4 @javascript Feature: Test basic usage of login in app I need basic login functionality to work diff --git a/mod/messages/tests/behat/app_basic_usage.feature b/mod/messages/tests/behat/app_basic_usage.feature index f33f06c13..a2aa8fa3f 100755 --- a/mod/messages/tests/behat/app_basic_usage.feature +++ b/mod/messages/tests/behat/app_basic_usage.feature @@ -1,4 +1,4 @@ -@mod @mod_messages @app @javascript +@mod @mod_messages @app @app_upto3.9.4 @javascript Feature: Test basic usage of messages in app In order to participate with messages while using the mobile app As a student diff --git a/mod/quiz/tests/behat/app_basic_usage.feature b/mod/quiz/tests/behat/app_basic_usage.feature index ca652fdcb..7b2f92086 100755 --- a/mod/quiz/tests/behat/app_basic_usage.feature +++ b/mod/quiz/tests/behat/app_basic_usage.feature @@ -1,4 +1,4 @@ -@mod @mod_quiz @app @javascript +@mod @mod_quiz @app @app_upto3.9.4 @javascript Feature: Attempt a quiz in app As a student In order to demonstrate what I know diff --git a/mod/quiz/tests/behat/app_quiz_navigation.feature b/mod/quiz/tests/behat/app_quiz_navigation.feature index c28bc1c86..75b8b7a9d 100644 --- a/mod/quiz/tests/behat/app_quiz_navigation.feature +++ b/mod/quiz/tests/behat/app_quiz_navigation.feature @@ -1,4 +1,4 @@ -@mod @mod_quiz @app @javascript +@mod @mod_quiz @app @app_upto3.9.4 @javascript Feature: Attempt a quiz in app As a student In order to demonstrate what I know diff --git a/mod/survey/tests/behat/app_basic_usage.feature b/mod/survey/tests/behat/app_basic_usage.feature index a8a324415..fcba1840e 100755 --- a/mod/survey/tests/behat/app_basic_usage.feature +++ b/mod/survey/tests/behat/app_basic_usage.feature @@ -1,4 +1,4 @@ -@mod @mod_survey @app @javascript +@mod @mod_survey @app @app_upto3.9.4 @javascript Feature: Test basic usage of survey activity in app In order to participate in surveys while using the mobile app As a student diff --git a/tests/behat/app_behat_runtime.js b/tests/behat/app_behat_runtime.js new file mode 100644 index 000000000..9a0e4bff2 --- /dev/null +++ b/tests/behat/app_behat_runtime.js @@ -0,0 +1,702 @@ +(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... + checkUIBlocked(); + + // 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 waitingBlocked = false; + + /** + * Checks if a loading spinner is present and visible; if so, adds it to the pending array + * (and if not, removes it). + */ + var checkUIBlocked = function() { + var blocked = document.querySelector('span.core-loading-spinner, ion-loading, .click-block-active'); + if (blocked && blocked.offsetParent) { + if (!waitingBlocked) { + addPending('blocked'); + waitingBlocked = true; + } + } else { + if (waitingBlocked) { + removePending('blocked'); + waitingBlocked = 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. + checkUIBlocked(); + }; + + // Set listener using the mutation callback. + var observer = new MutationObserver(mutationCallback); + observer.observe(document, {attributes: true, childList: true, subtree: true}); + + /** + * Check if an element is visible. + * + * @param {HTMLElement} element Element + * @param {HTMLElement} container Container + * @returns {boolean} Whether the element is visible or not + */ + var isElementVisible = (element, container) => { + if (element.getAttribute('aria-hidden') === 'true' || getComputedStyle(element).display === 'none') + return false; + + if (element.parentElement === container) + return true; + + if (!element.parentElement) + return false; + + return isElementVisible(element.parentElement, container); + }; + + /** + * Check if an element is selected. + * + * @param {HTMLElement} element Element + * @param {HTMLElement} container Container + * @returns {boolean} Whether the element is selected or not + */ + var isElementSelected = (element, container) => { + const ariaCurrent = element.getAttribute('aria-current'); + if (ariaCurrent && ariaCurrent !== 'false') + return true; + + if (!element.parentElement || element.parentElement === container) + return false; + + return isElementSelected(element.parentElement, container); + }; + + /** + * Generic shared function to find possible xpath matches within the document, that are visible, + * and then process them using a callback function. + * + * @param {string} xpath Xpath to use + * @param {function} process Callback function that handles each matched node + */ + var findPossibleMatches = function(xpath, process) { + var select = 'ion-alert, ion-popover, ion-action-sheet, core-ion-tab.show-tab ion-page.show-page, ion-page.show-page, html'; + var parent = document.querySelector(select); + var matches = document.evaluate(xpath, parent || document); + while (true) { + var match = matches.iterateNext(); + if (!match) { + break; + } + // Skip invisible text nodes. + if (!match.offsetParent) { + continue; + } + + process(match); + } + }; + + /** + * Finds an element within a given container. + * + * @param {HTMLElement} container Parent element to search the element within + * @param {string} text Text to look for + * @return {HTMLElement} Found element + */ + var findElementBasedOnTextWithin = (container, text) => { + const attributesSelector = `[aria-label*="${text}"], a[title*="${text}"], img[alt*="${text}"]`; + + for (const foundByAttributes of container.querySelectorAll(attributesSelector)) { + if (isElementVisible(foundByAttributes, container)) + return foundByAttributes; + } + + const treeWalker = document.createTreeWalker( + container, + NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_DOCUMENT_FRAGMENT | NodeFilter.SHOW_TEXT, + { + acceptNode: node => { + if ( + node instanceof HTMLStyleElement || + node instanceof HTMLLinkElement || + node instanceof HTMLScriptElement + ) + return NodeFilter.FILTER_REJECT; + + if ( + node instanceof HTMLElement && ( + node.getAttribute('aria-hidden') === 'true' || getComputedStyle(node).display === 'none' + ) + ) + return NodeFilter.FILTER_REJECT; + + return NodeFilter.FILTER_ACCEPT; + } + }, + ); + + let currentNode; + while (currentNode = treeWalker.nextNode()) { + if (currentNode instanceof Text) { + if (currentNode.textContent.includes(text)) { + return currentNode.parentElement; + } + + continue; + } + + const labelledBy = currentNode.getAttribute('aria-labelledby'); + if (labelledBy && container.querySelector(`#${labelledBy}`)?.innerText?.includes(text)) + return currentNode; + + if (currentNode.shadowRoot) { + for (const childNode of currentNode.shadowRoot.childNodes) { + if (!childNode) { + continue; + } + + if (childNode.matches(attributesSelector)) { + return childNode; + } + + const foundByText = findElementBasedOnTextWithin(childNode, text); + + if (foundByText) { + return foundByText; + } + } + } + } + }; + + /** + * Function to find an element based on its text or Aria label. + * + * @param {string} text Text (full or partial) + * @param {string} [near] Optional 'near' text - if specified, must have a single match on page + * @return {HTMLElement} Found element + */ + var findElementBasedOnText = function(text, near) { + const topContainer = document.querySelector('ion-alert, ion-popover, ion-action-sheet, core-ion-tab.show-tab ion-page.show-page, ion-page.show-page, html'); + let container = topContainer; + + if (topContainer && near) { + const nearElement = findElementBasedOnText(near); + + if (!nearElement) { + return; + } + + container = nearElement.parentElement; + } + + do { + const node = findElementBasedOnTextWithin(container, text); + + if (node) { + return node; + } + } while ((container = container.parentElement) && container !== topContainer); + }; + + /** + * 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 + */ + var behatPressStandard = function(button) { + log('Action - Click standard button: ' + button); + + // Find button + var foundButton = null; + + if (window.BehatMoodleAppLegacy) { + var selector; + switch (button) { + case 'back' : + selector = 'ion-navbar > button.back-button-md'; + break; + case 'main menu' : + // Change in app version 3.8. + selector = 'page-core-mainmenu .tab-button > ion-icon[aria-label=more], ' + + 'page-core-mainmenu .tab-button > ion-icon[aria-label=menu]'; + break; + case 'page menu' : + // This lang string was changed in app version 3.6. + selector = 'core-context-menu > button[aria-label=Info], ' + + 'core-context-menu > button[aria-label=Information], ' + + 'core-context-menu > button[aria-label="Display options"]'; + break; + default: + return 'ERROR: Unsupported standard button type'; + } + var buttons = Array.from(document.querySelectorAll(selector)); + var tooMany = false; + buttons.forEach(function(button) { + if (button.offsetParent) { + if (foundButton === null) { + foundButton = button; + } else { + tooMany = true; + } + } + }); + if (!foundButton) { + return 'ERROR: Could not find button'; + } + if (tooMany) { + return 'ERROR: Found too many buttons'; + } + } else { + switch (button) { + case 'back': + foundButton = findElementBasedOnText('Back'); + break; + case 'main menu': + foundButton = findElementBasedOnText('more', 'Notifications'); + break; + default: + return 'ERROR: Unsupported standard button type'; + } + } + + // Click button + 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 + */ + var 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 find an arbitrary item based on its text or aria label. + * + * @param {string} text Text (full or partial) + * @param {string} [near] Optional 'near' text + * @return {string} OK if successful, or ERROR: followed by message + */ + var behatFind = function(text, near) { + log(`Action - Find ${text}`); + + try { + const element = findElementBasedOnText(text, near); + + if (!element) { + return 'ERROR: No matches for text'; + } + + return 'OK'; + } catch (error) { + return 'ERROR: ' + error.message; + } + }; + + /** + * Get main navigation controller. + * + * @return {Object} main navigation controller. + */ + var getNavCtrl = function() { + var mainNav = window.appProvider.appCtrl.getRootNavs()[0].getActiveChildNav(); + if (mainNav && mainNav.tabsIds.length && mainNav.firstSelectedTab) { + var tabPos = mainNav.tabsIds.indexOf(mainNav.firstSelectedTab); + if (tabPos !== -1 && mainNav._tabs && mainNav._tabs.length > tabPos) { + return mainNav._tabs[tabPos]; + } + } + // Fallback to return main nav - this will work but will overlay current tab. + return window.appProvider.appCtrl.getRootNavs()[0]; + }; + + /** + * Check whether an item is selected or not. + * + * @param {string} text Text (full or partial) + * @param {string} near Optional 'near' text + * @return {string} YES or NO if successful, or ERROR: followed by message + */ + var behatIsSelected = function(text, near) { + log(`Action - Is Selected: "${text}"${near ? ` near "${near}"`: ''}`); + + try { + const element = findElementBasedOnText(text, near); + + return isElementSelected(element, document.body) ? 'YES' : 'NO'; + } catch (error) { + return 'ERROR: ' + error.message; + } + } + + /** + * Function to press arbitrary item based on its text or Aria label. + * + * @param {string} text Text (full or partial) + * @param {string} near Optional 'near' text + * @return {string} OK if successful, or ERROR: followed by message + */ + var behatPress = function(text, near) { + log('Action - Press ' + text + (near === undefined ? '' : ' - near ' + near)); + + var found; + try { + found = findElementBasedOnText(text, near); + + if (!found) { + return 'ERROR: No matches for text'; + } + } catch (error) { + return 'ERROR: ' + error.message; + } + + if (window.BehatMoodleAppLegacy) { + var mainContent = getNavCtrl().getActive().contentRef().nativeElement; + var rect = found.getBoundingClientRect(); + + // Scroll the item into view. + mainContent.scrollTo(rect.x, rect.y); + + // Simulate a mouse click on the button. + var eventOptions = {clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2, + bubbles: true, view: window, cancelable: true}; + setTimeout(function() { + found.dispatchEvent(new MouseEvent('mousedown', eventOptions)); + }, 0); + setTimeout(function() { + found.dispatchEvent(new MouseEvent('mouseup', eventOptions)); + }, 0); + setTimeout(function() { + found.dispatchEvent(new MouseEvent('click', eventOptions)); + }, 0); + } else { + found.scrollIntoView(); + setTimeout(() => found.click(), 300); + } + + // Mark busy until the button click finishes processing. + addPendingDelay(); + + return 'OK'; + }; + + /** + * Gets the currently displayed page header. + * + * @return {string} OK: followed by header text if successful, or ERROR: followed by message. + */ + var 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 ( + (window.BehatMoodleAppLegacy && title.offsetParent) || + (!window.BehatMoodleAppLegacy && isElementVisible(title, document.body)) + ) { + 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 + */ + var 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'; + }; + + // Make some functions publicly available for Behat to call. + window.behat = { + pressStandard : behatPressStandard, + closePopup : behatClosePopup, + find : behatFind, + isSelected : behatIsSelected, + press : behatPress, + setField : behatSetField, + getHeader : behatGetHeader, + }; +})(); diff --git a/tests/behat/behat_app.php b/tests/behat/behat_app.php new file mode 100644 index 000000000..8eec8fd7b --- /dev/null +++ b/tests/behat/behat_app.php @@ -0,0 +1,631 @@ +. + +/** + * 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__ . '/../../../../lib/behat/behat_base.php'); + +use Behat\Mink\Exception\DriverException; +use Behat\Mink\Exception\ExpectationException; + +/** + * 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 stdClass Object with data about launched Ionic instance (if any) */ + protected static $ionicrunning = null; + + /** @var string URL for running Ionic server */ + protected $ionicurl = ''; + + /** + * Checks if the current OS is Windows, from the point of view of task-executing-and-killing. + * + * @return bool True if Windows + */ + protected static function is_windows() : bool { + return strtoupper(substr(PHP_OS, 0, 3)) === 'WIN'; + } + + /** + * Called from behat_hooks when a new scenario starts, if it has the app tag. + * + * This updates Moodle configuration and starts Ionic running, if it isn't already. + */ + public function start_scenario() { + $this->check_behat_setup(); + $this->fix_moodle_setup(); + $this->ionicurl = $this->start_or_reuse_ionic(); + } + + /** + * Opens the Moodle app in the browser. + * + * 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() { + // Check the app tag was set. + if (!$this->has_tag('app')) { + throw new DriverException('Requires @app tag on scenario or feature.'); + } + + // Restart the browser and set its size. + $this->getSession()->restart(); + $this->resize_window('360x720', true); + + if (empty($this->ionicurl)) { + $this->ionicurl = $this->start_or_reuse_ionic(); + } + + // Go to page and prepare browser for app. + $this->prepare_browser($this->ionicurl); + } + + /** + * Finds elements in the app. + * + * @Then /^I should(?P not)? find "(?P(?:[^"]|\\")*)"(?: near "(?P(?:[^"]|\\")*)")? in the app$/ + * @param string $text + */ + public function i_find_in_the_app($not, $text='', $near='') { + $not = !empty($not); + $text = addslashes_js($text); + $near = addslashes_js($near); + + $this->spin(function() use ($not, $text, $near) { + $result = $this->evaluate_script("return window.behat.find(\"$text\", \"$near\");"); + + if ($not && $result === 'OK') { + throw new DriverException('Error, found an item that should not be found'); + } + + if (!$not && $result !== 'OK') { + throw new DriverException('Error finding item - ' . $result); + } + + return true; + }); + $this->wait_for_pending_js(); + } + + /** + * Check if elements are selected in the app. + * + * @Then /^"(?P(?:[^"]|\\")*)"(?: near "(?P(?:[^"]|\\")*)")? should(?P not)? be selected in the app$/ + * @param string $text + */ + public function be_selected_in_the_app($text, $near='', $not='') { + $not = !empty($not); + $text = addslashes_js($text); + $near = addslashes_js($near); + + $this->spin(function() use ($not, $text, $near) { + $result = $this->evaluate_script("return window.behat.isSelected(\"$text\", \"$near\");"); + + switch ($result) { + case 'YES': + if ($not) { + throw new ExpectationException("Item was selected and shouldn't have", $this->getSession()->getDriver()); + } + break; + case 'NO': + if (!$not) { + throw new ExpectationException("Item wasn't selected and should have", $this->getSession()->getDriver()); + } + break; + default: + throw new DriverException('Error finding item - ' . $result); + } + + return true; + }); + $this->wait_for_pending_js(); + } + + /** + * Checks the Behat setup - tags and configuration. + * + * @throws DriverException + */ + protected function check_behat_setup() { + global $CFG; + + // 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_ionic_wwwroot) && empty($CFG->behat_ionic_dirroot)) { + throw new DriverException('$CFG->behat_ionic_wwwroot or $CFG->behat_ionic_dirroot 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_ionic_dirroot) && !empty($CFG->behat_ionic_wwwroot)) { + // Use supplied Ionic server which should already be running. + $url = $CFG->behat_ionic_wwwroot; + } else if (self::$ionicrunning) { + // Use existing Ionic instance launched previously. + $url = self::$ionicrunning->url; + } else { + // Open Ionic process in relevant path. + $path = realpath($CFG->behat_ionic_dirroot); + $stderrfile = $CFG->dataroot . '/behat/ionic-stderr.log'; + $prefix = ''; + // Except on Windows, use 'exec' so that we get the pid of the actual Node process + // 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]; + $url = self::$ionicrunning->url; + } + 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; + + // Check whether the app is running a legacy version. + $json = @file_get_contents("$url/assets/env.json") ?: @file_get_contents("$url/config.json"); + $data = json_decode($json); + $appversion = $data->build->version ?? str_replace('-dev', '', $data->versionname); + $islegacy = version_compare($appversion, '3.9.5', '<'); + + // Visit the Ionic URL and wait for it to load. + $this->getSession()->visit($url); + $this->spin( + function($context) use ($islegacy) { + $title = $context->getSession()->getPage()->find('xpath', '//title'); + if ($title) { + $text = $title->getHtml(); + if ( + ($islegacy && $text === 'Moodle Desktop') || + (!$islegacy && $text === 'Moodle App') + ) { + return true; + } + } + throw new DriverException('Moodle app not found in browser'); + }, false, 60); + + // Run the scripts to install Moodle 'pending' checks. + $islegacyboolean = $islegacy ? 'true' : 'false'; + $this->execute_script("window.BehatMoodleAppLegacy = $islegacyboolean;"); + $this->execute_script(file_get_contents(__DIR__ . '/app_behat_runtime.js')); + + // Wait until the site login field appears OR the main page. + $situation = $this->spin( + function($context) use ($islegacy) { + $page = $context->getSession()->getPage(); + + $element = $page->find('xpath', '//page-core-login-site//input[@name="url"]'); + if ($element) { + // Wait for the onboarding modal to open, if any. + $this->wait_for_pending_js(); + $element = $islegacy + ? $page->find('xpath', '//page-core-login-site-onboarding') + : $page->find('xpath', '//core-login-site-onboarding'); + if ($element) { + $this->i_press_in_the_app('Skip'); + $this->wait_for_pending_js(); + } + + return 'login'; + } + + $element = $page->find('xpath', '//page-core-mainmenu'); + if ($element) { + return 'mainpage'; + } + throw new DriverException('Moodle app login URL prompt not found'); + }, behat_base::get_extended_timeout(), 60); + + // If it's the login page, we automatically fill in the URL and leave it on the user/pass + // page. If it's the main page, we just leave it there. + if ($situation === 'login') { + $this->i_set_the_field_in_the_app('campus.example.edu', $CFG->wwwroot); + $this->i_press_in_the_app($islegacy ? 'Connect!' : 'Connect to your site'); + } + + // Continue only after JS finishes. + $this->wait_for_pending_js(); + } + + /** + * Carries out the login steps for the app, assuming the user is on the app login page. Called + * from behat_auth.php. + * + * @param string $username Username (and password) + * @throws Exception Any error + */ + public function login(string $username) { + $this->i_set_the_field_in_the_app('Username', $username); + $this->i_set_the_field_in_the_app('Password', $username); + + // Note there are two 'Log in' texts visible (the title and the button) so we have to use + // a 'near' value 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->evaluate_script("return window.behat.pressStandard('{$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->evaluate_script("return window.behat.closePopup();"); + 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->press($text); + } + + /** + * 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->press($text, $near); + } + + /** + * Clicks on / touches something that is visible in the app, near some other text. + * + * If the $near is specified then when there are multiple matches, it picks the one + * nearest (in DOM terms) $near. $near should be an exact match, or a partial match that only + * has one result. + * + * @param behat_base $base Behat context + * @param string $text Text identifying click target + * @param string $near Text identifying a nearby unique piece of text + * @throws DriverException If the press doesn't work + */ + protected function press(string $text, string $near = '') { + $this->spin(function($context, $args) use ($text, $near) { + if ($near !== '') { + $nearbit = ', "' . addslashes_js($near) . '"'; + } else { + $nearbit = ''; + } + $result = $this->evaluate_script('return window.behat.press("' . + addslashes_js($text) . '"' . $nearbit .');'); + 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->evaluate_script('return window.behat.setField("' . + 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) { + $this->spin(function() use ($text) { + $result = $this->evaluate_script('return window.behat.getHeader();'); + + if (substr($result, 0, 3) !== 'OK:') { + throw new DriverException('Error getting header - ' . $result); + } + + $header = substr($result, 3); + if (trim($header) !== trim($text)) { + throw new ExpectationException( + "The header text was not as expected: '$header'", + $this->getSession()->getDriver() + ); + } + + return true; + }); + } + + /** + * 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->execute_script('window.close()'); + $this->getSession()->switchToWindow($names[0]); + } + + /** + * Switch navigator online mode. + * + * @Given /^I switch offline mode to "(?P(?:[^"]|\\")*)"$/ + * @param string $offline New value for navigator online mode + * @throws DriverException If the navigator.online mode is not available + */ + public function i_switch_offline_mode(string $offline) { + $this->execute_script('appProvider.setForceOffline(' . $offline . ');'); + } +} diff --git a/tests/behat/navigation.feature b/tests/behat/navigation.feature new file mode 100644 index 000000000..5a02138e5 --- /dev/null +++ b/tests/behat/navigation.feature @@ -0,0 +1,133 @@ +@app @javascript +Feature: It navigates properly between pages. + + Background: + Given the following "users" exist: + | username | + | student1 | + Given the following "courses" exist: + | fullname | shortname | + | Course 2 | C2 | + | Course 1 | C1 | + And the following "course enrolments" exist: + | user | course | role | + | student1 | C1 | student | + | student1 | C2 | student | + And the following "grade categories" exist: + | fullname | course | + | Grade category C1 | C1 | + | Grade category C2 | C2 | + And the following "grade items" exist: + | gradecategory | itemname | grademin | grademax | course | + | Grade category C1 | Grade item C1 | 20 | 40 | C1 | + | Grade category C2 | Grade item C2 | 60 | 80 | C2 | + + Scenario: Navigate between split-view items in mobiles + + # Open more tab + Given I enter the app + And I log in as "student1" + And I press the main menu button in the app + + # Open grades tab + When I press "Grades" in the app + Then the header should be "Grades" in the app + And I should find "Course 1" in the app + And I should find "Course 2" in the app + + # Open C1 course grades + When I press "Course 1" in the app + Then the header should be "Grades" in the app + And I should find "Grade category C1" in the app + + # Open C1 grade item + When I press "Grade item C1" in the app + Then the header should be "Grade" in the app + And I should find "20" near "Range" in the app + And I should find "40" near "Range" in the app + + # Go back to course grades + When I press the back button in the app + Then the header should be "Grades" in the app + And I should find "Grade category C1" in the app + + # Go back to grades tab + When I press the back button in the app + Then the header should be "Grades" in the app + And I should find "Course 1" in the app + And I should find "Course 2" in the app + + # Open C2 course grades + When I press "Course 2" in the app + Then the header should be "Grades" in the app + And I should find "Grade category C2" in the app + + # Open C2 grade item + When I press "Grade item C2" in the app + Then the header should be "Grade" in the app + And I should find "60" near "Range" in the app + And I should find "80" near "Range" in the app + + # Go back to course grades + When I press the back button in the app + Then the header should be "Grades" in the app + And I should find "Grade category C2" in the app + + # Go back to grades tab + When I press the back button in the app + Then the header should be "Grades" in the app + And I should find "Course 1" in the app + And I should find "Course 2" in the app + + # Go back to more tab + When I press the back button in the app + Then I should find "Grades" in the app + And I should find "App settings" in the app + But I should not find "Back" in the app + + Scenario: Navigate between split-view items in tablets + + # Open more tab + Given I enter the app + And I change viewport size to "1200x640" + And I log in as "student1" + + # Open grades tab + When I press "Grades" in the app + Then the header should be "Grades" in the app + And I should find "Course 1" in the app + And I should find "Course 2" in the app + And I should find "Grade category C1" in the app + + # Open C1 course grades + When I press "Grade item C1" in the app + Then the header should be "Grades" in the app + And I should find "Grade category C1" in the app + And I should find "20" near "Range" in the app + And I should find "40" near "Range" in the app + + # Go back to grades tab + When I press the back button in the app + Then the header should be "Grades" in the app + And I should find "Course 1" in the app + And I should find "Course 2" in the app + + # Select C2 course + When I press "Course 2" in the app + Then the header should be "Grades" in the app + And "Course 2" should be selected in the app + And I should find "Grade category C2" in the app + + # Open C2 course grades + When I press "Grade item C2" in the app + Then the header should be "Grades" in the app + And I should find "Grade category C2" in the app + And I should find "60" near "Range" in the app + And I should find "80" near "Range" in the app + + # Go back to grades tab + When I press the back button in the app + Then the header should be "Grades" in the app + And I should find "Course 1" in the app + And I should find "Course 2" in the app + But I should not find "Back" in the app