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 Feature: Test basic usage of assignment activity in app
In order to participate in the assignment while using the mobile app In order to participate in the assignment while using the mobile app
I need basic assignment functionality to work 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 Feature: Test basic usage of chat in app
As a student As a student
I need basic chat functionality to work 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 Feature: Test basic usage of choice activity in app
In order to participate in the choice while using the mobile app In order to participate in the choice while using the mobile app
As a student 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 Feature: Test basic usage of comments in app
In order to participate in the comments while using the mobile app In order to participate in the comments while using the mobile app
As a student 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 Feature: Test basic usage of one course in app
In order to participate in one course while using the mobile app In order to participate in one course while using the mobile app
As a student 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. Feature: Check course completion feature.
In order to track the progress of the course on mobile device In order to track the progress of the course on mobile device
As a student As a student

View File

@ -22,17 +22,17 @@ Feature: Test course list shown on app start tab
Scenario: View courses (shortnames not displayed) Scenario: View courses (shortnames not displayed)
When I enter the app When I enter the app
And I log in as "student1" And I log in as "student1"
Then I should see "Course 1" Then I should find "Course 1" in the app
But I should not see "Course 2" But I should not find "Course 2" in the app
But I should not see "C1" But I should not find "C1" in the app
But I should not see "C2" But I should not find "C2" in the app
When I enter the app When I enter the app
And I log in as "student2" And I log in as "student2"
Then I should see "Course 1" Then I should find "Course 1" in the app
And I should see "Course 2" And I should find "Course 2" in the app
But I should not see "C1" But I should not find "C1" in the app
But I should not see "C2" But I should not find "C2" in the app
Scenario: Filter courses Scenario: Filter courses
Given the following config values are set as admin: 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 | | student2 | Z10 | student |
When I enter the app When I enter the app
And I log in as "student2" And I log in as "student2"
Then I press "Display options" near "Course overview" in the app Then I should find "C1" in the app
Then I should see "C1" And I should find "C2" in the app
And I should see "C2" And I should find "C3" in the app
And I should see "C3" And I should find "C4" in the app
And I should see "C4" And I should find "C5" in the app
And I should see "C5" And I should find "C6" in the app
And I should see "C6" And I should find "Course 1" in the app
Then I press "Filter my courses" in the app And I should find "Course 2" in the app
And I set the field "Filter my courses" to "fr" in the app And I should find "Frog 3" in the app
Then I should not see "C1" And I should find "Frog 4" in the app
And I should not see "C2" And I should find "Course 5" in the app
And I should see "C3" And I should find "Toad 6" in the app
And I should see "C4"
And I should not see "C5" When I press "Display options" near "Course overview" in the app
And I should not see "C6"
And I press "Display options" near "Course overview" in the app
And I press "Filter my courses" in the app And I press "Filter my courses" in the app
Then I should see "C1" And I set the field "Filter my courses" to "fr" in the app
And I should see "C2" Then I should find "C3" in the app
And I should see "C3" And I should find "C4" in the app
And I should see "C4" And I should find "Frog 3" in the app
And I should see "C5" And I should find "Frog 4" in the app
And I should see "C6" 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 Feature: Test basic usage of courses in app
In order to participate in the courses while using the mobile app In order to participate in the courses while using the mobile app
As a student 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 Feature: Users can manage entries in database activities
In order to populate databases In order to populate databases
As a user 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 Feature: Users can store entries in database activities when offline and sync when online
In order to populate databases while offline In order to populate databases while offline
As a user 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 Feature: Test basic usage of forum activity in app
In order to participate in the forum while using the mobile app In order to participate in the forum while using the mobile app
As a student 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 Feature: Test basic usage of glossary in app
In order to participate in the glossaries while using the mobile app In order to participate in the glossaries while using the mobile app
As a student 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 Feature: Test basic usage of login in app
I need basic login functionality to work 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 Feature: Test basic usage of messages in app
In order to participate with messages while using the mobile app In order to participate with messages while using the mobile app
As a student 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 Feature: Attempt a quiz in app
As a student As a student
In order to demonstrate what I know 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 Feature: Attempt a quiz in app
As a student As a student
In order to demonstrate what I know 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 Feature: Test basic usage of survey activity in app
In order to participate in surveys while using the mobile app In order to participate in surveys while using the mobile app
As a student 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