Merge pull request #14 from NoelDeMartin/ionic5

MOBILE-3738: Migrate behat tests to ionic 5
main
Pau Ferrer Ocaña 2021-05-03 14:01:48 +02:00 committed by GitHub
commit b404484100
20 changed files with 1532 additions and 46 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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,
};
})();

View File

@ -0,0 +1,631 @@
<?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__ . '/../../../../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_boolean> not)? find "(?P<text_string>(?:[^"]|\\")*)"(?: near "(?P<near_string>(?:[^"]|\\")*)")? 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<text_string>(?:[^"]|\\")*)"(?: near "(?P<near_string>(?:[^"]|\\")*)")? should(?P<not_boolean> 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 (?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->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<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->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<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->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<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->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<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) {
$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<offline_string>(?:[^"]|\\")*)"$/
* @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 . ');');
}
}

View File

@ -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