mirror of
https://github.com/moodle/moodle.git
synced 2025-01-18 05:58:34 +01:00
Merge branch 'MDL-63977-beforemerge' of https://github.com/sammarshallou/moodle
This commit is contained in:
commit
d1570a6f52
@ -24,6 +24,7 @@
|
||||
|
||||
$string['aim'] = 'This administration tool helps developers and test writers to create .feature files describing Moodle\'s functionalities and run them automatically. Step definitions available for use in .feature files are listed below.';
|
||||
$string['allavailablesteps'] = 'All available step definitions';
|
||||
$string['errorapproot'] = '$CFG->behat_ionic_dirroot is not pointing to a valid Moodle Mobile developer install.';
|
||||
$string['errorbehatcommand'] = 'Error running behat CLI command. Try running "{$a} --help" manually from CLI to find out more about the problem.';
|
||||
$string['errorcomposer'] = 'Composer dependencies are not installed.';
|
||||
$string['errordataroot'] = '$CFG->behat_dataroot is not set or is invalid.';
|
||||
|
@ -44,6 +44,12 @@ class behat_auth extends behat_base {
|
||||
* @Given /^I log in as "(?P<username_string>(?:[^"]|\\")*)"$/
|
||||
*/
|
||||
public function i_log_in_as($username) {
|
||||
// In the mobile app the required tasks are different.
|
||||
if ($this->is_in_app()) {
|
||||
$this->execute('behat_app::login', [$username]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Visit login page.
|
||||
$this->getSession()->visit($this->locate_path('login/index.php'));
|
||||
|
||||
|
@ -868,6 +868,13 @@ $CFG->admin = 'admin';
|
||||
// Example:
|
||||
// define('BEHAT_DISABLE_HISTOGRAM', true);
|
||||
//
|
||||
// Mobile app Behat testing requires this option, pointing to a developer Moodle Mobile directory:
|
||||
// $CFG->behat_ionic_dirroot = '/where/I/keep/my/git/checkouts/moodlemobile2';
|
||||
//
|
||||
// The following option can be used to indicate a running Ionic server (otherwise Behat will start
|
||||
// one automatically for each test run, which is convenient but takes ages):
|
||||
// $CFG->behat_ionic_wwwroot = 'http://localhost:8100';
|
||||
//
|
||||
//=========================================================================
|
||||
// 12. DEVELOPER DATA GENERATOR
|
||||
//=========================================================================
|
||||
|
120
course/tests/behat/app_courselist.feature
Normal file
120
course/tests/behat/app_courselist.feature
Normal file
@ -0,0 +1,120 @@
|
||||
@core @core_course @app @javascript
|
||||
Feature: Test course list shown on app start tab
|
||||
In order to select a course
|
||||
As a student
|
||||
I need to see the correct list of courses
|
||||
|
||||
Background:
|
||||
Given the following "courses" exist:
|
||||
| fullname | shortname |
|
||||
| Course 1 | C1 |
|
||||
| Course 2 | C2 |
|
||||
And the following "users" exist:
|
||||
| username |
|
||||
| student1 |
|
||||
| student2 |
|
||||
And the following "course enrolments" exist:
|
||||
| user | course | role |
|
||||
| student1 | C1 | student |
|
||||
| student2 | C1 | student |
|
||||
| student2 | C2 | student |
|
||||
|
||||
Scenario: Student is registered on one course
|
||||
When I enter the app
|
||||
And I log in as "student1"
|
||||
Then I should see "Course 1"
|
||||
And I should not see "Course 2"
|
||||
|
||||
Scenario: Student is registered on two courses (shortnames not displayed)
|
||||
When I enter the app
|
||||
And I log in as "student2"
|
||||
Then I should see "Course 1"
|
||||
And I should see "Course 2"
|
||||
And I should not see "C1"
|
||||
And I should not see "C2"
|
||||
|
||||
Scenario: Student is registered on two courses (shortnames displayed)
|
||||
Given the following config values are set as admin:
|
||||
| courselistshortnames | 1 |
|
||||
When I enter the app
|
||||
And I log in as "student2"
|
||||
Then I should see "Course 1"
|
||||
And I should see "Course 2"
|
||||
And I should see "C1"
|
||||
And I should see "C2"
|
||||
|
||||
Scenario: Student uses course list to enter course, then leaves it again
|
||||
When I enter the app
|
||||
And I log in as "student2"
|
||||
And I press "Course 2" near "Course overview" in the app
|
||||
Then the header should be "Course 2" in the app
|
||||
And I press the back button in the app
|
||||
Then the header should be "Acceptance test site" in the app
|
||||
|
||||
Scenario: Student uses filter feature to reduce course list
|
||||
Given the following config values are set as admin:
|
||||
| courselistshortnames | 1 |
|
||||
And the following "courses" exist:
|
||||
| fullname | shortname |
|
||||
| Frog 3 | C3 |
|
||||
| Frog 4 | C4 |
|
||||
| Course 5 | C5 |
|
||||
| Toad 6 | C6 |
|
||||
And the following "course enrolments" exist:
|
||||
| user | course | role |
|
||||
| student2 | C3 | student |
|
||||
| student2 | C4 | student |
|
||||
| student2 | C5 | student |
|
||||
| student2 | C6 | student |
|
||||
# Create bogus courses so that the main ones aren't shown in the 'recently accessed' part.
|
||||
# Because these come later in alphabetical order, they may not be displayed in the lower part
|
||||
# which is OK.
|
||||
And the following "courses" exist:
|
||||
| fullname | shortname |
|
||||
| Zogus 1 | Z1 |
|
||||
| Zogus 2 | Z2 |
|
||||
| Zogus 3 | Z3 |
|
||||
| Zogus 4 | Z4 |
|
||||
| Zogus 5 | Z5 |
|
||||
| Zogus 6 | Z6 |
|
||||
| Zogus 7 | Z7 |
|
||||
| Zogus 8 | Z8 |
|
||||
| Zogus 9 | Z9 |
|
||||
| Zogus 10 | Z10 |
|
||||
And the following "course enrolments" exist:
|
||||
| user | course | role |
|
||||
| student2 | Z1 | student |
|
||||
| student2 | Z2 | student |
|
||||
| student2 | Z3 | student |
|
||||
| student2 | Z4 | student |
|
||||
| student2 | Z5 | student |
|
||||
| student2 | Z6 | student |
|
||||
| student2 | Z7 | student |
|
||||
| student2 | Z8 | student |
|
||||
| student2 | Z9 | student |
|
||||
| student2 | Z10 | student |
|
||||
When I enter the app
|
||||
And I log in as "student2"
|
||||
Then I should see "C1"
|
||||
And I should see "C2"
|
||||
And I should see "C3"
|
||||
And I should see "C4"
|
||||
And I should see "C5"
|
||||
And I should see "C6"
|
||||
And I press "more" near "Course overview" in the app
|
||||
And I press "Filter my courses" in the app
|
||||
And I set the field "Filter my courses" to "fr" in the app
|
||||
Then I should not see "C1"
|
||||
And I should not see "C2"
|
||||
And I should see "C3"
|
||||
And I should see "C4"
|
||||
And I should not see "C5"
|
||||
And I should not see "C6"
|
||||
And I press "more" 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"
|
@ -474,6 +474,21 @@ class behat_base extends Behat\MinkExtension\Context\RawMinkContext {
|
||||
return get_class($this->getSession()->getDriver()) !== 'Behat\Mink\Driver\GoutteDriver';
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the current page is part of the mobile app.
|
||||
*
|
||||
* @return bool True if it's in the app
|
||||
*/
|
||||
protected function is_in_app() : bool {
|
||||
// Cannot be in the app if there's no @app tag on scenario.
|
||||
if (!$this->has_tag('app')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check on page to see if it's an app page. Safest way is to look for added JavaScript.
|
||||
return $this->getSession()->evaluateScript('typeof window.behat') === 'object';
|
||||
}
|
||||
|
||||
/**
|
||||
* Spins around an element until it exists
|
||||
*
|
||||
@ -647,6 +662,16 @@ class behat_base extends Behat\MinkExtension\Context\RawMinkContext {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the current scenario, or its feature, has a specified tag.
|
||||
*
|
||||
* @param string $tag Tag to check
|
||||
* @return bool True if the tag exists in scenario or feature
|
||||
*/
|
||||
public function has_tag(string $tag) : bool {
|
||||
return array_key_exists($tag, behat_hooks::get_tags_for_scenario());
|
||||
}
|
||||
|
||||
/**
|
||||
* Change browser window size.
|
||||
* - small: 640x480
|
||||
|
@ -219,6 +219,12 @@ class behat_command {
|
||||
return BEHAT_EXITCODE_CONFIG;
|
||||
}
|
||||
|
||||
// If app config is supplied, check the value is correct.
|
||||
if (!empty($CFG->behat_ionic_dirroot) && !file_exists($CFG->behat_ionic_dirroot . '/ionic.config.json')) {
|
||||
self::output_msg(get_string('errorapproot', 'tool_behat'));
|
||||
return BEHAT_EXITCODE_CONFIG;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
@ -620,6 +620,42 @@ class behat_config_util {
|
||||
|
||||
// Check suite values.
|
||||
$behatprofilesuites = array();
|
||||
|
||||
// Automatically set tags information to skip app testing if necessary. We skip app testing
|
||||
// if the browser is not Chrome. (Note: We also skip if it's not configured, but that is
|
||||
// done on the theme/suite level.)
|
||||
if (empty($values['browser']) || $values['browser'] !== 'chrome') {
|
||||
if (!empty($values['tags'])) {
|
||||
$values['tags'] .= ' && ~@app';
|
||||
} else {
|
||||
$values['tags'] = '~@app';
|
||||
}
|
||||
}
|
||||
|
||||
// Automatically add Chrome command line option to skip the prompt about allowing file
|
||||
// storage - needed for mobile app testing (won't hurt for everything else either).
|
||||
if (!empty($values['browser']) && $values['browser'] === 'chrome') {
|
||||
if (!isset($values['capabilities'])) {
|
||||
$values['capabilities'] = [];
|
||||
}
|
||||
if (!isset($values['capabilities']['chrome'])) {
|
||||
$values['capabilities']['chrome'] = [];
|
||||
}
|
||||
if (!isset($values['capabilities']['chrome']['switches'])) {
|
||||
$values['capabilities']['chrome']['switches'] = [];
|
||||
}
|
||||
$values['capabilities']['chrome']['switches'][] = '--unlimited-storage';
|
||||
|
||||
// If the mobile app is enabled, check its version and add appropriate tags.
|
||||
if ($mobiletags = $this->get_mobile_version_tags()) {
|
||||
if (!empty($values['tags'])) {
|
||||
$values['tags'] .= ' && ' . $mobiletags;
|
||||
} else {
|
||||
$values['tags'] = $mobiletags;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fill tags information.
|
||||
if (isset($values['tags'])) {
|
||||
$behatprofilesuites = array(
|
||||
@ -658,6 +694,102 @@ class behat_config_util {
|
||||
return array($profile => array_merge($behatprofilesuites, $behatprofileextension));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets version tags to use for the mobile app.
|
||||
*
|
||||
* This is based on the current mobile app version (from its package.json) and all known
|
||||
* mobile app versions (based on the list appversions.json in the lib/behat directory).
|
||||
*
|
||||
* @param bool $verbose If true, outputs information about installed app version
|
||||
* @return string List of tags or '' if not supporting mobile
|
||||
*/
|
||||
protected function get_mobile_version_tags($verbose = true) : string {
|
||||
global $CFG;
|
||||
|
||||
if (!empty($CFG->behat_ionic_dirroot)) {
|
||||
// Get app version from package.json.
|
||||
$jsonpath = $CFG->behat_ionic_dirroot . '/package.json';
|
||||
$json = @file_get_contents($jsonpath);
|
||||
if (!$json) {
|
||||
throw new coding_exception('Unable to load app version from ' . $jsonpath);
|
||||
}
|
||||
$package = json_decode($json);
|
||||
if ($package === null || empty($package->version)) {
|
||||
throw new coding_exception('Invalid app package data in ' . $jsonpath);
|
||||
}
|
||||
$installedversion = $package->version;
|
||||
} else if (!empty($CFG->behat_ionic_wwwroot)) {
|
||||
// Get app version from config.json inside wwwroot.
|
||||
$jsonurl = $CFG->behat_ionic_wwwroot . '/config.json';
|
||||
$json = @download_file_content($jsonurl);
|
||||
if (!$json) {
|
||||
throw new coding_exception('Unable to load app version from ' . $jsonurl);
|
||||
}
|
||||
$config = json_decode($json);
|
||||
if ($config === null || empty($config->versionname)) {
|
||||
throw new coding_exception('Invalid app config data in ' . $jsonurl);
|
||||
}
|
||||
$installedversion = str_replace('-dev', '', $config->versionname);
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Read all feature files to check which mobile tags are used. (Note: This could be cached
|
||||
// but ideally, it is the sort of thing that really ought to be refreshed by doing a new
|
||||
// Behat init. Also, at time of coding it only takes 0.3 seconds and only if app enabled.)
|
||||
$usedtags = [];
|
||||
foreach ($this->features as $filepath) {
|
||||
$feature = file_get_contents($filepath);
|
||||
// This may incorrectly detect versions used e.g. in a comment or something, but it
|
||||
// doesn't do much harm if we have extra ones.
|
||||
if (preg_match_all('~@app_(?:from|upto)(?:[0-9]+(?:\.[0-9]+)*)~', $feature, $matches)) {
|
||||
foreach ($matches[0] as $tag) {
|
||||
// Store as key in array so we don't get duplicates.
|
||||
$usedtags[$tag] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set up relevant tags for each version.
|
||||
$tags = [];
|
||||
foreach ($usedtags as $usedtag => $ignored) {
|
||||
if (!preg_match('~^@app_(from|upto)([0-9]+(?:\.[0-9]+)*)$~', $usedtag, $matches)) {
|
||||
throw new coding_exception('Unexpected tag format');
|
||||
}
|
||||
$direction = $matches[1];
|
||||
$version = $matches[2];
|
||||
|
||||
switch (version_compare($installedversion, $version)) {
|
||||
case -1:
|
||||
// Installed version OLDER than the one being considered, so do not
|
||||
// include any scenarios that only run from the considered version up.
|
||||
if ($direction === 'from') {
|
||||
$tags[] = '~@app_from' . $version;
|
||||
}
|
||||
break;
|
||||
|
||||
case 0:
|
||||
// Installed version EQUAL to the one being considered - no tags need
|
||||
// excluding.
|
||||
break;
|
||||
|
||||
case 1:
|
||||
// Installed version NEWER than the one being considered, so do not
|
||||
// include any scenarios that only run up to that version.
|
||||
if ($direction === 'upto') {
|
||||
$tags[] = '~@app_upto' . $version;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($verbose) {
|
||||
mtrace('Configured app tests for version ' . $installedversion);
|
||||
}
|
||||
|
||||
return join(' && ', $tags);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to split feature list into fairish buckets using timing information, if available.
|
||||
* Simply add each one to lightest buckets until all files allocated.
|
||||
@ -1237,12 +1369,20 @@ class behat_config_util {
|
||||
* @return array ($blacklistfeatures, $blacklisttags, $features)
|
||||
*/
|
||||
protected function get_behat_features_for_theme($theme) {
|
||||
global $CFG;
|
||||
|
||||
// Get list of features defined by theme.
|
||||
$themefeatures = $this->get_tests_for_theme($theme, 'features');
|
||||
$themeblacklistfeatures = $this->get_blacklisted_tests_for_theme($theme, 'features');
|
||||
$themeblacklisttags = $this->get_blacklisted_tests_for_theme($theme, 'tags');
|
||||
|
||||
// Mobile app tests are not theme-specific, so run only for the default theme (and if
|
||||
// configured).
|
||||
if ((empty($CFG->behat_ionic_dirroot) && empty($CFG->behat_ionic_wwwroot)) ||
|
||||
$theme !== $this->get_default_theme()) {
|
||||
$themeblacklisttags[] = '@app';
|
||||
}
|
||||
|
||||
// Clean feature key and path.
|
||||
$features = array();
|
||||
$blacklistfeatures = array();
|
||||
|
647
lib/tests/behat/app_behat_runtime.js
Normal file
647
lib/tests/behat/app_behat_runtime.js
Normal file
@ -0,0 +1,647 @@
|
||||
(function() {
|
||||
// Set up the M object - only pending_js is implemented.
|
||||
window.M = window.M ? window.M : {};
|
||||
var M = window.M;
|
||||
M.util = M.util ? M.util : {};
|
||||
M.util.pending_js = M.util.pending_js ? M.util.pending_js : []; // eslint-disable-line camelcase
|
||||
|
||||
/**
|
||||
* Logs information from this Behat runtime JavaScript, including the time and the 'BEHAT'
|
||||
* keyword so we can easily filter for it if needed.
|
||||
*
|
||||
* @param {string} text Information to log
|
||||
*/
|
||||
var log = function(text) {
|
||||
var now = new Date();
|
||||
var nowFormatted = String(now.getHours()).padStart(2, '0') + ':' +
|
||||
String(now.getMinutes()).padStart(2, '0') + ':' +
|
||||
String(now.getSeconds()).padStart(2, '0') + '.' +
|
||||
String(now.getMilliseconds()).padStart(2, '0');
|
||||
console.log('BEHAT: ' + nowFormatted + ' ' + text); // eslint-disable-line no-console
|
||||
};
|
||||
|
||||
/**
|
||||
* Run after several setTimeouts to ensure queued events are finished.
|
||||
*
|
||||
* @param {function} target function to run
|
||||
* @param {number} count Number of times to do setTimeout (leave blank for 10)
|
||||
*/
|
||||
var runAfterEverything = function(target, count) {
|
||||
if (count === undefined) {
|
||||
count = 10;
|
||||
}
|
||||
setTimeout(function() {
|
||||
count--;
|
||||
if (count == 0) {
|
||||
target();
|
||||
} else {
|
||||
runAfterEverything(target, count);
|
||||
}
|
||||
}, 0);
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds a pending key to the array.
|
||||
*
|
||||
* @param {string} key Key to add
|
||||
*/
|
||||
var addPending = function(key) {
|
||||
// Add a special DELAY entry whenever another entry is added.
|
||||
if (window.M.util.pending_js.length == 0) {
|
||||
window.M.util.pending_js.push('DELAY');
|
||||
}
|
||||
window.M.util.pending_js.push(key);
|
||||
|
||||
log('PENDING+: ' + window.M.util.pending_js);
|
||||
};
|
||||
|
||||
/**
|
||||
* Removes a pending key from the array. If this would clear the array, the actual clear only
|
||||
* takes effect after the queued events are finished.
|
||||
*
|
||||
* @param {string} key Key to remove
|
||||
*/
|
||||
var removePending = function(key) {
|
||||
// Remove the key immediately.
|
||||
window.M.util.pending_js = window.M.util.pending_js.filter(function(x) { // eslint-disable-line camelcase
|
||||
return x !== key;
|
||||
});
|
||||
log('PENDING-: ' + window.M.util.pending_js);
|
||||
|
||||
// If the only thing left is DELAY, then remove that as well, later...
|
||||
if (window.M.util.pending_js.length === 1) {
|
||||
runAfterEverything(function() {
|
||||
// Check there isn't a spinner...
|
||||
updateSpinner();
|
||||
|
||||
// Only remove it if the pending array is STILL empty after all that.
|
||||
if (window.M.util.pending_js.length === 1) {
|
||||
window.M.util.pending_js = []; // eslint-disable-line camelcase
|
||||
log('PENDING-: ' + window.M.util.pending_js);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds a pending key to the array, but removes it after some setTimeouts finish.
|
||||
*/
|
||||
var addPendingDelay = function() {
|
||||
addPending('...');
|
||||
removePending('...');
|
||||
};
|
||||
|
||||
// Override XMLHttpRequest to mark things pending while there is a request waiting.
|
||||
var realOpen = XMLHttpRequest.prototype.open;
|
||||
var requestIndex = 0;
|
||||
XMLHttpRequest.prototype.open = function() {
|
||||
var index = requestIndex++;
|
||||
var key = 'httprequest-' + index;
|
||||
|
||||
// Add to the list of pending requests.
|
||||
addPending(key);
|
||||
|
||||
// Detect when it finishes and remove it from the list.
|
||||
this.addEventListener('loadend', function() {
|
||||
removePending(key);
|
||||
});
|
||||
|
||||
return realOpen.apply(this, arguments);
|
||||
};
|
||||
|
||||
var waitingSpinner = false;
|
||||
|
||||
/**
|
||||
* Checks if a loading spinner is present and visible; if so, adds it to the pending array
|
||||
* (and if not, removes it).
|
||||
*/
|
||||
var updateSpinner = function() {
|
||||
var spinner = document.querySelector('span.core-loading-spinner');
|
||||
if (spinner && spinner.offsetParent) {
|
||||
if (!waitingSpinner) {
|
||||
addPending('spinner');
|
||||
waitingSpinner = true;
|
||||
}
|
||||
} else {
|
||||
if (waitingSpinner) {
|
||||
removePending('spinner');
|
||||
waitingSpinner = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// It would be really beautiful if you could detect CSS transitions and animations, that would
|
||||
// cover almost everything, but sadly there is no way to do this because the transitionstart
|
||||
// and animationcancel events are not implemented in Chrome, so we cannot detect either of
|
||||
// these reliably. Instead, we have to look for any DOM changes and do horrible polling. Most
|
||||
// of the animations are set to 500ms so we allow it to continue from 500ms after any DOM
|
||||
// change.
|
||||
|
||||
var recentMutation = false;
|
||||
var lastMutation;
|
||||
|
||||
/**
|
||||
* Called from the mutation callback to remove the pending tag after 500ms if nothing else
|
||||
* gets mutated.
|
||||
*
|
||||
* This will be called after 500ms, then every 100ms until there have been no mutation events
|
||||
* for 500ms.
|
||||
*/
|
||||
var pollRecentMutation = function() {
|
||||
if (Date.now() - lastMutation > 500) {
|
||||
recentMutation = false;
|
||||
removePending('dom-mutation');
|
||||
} else {
|
||||
setTimeout(pollRecentMutation, 100);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Mutation callback, called whenever the DOM is mutated.
|
||||
*/
|
||||
var mutationCallback = function() {
|
||||
lastMutation = Date.now();
|
||||
if (!recentMutation) {
|
||||
recentMutation = true;
|
||||
addPending('dom-mutation');
|
||||
setTimeout(pollRecentMutation, 500);
|
||||
}
|
||||
// Also update the spinner presence if needed.
|
||||
updateSpinner();
|
||||
};
|
||||
|
||||
// Set listener using the mutation callback.
|
||||
var observer = new MutationObserver(mutationCallback);
|
||||
observer.observe(document, {attributes: true, childList: true, subtree: true});
|
||||
|
||||
/**
|
||||
* Generic shared function to find possible xpath matches within the document, that are visible,
|
||||
* and then process them using a callback function.
|
||||
*
|
||||
* @param {string} xpath Xpath to use
|
||||
* @param {function} process Callback function that handles each matched node
|
||||
*/
|
||||
var findPossibleMatches = function(xpath, process) {
|
||||
var matches = document.evaluate(xpath, document);
|
||||
while (true) {
|
||||
var match = matches.iterateNext();
|
||||
if (!match) {
|
||||
break;
|
||||
}
|
||||
// Skip invisible text nodes.
|
||||
if (!match.offsetParent) {
|
||||
continue;
|
||||
}
|
||||
|
||||
process(match);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Function to find an element based on its text or Aria label.
|
||||
*
|
||||
* @param {string} text Text (full or partial)
|
||||
* @param {string} [near] Optional 'near' text - if specified, must have a single match on page
|
||||
* @return {HTMLElement} Found element
|
||||
* @throws {string} Error message beginning 'ERROR:' if something went wrong
|
||||
*/
|
||||
var findElementBasedOnText = function(text, near) {
|
||||
// Find all the elements that contain this text (and don't have a child element that
|
||||
// contains it - i.e. the most specific elements).
|
||||
var escapedText = text.replace('"', '""');
|
||||
var exactMatches = [];
|
||||
var anyMatches = [];
|
||||
findPossibleMatches('//*[contains(normalize-space(.), "' + escapedText +
|
||||
'") and not(child::*[contains(normalize-space(.), "' + escapedText + '")])]',
|
||||
function(match) {
|
||||
// Get the text. Note that innerText returns capitalised values for Android buttons
|
||||
// for some reason, so we'll have to do a case-insensitive match.
|
||||
var matchText = match.innerText.trim().toLowerCase();
|
||||
|
||||
// Let's just check - is this actually a label for something else? If so we will click
|
||||
// that other thing instead.
|
||||
var labelId = document.evaluate('string(ancestor-or-self::ion-label[@id][1]/@id)', match).stringValue;
|
||||
if (labelId) {
|
||||
var target = document.querySelector('*[aria-labelledby=' + labelId + ']');
|
||||
if (target) {
|
||||
match = target;
|
||||
}
|
||||
}
|
||||
|
||||
// Add to array depending on if it's an exact or partial match.
|
||||
if (matchText === text.toLowerCase()) {
|
||||
exactMatches.push(match);
|
||||
} else {
|
||||
anyMatches.push(match);
|
||||
}
|
||||
});
|
||||
|
||||
// Find all the Aria labels that contain this text.
|
||||
var exactLabelMatches = [];
|
||||
var anyLabelMatches = [];
|
||||
findPossibleMatches('//*[@aria-label and contains(@aria-label, "' + escapedText +
|
||||
'")]', function(match) {
|
||||
// Add to array depending on if it's an exact or partial match.
|
||||
if (match.getAttribute('aria-label').trim() === text) {
|
||||
exactLabelMatches.push(match);
|
||||
} else {
|
||||
anyLabelMatches.push(match);
|
||||
}
|
||||
});
|
||||
|
||||
// If the 'near' text is set, use it to filter results.
|
||||
var nearAncestors = [];
|
||||
if (near !== undefined) {
|
||||
escapedText = near.replace('"', '""');
|
||||
var exactNearMatches = [];
|
||||
var anyNearMatches = [];
|
||||
findPossibleMatches('//*[contains(normalize-space(.), "' + escapedText +
|
||||
'") and not(child::*[contains(normalize-space(.), "' + escapedText +
|
||||
'")])]', function(match) {
|
||||
// Get the text.
|
||||
var matchText = match.innerText.trim();
|
||||
|
||||
// Add to array depending on if it's an exact or partial match.
|
||||
if (matchText === text) {
|
||||
exactNearMatches.push(match);
|
||||
} else {
|
||||
anyNearMatches.push(match);
|
||||
}
|
||||
});
|
||||
|
||||
var nearFound = null;
|
||||
|
||||
// If there is an exact text match, use that (regardless of other matches).
|
||||
if (exactNearMatches.length > 1) {
|
||||
throw new Error('Too many exact matches for near text');
|
||||
} else if (exactNearMatches.length) {
|
||||
nearFound = exactNearMatches[0];
|
||||
}
|
||||
|
||||
if (nearFound === null) {
|
||||
// If there is one partial text match, use that.
|
||||
if (anyNearMatches.length > 1) {
|
||||
throw new Error('Too many partial matches for near text');
|
||||
} else if (anyNearMatches.length) {
|
||||
nearFound = anyNearMatches[0];
|
||||
}
|
||||
}
|
||||
|
||||
if (!nearFound) {
|
||||
throw new Error('No matches for near text');
|
||||
}
|
||||
|
||||
while (nearFound) {
|
||||
nearAncestors.push(nearFound);
|
||||
nearFound = nearFound.parentNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the number of steps up the tree from a specified node before getting to an
|
||||
* ancestor of the 'near' item
|
||||
*
|
||||
* @param {HTMLElement} node HTML node
|
||||
* @returns {number} Number of steps up, or Number.MAX_SAFE_INTEGER if it never matched
|
||||
*/
|
||||
var calculateNearDepth = function(node) {
|
||||
var depth = 0;
|
||||
while (node) {
|
||||
var ancestorDepth = nearAncestors.indexOf(node);
|
||||
if (ancestorDepth !== -1) {
|
||||
return depth + ancestorDepth;
|
||||
}
|
||||
node = node.parentNode;
|
||||
depth++;
|
||||
}
|
||||
return Number.MAX_SAFE_INTEGER;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reduces an array to include only the nearest in each category.
|
||||
*
|
||||
* @param {Array} arr Array to
|
||||
* @return {Array} Array including only the items with minimum 'near' depth
|
||||
*/
|
||||
var filterNonNearest = function(arr) {
|
||||
var nearDepth = arr.map(function(node) {
|
||||
return calculateNearDepth(node);
|
||||
});
|
||||
var minDepth = Math.min.apply(null, nearDepth);
|
||||
return arr.filter(function(element, index) {
|
||||
return nearDepth[index] == minDepth;
|
||||
});
|
||||
};
|
||||
|
||||
// Filter all the category arrays.
|
||||
exactMatches = filterNonNearest(exactMatches);
|
||||
exactLabelMatches = filterNonNearest(exactLabelMatches);
|
||||
anyMatches = filterNonNearest(anyMatches);
|
||||
anyLabelMatches = filterNonNearest(anyLabelMatches);
|
||||
}
|
||||
|
||||
// Select the resulting match. Note this 'do' loop is not really a loop, it is just so we
|
||||
// can easily break out of it as soon as we find a match.
|
||||
var found = null;
|
||||
do {
|
||||
// If there is an exact text match, use that (regardless of other matches).
|
||||
if (exactMatches.length > 1) {
|
||||
throw new Error('Too many exact matches for text');
|
||||
} else if (exactMatches.length) {
|
||||
found = exactMatches[0];
|
||||
break;
|
||||
}
|
||||
|
||||
// If there is an exact label match, use that.
|
||||
if (exactLabelMatches.length > 1) {
|
||||
throw new Error('Too many exact label matches for text');
|
||||
} else if (exactLabelMatches.length) {
|
||||
found = exactLabelMatches[0];
|
||||
break;
|
||||
}
|
||||
|
||||
// If there is one partial text match, use that.
|
||||
if (anyMatches.length > 1) {
|
||||
throw new Error('Too many partial matches for text');
|
||||
} else if (anyMatches.length) {
|
||||
found = anyMatches[0];
|
||||
break;
|
||||
}
|
||||
|
||||
// Finally if there is one partial label match, use that.
|
||||
if (anyLabelMatches.length > 1) {
|
||||
throw new Error('Too many partial label matches for text');
|
||||
} else if (anyLabelMatches.length) {
|
||||
found = anyLabelMatches[0];
|
||||
break;
|
||||
}
|
||||
} while (false);
|
||||
|
||||
if (!found) {
|
||||
throw new Error('No matches for text');
|
||||
}
|
||||
|
||||
return found;
|
||||
};
|
||||
|
||||
/**
|
||||
* Function to find and click an app standard button.
|
||||
*
|
||||
* @param {string} button Type of button to press
|
||||
* @return {string} OK if successful, or ERROR: followed by message
|
||||
*/
|
||||
var behatPressStandard = function(button) {
|
||||
log('Action - Click standard button: ' + button);
|
||||
var selector;
|
||||
switch (button) {
|
||||
case 'back' :
|
||||
selector = 'ion-navbar > button.back-button-md';
|
||||
break;
|
||||
case 'main menu' :
|
||||
selector = 'page-core-mainmenu .tab-button > ion-icon[aria-label=more]';
|
||||
break;
|
||||
case 'page menu' :
|
||||
// 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]';
|
||||
break;
|
||||
default:
|
||||
return 'ERROR: Unsupported standard button type';
|
||||
}
|
||||
var buttons = Array.from(document.querySelectorAll(selector));
|
||||
var foundButton = null;
|
||||
var tooMany = false;
|
||||
buttons.forEach(function(button) {
|
||||
if (button.offsetParent) {
|
||||
if (foundButton === null) {
|
||||
foundButton = button;
|
||||
} else {
|
||||
tooMany = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
if (!foundButton) {
|
||||
return 'ERROR: Could not find button';
|
||||
}
|
||||
if (tooMany) {
|
||||
return 'ERROR: Found too many buttons';
|
||||
}
|
||||
foundButton.click();
|
||||
|
||||
// Mark busy until the button click finishes processing.
|
||||
addPendingDelay();
|
||||
|
||||
return 'OK';
|
||||
};
|
||||
|
||||
/**
|
||||
* When there is a popup, clicks on the backdrop.
|
||||
*
|
||||
* @return {string} OK if successful, or ERROR: followed by message
|
||||
*/
|
||||
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 press arbitrary item based on its text or Aria label.
|
||||
*
|
||||
* @param {string} text Text (full or partial)
|
||||
* @param {string} near Optional 'near' text - if specified, must have a single match on page
|
||||
* @return {string} OK if successful, or ERROR: followed by message
|
||||
*/
|
||||
var behatPress = function(text, near) {
|
||||
log('Action - Press ' + text + (near === undefined ? '' : ' - near ' + near));
|
||||
|
||||
var found;
|
||||
try {
|
||||
found = findElementBasedOnText(text, near);
|
||||
} catch (error) {
|
||||
return 'ERROR: ' + error.message;
|
||||
}
|
||||
|
||||
// Simulate a mouse click on the button.
|
||||
found.scrollIntoView();
|
||||
var rect = found.getBoundingClientRect();
|
||||
var eventOptions = {clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2,
|
||||
bubbles: true, view: window, cancelable: true};
|
||||
setTimeout(function() {
|
||||
found.dispatchEvent(new MouseEvent('mousedown', eventOptions));
|
||||
}, 0);
|
||||
setTimeout(function() {
|
||||
found.dispatchEvent(new MouseEvent('mouseup', eventOptions));
|
||||
}, 0);
|
||||
setTimeout(function() {
|
||||
found.dispatchEvent(new MouseEvent('click', eventOptions));
|
||||
}, 0);
|
||||
|
||||
// Mark busy until the button click finishes processing.
|
||||
addPendingDelay();
|
||||
|
||||
return 'OK';
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the currently displayed page header.
|
||||
*
|
||||
* @return {string} OK: followed by header text if successful, or ERROR: followed by message.
|
||||
*/
|
||||
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 (title.offsetParent) {
|
||||
result = title.innerText.trim();
|
||||
resultCount++;
|
||||
}
|
||||
});
|
||||
|
||||
if (resultCount > 1) {
|
||||
return 'ERROR: Too many possible titles';
|
||||
} else if (!resultCount) {
|
||||
return 'ERROR: No title found';
|
||||
} else {
|
||||
return 'OK:' + result;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Sets the text of a field to the specified value.
|
||||
*
|
||||
* This currently matches fields only based on the placeholder attribute.
|
||||
*
|
||||
* @param {string} field Field name
|
||||
* @param {string} value New value
|
||||
* @return {string} OK or ERROR: followed by message
|
||||
*/
|
||||
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,
|
||||
press : behatPress,
|
||||
setField : behatSetField,
|
||||
getHeader : behatGetHeader,
|
||||
};
|
||||
})();
|
527
lib/tests/behat/behat_app.php
Normal file
527
lib/tests/behat/behat_app.php
Normal file
@ -0,0 +1,527 @@
|
||||
<?php
|
||||
// This file is part of Moodle - http://moodle.org/
|
||||
//
|
||||
// Moodle is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Moodle is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
/**
|
||||
* Mobile/desktop app steps definitions.
|
||||
*
|
||||
* @package core
|
||||
* @category test
|
||||
* @copyright 2018 The Open University
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
|
||||
// NOTE: no MOODLE_INTERNAL test here, this file may be required by behat before including /config.php.
|
||||
|
||||
require_once(__DIR__ . '/../../behat/behat_base.php');
|
||||
|
||||
use Behat\Mink\Exception\DriverException;
|
||||
use Behat\Mink\Exception\ExpectationException;
|
||||
|
||||
/**
|
||||
* 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);
|
||||
|
||||
// Go to page and prepare browser for app.
|
||||
$this->prepare_browser($this->ionicurl);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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_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];
|
||||
}
|
||||
return $url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes Ionic (if it was started) at end of test suite.
|
||||
*
|
||||
* @AfterSuite
|
||||
*/
|
||||
public static function close_ionic() {
|
||||
if (self::$ionicrunning) {
|
||||
fclose(self::$ionicrunning->pipes[1]);
|
||||
|
||||
if (self::is_windows()) {
|
||||
// Using proc_terminate here does not work. It terminates the process but not any
|
||||
// other processes it might have launched. Instead, we need to use an OS-specific
|
||||
// mechanism to kill the process and children based on its pid.
|
||||
exec('taskkill /F /T /PID ' . self::$ionicrunning->pid);
|
||||
} else {
|
||||
// On Unix this actually works, although only due to the 'exec' command inserted
|
||||
// above.
|
||||
proc_terminate(self::$ionicrunning->process);
|
||||
}
|
||||
self::$ionicrunning = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Goes to the app page and then sets up some initial JavaScript so we can use it.
|
||||
*
|
||||
* @param string $url App URL
|
||||
* @throws DriverException If the app fails to load properly
|
||||
*/
|
||||
protected function prepare_browser(string $url) {
|
||||
global $CFG;
|
||||
|
||||
// Visit the Ionic URL and wait for it to load.
|
||||
$this->getSession()->visit($url);
|
||||
$this->spin(
|
||||
function($context, $args) {
|
||||
$title = $context->getSession()->getPage()->find('xpath', '//title');
|
||||
if ($title) {
|
||||
$text = $title->getHtml();
|
||||
if ($text === 'Moodle Desktop') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
throw new DriverException('Moodle app not found in browser');
|
||||
}, false, 30);
|
||||
|
||||
// Run the scripts to install Moodle 'pending' checks.
|
||||
$this->getSession()->executeScript(
|
||||
file_get_contents(__DIR__ . '/app_behat_runtime.js'));
|
||||
|
||||
// Wait until the site login field appears OR the main page.
|
||||
$situation = $this->spin(
|
||||
function($context, $args) {
|
||||
$input = $context->getSession()->getPage()->find('xpath', '//input[@name="url"]');
|
||||
if ($input) {
|
||||
return 'login';
|
||||
}
|
||||
$mainmenu = $context->getSession()->getPage()->find('xpath', '//page-core-mainmenu');
|
||||
if ($mainmenu) {
|
||||
return 'mainpage';
|
||||
}
|
||||
throw new DriverException('Moodle app login URL prompt not found');
|
||||
}, false, 30);
|
||||
|
||||
// If it's the login page, we automatically fill in the URL and leave it on the user/pass
|
||||
// page. If it's the main page, we just leave it there.
|
||||
if ($situation === 'login') {
|
||||
$this->i_set_the_field_in_the_app('Site address', $CFG->wwwroot);
|
||||
$this->i_press_in_the_app('Connect!');
|
||||
}
|
||||
|
||||
// Continue only after JS finishes.
|
||||
$this->wait_for_pending_js();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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->getSession()->evaluateScript('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->getSession()->evaluateScript('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 = $context->getSession()->evaluateScript('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->getSession()->evaluateScript('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) {
|
||||
$result = $this->spin(function($context, $args) {
|
||||
$result = $this->getSession()->evaluateScript('return window.behat.getHeader();');
|
||||
if (substr($result, 0, 3) !== 'OK:') {
|
||||
throw new DriverException('Error getting header - ' . $result);
|
||||
}
|
||||
return $result;
|
||||
});
|
||||
$header = substr($result, 3);
|
||||
if (trim($header) !== trim($text)) {
|
||||
throw new ExpectationException('The header text was not as expected: \'' . $header . '\'',
|
||||
$this->getSession()->getDriver());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches to a newly-opened browser tab.
|
||||
*
|
||||
* This assumes the app opened a new tab.
|
||||
*
|
||||
* @Given /^I switch to the browser tab opened by the app$/
|
||||
* @throws DriverException If there aren't exactly 2 tabs open
|
||||
*/
|
||||
public function i_switch_to_the_browser_tab_opened_by_the_app() {
|
||||
$names = $this->getSession()->getWindowNames();
|
||||
if (count($names) !== 2) {
|
||||
throw new DriverException('Expected to see 2 tabs open, not ' . count($names));
|
||||
}
|
||||
$this->getSession()->switchToWindow($names[1]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the current browser tab.
|
||||
*
|
||||
* This assumes it was opened by the app and you will now get back to the app.
|
||||
*
|
||||
* @Given /^I close the browser tab opened by the app$/
|
||||
* @throws DriverException If there aren't exactly 2 tabs open
|
||||
*/
|
||||
public function i_close_the_browser_tab_opened_by_the_app() {
|
||||
$names = $this->getSession()->getWindowNames();
|
||||
if (count($names) !== 2) {
|
||||
throw new DriverException('Expected to see 2 tabs open, not ' . count($names));
|
||||
}
|
||||
$this->getSession()->getDriver()->executeScript('window.close()');
|
||||
$this->getSession()->switchToWindow($names[0]);
|
||||
}
|
||||
}
|
@ -103,6 +103,11 @@ class behat_hooks extends behat_base {
|
||||
*/
|
||||
protected static $runningsuite = '';
|
||||
|
||||
/**
|
||||
* @var array Array (with tag names in keys) of all tags in current scenario.
|
||||
*/
|
||||
protected static $scenariotags;
|
||||
|
||||
/**
|
||||
* Hook to capture BeforeSuite event so as to give access to moodle codebase.
|
||||
* This will try and catch any exception and exists if anything fails.
|
||||
@ -384,6 +389,35 @@ class behat_hooks extends behat_base {
|
||||
|
||||
// Run all test with medium (1024x768) screen size, to avoid responsive problems.
|
||||
$this->resize_window('medium');
|
||||
|
||||
// Set up the tags for current scenario.
|
||||
self::fetch_tags_for_scenario($scope);
|
||||
|
||||
// If scenario requires the Moodle app to be running, set this up.
|
||||
if ($this->has_tag('app')) {
|
||||
$this->execute('behat_app::start_scenario');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up the tags for the current scenario.
|
||||
*
|
||||
* @param \Behat\Behat\Hook\Scope\BeforeScenarioScope $scope Scope
|
||||
*/
|
||||
protected static function fetch_tags_for_scenario(\Behat\Behat\Hook\Scope\BeforeScenarioScope $scope) {
|
||||
self::$scenariotags = array_flip(array_merge(
|
||||
$scope->getScenario()->getTags(),
|
||||
$scope->getFeature()->getTags()
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the tags for the current scenario
|
||||
*
|
||||
* @return array Array where key is tag name and value is an integer
|
||||
*/
|
||||
public static function get_tags_for_scenario() : array {
|
||||
return self::$scenariotags;
|
||||
}
|
||||
|
||||
/**
|
||||
|
62
mod/forum/tests/behat/app_basic_usage.feature
Normal file
62
mod/forum/tests/behat/app_basic_usage.feature
Normal file
@ -0,0 +1,62 @@
|
||||
@mod @mod_forum @app @javascript
|
||||
Feature: Test basic usage in app
|
||||
In order to participate in the forum while using the mobile app
|
||||
As a student
|
||||
I need basic forum functionality to work
|
||||
|
||||
Background:
|
||||
Given the following "courses" exist:
|
||||
| fullname | shortname |
|
||||
| Course 1 | C1 |
|
||||
And the following "users" exist:
|
||||
| username |
|
||||
| student1 |
|
||||
And the following "course enrolments" exist:
|
||||
| user | course | role |
|
||||
| student1 | C1 | student |
|
||||
And the following "activities" exist:
|
||||
| activity | name | intro | course | idnumber | groupmode |
|
||||
| forum | Test forum name | Test forum | C1 | forum | 0 |
|
||||
|
||||
Scenario: Student starts a discussion
|
||||
When I enter the app
|
||||
And I log in as "student1"
|
||||
And I press "Course 1" near "Course overview" in the app
|
||||
And I press "Test forum name" in the app
|
||||
And I press "Add a new discussion topic" in the app
|
||||
And I set the field "Subject" to "My happy subject" in the app
|
||||
And I set the field "Message" to "An awesome message" in the app
|
||||
And I press "Post to forum" in the app
|
||||
Then I should see "My happy subject"
|
||||
And I should see "An awesome message"
|
||||
|
||||
Scenario: Student posts a reply
|
||||
When I enter the app
|
||||
And I log in as "student1"
|
||||
And I press "Course 1" near "Course overview" in the app
|
||||
And I press "Test forum name" in the app
|
||||
And I press "Add a new discussion topic" in the app
|
||||
And I set the field "Subject" to "DiscussionSubject" in the app
|
||||
And I set the field "Message" to "DiscussionMessage" in the app
|
||||
And I press "Post to forum" in the app
|
||||
And I press "DiscussionSubject" in the app
|
||||
And I press "Reply" in the app
|
||||
And I set the field "Message" to "ReplyMessage" in the app
|
||||
And I press "Post to forum" in the app
|
||||
Then I should see "DiscussionMessage"
|
||||
And I should see "ReplyMessage"
|
||||
|
||||
Scenario: Test that 'open in browser' works for forum
|
||||
When I enter the app
|
||||
And I change viewport size to "360x640"
|
||||
And I log in as "student1"
|
||||
And I press "Course 1" near "Course overview" in the app
|
||||
And I press "Test forum name" in the app
|
||||
And I press the page menu button in the app
|
||||
And I press "Open in browser" in the app
|
||||
And I switch to the browser tab opened by the app
|
||||
And I log in as "student1"
|
||||
Then I should see "Test forum name"
|
||||
And I should see "Add a new discussion topic"
|
||||
And I close the browser tab opened by the app
|
||||
And I press the back button in the app
|
Loading…
x
Reference in New Issue
Block a user